# claud.sale: configure an existing CLI. Keys are entered locally, never as arguments. [CmdletBinding(PositionalBinding=$false)] param([switch]$Restore, [switch]$Help, [Parameter(ValueFromRemainingArguments=$true)][string[]]$UnexpectedArguments) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 2.0 $AppName = 'claude' $Utf8 = New-Object System.Text.UTF8Encoding($false) function Stop-Setup([string]$Message) { $exception = New-Object System.InvalidOperationException($Message) $exception.Data['ClaudsaleSafe'] = $true throw $exception } function Test-OrdinaryFile([string]$Path) { if (Test-Path -LiteralPath $Path) { $item = Get-Item -LiteralPath $Path -Force if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { Stop-Setup 'A managed path is a directory or link. No files were changed; use a regular config file.' } } } function Set-PrivateAcl([string]$Path, [bool]$Directory = $false) { $user = [Security.Principal.WindowsIdentity]::GetCurrent().User $system = New-Object Security.Principal.SecurityIdentifier('S-1-5-18') $acl = if ($Directory) { New-Object Security.AccessControl.DirectorySecurity } else { New-Object Security.AccessControl.FileSecurity } $acl.SetAccessRuleProtection($true, $false) foreach ($sid in @($user, $system)) { $rule = if ($Directory) { New-Object Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') } else { New-Object Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'Allow') } $acl.AddAccessRule($rule) } Set-Acl -LiteralPath $Path -AclObject $acl } function Write-PrivateBytes([string]$Path, [byte[]]$Bytes) { $parent = Split-Path -Parent $Path [void][IO.Directory]::CreateDirectory($parent) Test-OrdinaryFile $Path $tmp = Join-Path $parent ('.claudsale-' + [Guid]::NewGuid().ToString('N') + '.tmp') try { # Protect the file before any key material is written. [IO.File]::WriteAllBytes($tmp, [byte[]]@()) Set-PrivateAcl $tmp [IO.File]::WriteAllBytes($tmp, $Bytes) if (Test-Path -LiteralPath $Path) { Set-PrivateAcl $Path [IO.File]::Replace($tmp, $Path, $null) Set-PrivateAcl $Path } else { [IO.File]::Move($tmp, $Path) } } finally { if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force } } } function Write-PrivateText([string]$Path, [string]$Text) { Write-PrivateBytes $Path $Utf8.GetBytes($Text) } function Get-DottedKey([string]$Text) { $pattern = '\G\s*("(?:[^"\\]|\\.)*"|''[^'']*''|[A-Za-z0-9_-]+)\s*(\.|$)' $parts = New-Object 'System.Collections.Generic.List[string]' $position = 0 while ($position -lt $Text.Length) { $match = [regex]::Match($Text, $pattern, [Text.RegularExpressions.RegexOptions]::None, [TimeSpan]::FromSeconds(2)) # Match against a remaining substring so \G always means its beginning. if ($position -gt 0) { $match = [regex]::Match($Text.Substring($position), $pattern) } if (-not $match.Success -or $match.Index -ne 0) { return $null } $part = $match.Groups[1].Value if ($part.StartsWith('"')) { $part = ConvertFrom-Json -InputObject $part } elseif ($part.StartsWith("'")) { $part = $part.Substring(1, $part.Length - 2) } $parts.Add([string]$part) $position += $match.Length if (-not $match.Groups[2].Value) { return ,$parts.ToArray() } } return $null } function Get-TomlLineState([string]$Line, [hashtable]$State) { $quote = $State.Quote $depth = $State.Depth $index = 0 while ($index -lt $Line.Length) { $char = [string]$Line[$index] if ($quote) { if ($quote.StartsWith('"') -and $char -eq '\') { $index += 2; continue } if ($Line.Substring($index).StartsWith($quote)) { $index += $quote.Length; $quote = $null } else { $index++ } continue } if ($char -eq '#') { break } if ($char -eq '"' -or $char -eq "'") { $triple = $char + $char + $char $quote = if ($Line.Substring($index).StartsWith($triple)) { $triple } else { $char } $index += $quote.Length } else { if ($char -eq '[' -or $char -eq '{') { $depth++ } elseif ($char -eq ']' -or $char -eq '}') { $depth--; if ($depth -lt 0) { Stop-Setup 'Invalid TOML bracket nesting.' } } $index++ } } if ($quote -and $quote.Length -eq 1) { Stop-Setup 'Invalid TOML: a single-line string crosses a line.' } return @{ Quote = $quote; Depth = $depth } } function Edit-CodexToml([string]$Text) { $newline = if ($Text.Contains("`r`n")) { "`r`n" } else { "`n" } $lines = New-Object 'System.Collections.Generic.List[string]' foreach ($match in [regex]::Matches($Text, '[^\r\n]*(?:\r\n|\n|\r|$)')) { if ($match.Length) { $lines.Add($match.Value) } } $root = [ordered]@{ model = '"gpt-5.6-sol"'; model_provider = '"claudsale"' } $owned = [ordered]@{ name = '"claud.sale"'; base_url = '"https://claud.sale/v1"'; wire_api = '"responses"'; env_key = '"CLAUDSALE_API_KEY"'; requires_openai_auth = 'false' } $foundRoot = @{}; $foundOwned = @{} $section = ''; $state = @{ Quote = $null; Depth = 0 } $firstHeader = $lines.Count; $targetEnd = $lines.Count; $targetFound = $false $output = New-Object 'System.Collections.Generic.List[string]' foreach ($line in $lines) { $output.Add($line) } for ($index = 0; $index -lt $lines.Count; $index++) { $line = $lines[$index] if (-not $state.Quote -and $state.Depth -eq 0) { $stripped = $line.Trim() if ($stripped.StartsWith('[')) { $header = [regex]::Match($stripped, '^\[([^\n]*)\]\s*(?:#.*)?$') if (-not $header.Success) { Stop-Setup 'Unsupported TOML table header; configure manually.' } $inside = $header.Groups[1].Value if ($inside.StartsWith('[') -and $inside.EndsWith(']')) { $inside = $inside.Substring(1, $inside.Length - 2) } $parts = Get-DottedKey $inside.Trim() if ($null -eq $parts) { Stop-Setup 'Unsupported TOML table name.' } $firstHeader = [Math]::Min($firstHeader, $index) if ($section -eq 'model_providers/claudsale') { $targetEnd = $index } # A delimiter outside the TOML bare-key alphabet prevents dotted-name confusion. if (@($parts | Where-Object { $_.Contains('/') }).Count) { $section = 'unrelated' } else { $section = $parts -join '/' } if ($section -eq 'model_providers/claudsale') { if ($targetFound -or $stripped.StartsWith('[[')) { Stop-Setup 'Duplicate or invalid claudsale provider table.' } $targetFound = $true; $targetEnd = $lines.Count } } elseif ($stripped -and -not $stripped.StartsWith('#')) { $assignment = [regex]::Match($line.TrimEnd("`r", "`n"), '^\s*((?:"(?:[^"\\]|\\.)*"|''[^'']*''|[A-Za-z0-9_-]+)(?:\s*\.\s*(?:"(?:[^"\\]|\\.)*"|''[^'']*''|[A-Za-z0-9_-]+))*)\s*=\s*(.*)$') if ($assignment.Success) { $key = Get-DottedKey $assignment.Groups[1].Value if ($section -eq '' -and $null -ne $key -and $key.Count -eq 1 -and $key[0] -eq 'profile') { Stop-Setup 'An active default Codex profile is configured. Follow the manual profile instructions; no files changed.' } if ($section -eq '' -and $null -ne $key -and $key[0] -eq 'model_providers') { Stop-Setup 'Inline/dotted model_providers is unsupported. Use [model_providers.claudsale].' } if ($section -eq 'model_providers' -and $null -ne $key -and $key[0] -eq 'claudsale') { Stop-Setup 'Inline claudsale provider is unsupported. Use [model_providers.claudsale].' } $values = @{}; $found = @{} if ($section -eq '') { $values = $root; $found = $foundRoot } elseif ($section -eq 'model_providers/claudsale') { $values = $owned; $found = $foundOwned } if ($null -ne $key -and $key.Count -eq 1 -and $values.Contains($key[0])) { if ($found.ContainsKey($key[0])) { Stop-Setup 'Duplicate managed TOML setting.' } $valueState = Get-TomlLineState $assignment.Groups[2].Value @{ Quote = $null; Depth = 0 } if ($valueState.Quote -or $valueState.Depth -ne 0) { Stop-Setup 'Managed TOML values must be single-line; configure manually.' } $output[$index] = $key[0] + ' = ' + $values[$key[0]] + $newline $found[$key[0]] = $true } } } } $state = Get-TomlLineState $line $state } if ($state.Quote -or $state.Depth -ne 0) { Stop-Setup 'Invalid TOML: unclosed string or array.' } $insertions = @{} $missingRoot = ''; foreach ($key in $root.Keys) { if (-not $foundRoot.ContainsKey($key)) { $missingRoot += $key + ' = ' + $root[$key] + $newline } } if ($missingRoot) { $insertions[$firstHeader] = $missingRoot + $newline } $missingOwned = ''; foreach ($key in $owned.Keys) { if (-not $foundOwned.ContainsKey($key)) { $missingOwned += $key + ' = ' + $owned[$key] + $newline } } if ($targetFound) { if ($missingOwned) { if (-not $insertions.ContainsKey($targetEnd)) { $insertions[$targetEnd] = '' }; $insertions[$targetEnd] += $missingOwned } } else { if (-not $insertions.ContainsKey($lines.Count)) { $insertions[$lines.Count] = '' } $insertions[$lines.Count] += $newline + '[model_providers.claudsale]' + $newline + $missingOwned } $result = New-Object Text.StringBuilder for ($index = 0; $index -le $lines.Count; $index++) { if ($insertions.ContainsKey($index)) { if ($result.Length -gt 0 -and $result[$result.Length - 1] -ne "`n" -and $result[$result.Length - 1] -ne "`r") { [void]$result.Append($newline) } [void]$result.Append($insertions[$index]) } if ($index -lt $lines.Count) { [void]$result.Append($output[$index]) } } return $result.ToString() } function Get-ConfigDirectory { $override = if ($AppName -eq 'codex') { $env:CODEX_HOME } else { $env:CLAUDE_CONFIG_DIR } $default = if ($AppName -eq 'codex') { '.codex' } else { '.claude' } if ($override) { return [IO.Path]::GetFullPath($override) } return Join-Path $HOME $default } function Save-Snapshot([string]$Prefix, [string]$ConfigPath, [string[]]$EnvNames) { [void][IO.Directory]::CreateDirectory($BackupRoot) if ((Get-Item -LiteralPath $BackupRoot -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { Stop-Setup 'Backup directory must not be a link.' } Set-PrivateAcl $BackupRoot $true $dest = Join-Path $BackupRoot ($Prefix + '-' + [DateTime]::UtcNow.ToString('yyyyMMddTHHmmss.fffffffZ') + '-' + [Guid]::NewGuid().ToString('N').Substring(0, 8)) [void][IO.Directory]::CreateDirectory($dest) Set-PrivateAcl $dest $true Test-OrdinaryFile $ConfigPath $exists = Test-Path -LiteralPath $ConfigPath $sddl = $null if ($exists) { $sddl = (Get-Acl -LiteralPath $ConfigPath).Sddl Write-PrivateBytes (Join-Path $dest 'config.bin') ([IO.File]::ReadAllBytes($ConfigPath)) } $savedEnv = [ordered]@{} foreach ($name in $EnvNames) { $savedEnv[$name] = [Environment]::GetEnvironmentVariable($name, 'User') } $manifest = [ordered]@{ app = $AppName; config = $ConfigPath; existed = [bool]$exists; acl = $sddl; environment = $savedEnv } Write-PrivateText (Join-Path $dest 'manifest.json') ($manifest | ConvertTo-Json -Depth 10) return $dest } function Restore-Snapshot([string]$Directory) { $manifest = [IO.File]::ReadAllText((Join-Path $Directory 'manifest.json'), $Utf8) | ConvertFrom-Json if ($manifest.app -ne $AppName) { Stop-Setup 'Backup belongs to another app.' } Test-OrdinaryFile $manifest.config if ($manifest.existed) { Write-PrivateBytes $manifest.config ([IO.File]::ReadAllBytes((Join-Path $Directory 'config.bin'))) if ($manifest.acl) { $acl = New-Object Security.AccessControl.FileSecurity $acl.SetSecurityDescriptorSddlForm($manifest.acl) Set-Acl -LiteralPath $manifest.config -AclObject $acl } } elseif (Test-Path -LiteralPath $manifest.config) { Remove-Item -LiteralPath $manifest.config -Force } foreach ($property in $manifest.environment.PSObject.Properties) { [Environment]::SetEnvironmentVariable($property.Name, $property.Value, 'User') [Environment]::SetEnvironmentVariable($property.Name, $property.Value, 'Process') } } function Invoke-Setup { if (-not (Get-Command $AppName -ErrorAction SilentlyContinue)) { Stop-Setup 'CLI not found in PATH. Install the official CLI first, then rerun this helper.' } $conflicts = @('ANTHROPIC_API_KEY', 'ANTHROPIC_MODEL', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY') if ($AppName -eq 'claude') { foreach ($name in $conflicts) { if ([Environment]::GetEnvironmentVariable($name, 'Machine')) { Stop-Setup ('Conflicting System environment variable: ' + $name + '. Remove it in Windows Environment Variables, then rerun. No files changed.') } } } $keyName = if ($AppName -eq 'codex') { 'CLAUDSALE_API_KEY' } else { 'ANTHROPIC_AUTH_TOKEN' } $key = [Environment]::GetEnvironmentVariable($keyName, 'Process') if (-not $key) { $secure = Read-Host ('claud.sale ' + $AppName + ' API key (hidden)') -AsSecureString $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) try { $key = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer); $secure.Dispose() } } if ($key -notmatch '\Ask-[A-Za-z0-9_-]{8,512}\z') { Stop-Setup 'Invalid API key format. Use a claud.sale sk- key without spaces or shell characters.' } Test-OrdinaryFile $ConfigPath $original = if (Test-Path -LiteralPath $ConfigPath) { [IO.File]::ReadAllText($ConfigPath, $Utf8) } else { '' } $variables = [ordered]@{} $variables[$keyName] = $key if ($AppName -eq 'codex') { $newConfig = Edit-CodexToml $original if ($env:CODEX_HOME) { $variables['CODEX_HOME'] = $ConfigDirectory } } else { $variables['ANTHROPIC_BASE_URL'] = 'https://claud.sale' $variables['ANTHROPIC_DEFAULT_FABLE_MODEL'] = 'claude-fable-5-1' $variables['ANTHROPIC_DEFAULT_OPUS_MODEL'] = 'claude-opus-5' $variables['ANTHROPIC_DEFAULT_SONNET_MODEL'] = 'claude-sonnet-5' $variables['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = 'claude-haiku-4-5-20251001' $variables['CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY'] = '1' foreach ($name in $conflicts) { $variables[$name] = $null } if ($env:CLAUDE_CONFIG_DIR) { $variables['CLAUDE_CONFIG_DIR'] = $ConfigDirectory } $newConfig = Edit-ClaudeJson $original $variables } $changed = $original -cne $newConfig foreach ($name in $variables.Keys) { if ([Environment]::GetEnvironmentVariable($name, 'User') -cne $variables[$name]) { $changed = $true } } if (-not $changed) { Write-Host 'Already configured; no files changed.'; return } $backup = Save-Snapshot 'setup' $ConfigPath @($variables.Keys) try { Write-PrivateText $ConfigPath $newConfig foreach ($name in $variables.Keys) { [Environment]::SetEnvironmentVariable($name, $variables[$name], 'User') [Environment]::SetEnvironmentVariable($name, $variables[$name], 'Process') } Write-PrivateText (Join-Path $BackupRoot 'last-setup') ([IO.Path]::GetFileName($backup)) } catch { Restore-Snapshot $backup Stop-Setup 'Could not apply configuration; the previous configuration was restored.' } $key = $null Write-Host ('Configured ' + $AppName + ' for claud.sale. No API key was sent over the network.') Write-Host ('Configuration: ' + $ConfigPath) Write-Host ('Protected backup: ' + $backup) Write-Host 'Close all terminal windows and restart the editor/app. Open a fresh terminal from Start to load User environment.' } function Edit-ClaudeJson([string]$Text, [System.Collections.IDictionary]$Variables) { $settings = if ($Text.Trim()) { ConvertFrom-Json -InputObject $Text } else { [pscustomobject]@{} } if ($null -eq $settings -or $settings -isnot [pscustomobject]) { Stop-Setup 'settings.json must be a JSON object.' } if (-not $settings.PSObject.Properties['env']) { $settings | Add-Member -NotePropertyName env -NotePropertyValue ([pscustomobject]@{}) } if ($null -eq $settings.env -or $settings.env -isnot [pscustomobject]) { Stop-Setup 'settings.json env must be a JSON object.' } foreach ($name in $Variables.Keys) { if ($name -eq 'CLAUDE_CONFIG_DIR') { continue } if ($null -eq $Variables[$name]) { $settings.env.PSObject.Properties.Remove($name) } else { $settings.env | Add-Member -NotePropertyName $name -NotePropertyValue $Variables[$name] -Force } } $settings | Add-Member -NotePropertyName model -NotePropertyValue 'opus' -Force return ($settings | ConvertTo-Json -Depth 100) + "`n" } function Invoke-Restore { $marker = Join-Path $BackupRoot 'last-setup' if (-not (Test-Path -LiteralPath $marker)) { Stop-Setup 'No setup snapshot to restore.' } $name = [IO.File]::ReadAllText($marker).Trim() if ($name -notmatch '^setup-[0-9TZ.]+-[a-f0-9]{8}$') { Stop-Setup 'Invalid backup marker.' } $directory = Join-Path $BackupRoot $name $manifest = [IO.File]::ReadAllText((Join-Path $directory 'manifest.json'), $Utf8) | ConvertFrom-Json $envNames = @($manifest.environment.PSObject.Properties | ForEach-Object { $_.Name }) $recovery = Save-Snapshot 'before-restore' $manifest.config $envNames try { Restore-Snapshot $directory } catch { Restore-Snapshot $recovery; Stop-Setup 'Restore failed; current configuration was recovered.' } Remove-Item -LiteralPath $marker -Force Write-Host ('Previous setup restored. Current files saved in: ' + $recovery) Write-Host 'Close all terminal windows and restart the app. Open a fresh terminal from Start.' } # BEGIN MAIN if ($Help) { Write-Host ('Usage: powershell.exe -ExecutionPolicy Bypass -File .\' + $AppName + '.ps1 [-Restore]') Write-Host 'Configure an existing CLI. API key comes from environment or a hidden local prompt, never an argument.' Write-Host 'Windows PowerShell 5.1+ or PowerShell 7 on Windows. No administrator privileges required.' exit 0 } try { if ($UnexpectedArguments) { Stop-Setup 'Unexpected argument. Never pass an API key as an argument; use environment or the hidden prompt.' } if ($env:OS -ne 'Windows_NT') { Stop-Setup 'This helper is for Windows. On Linux use the .sh helper.' } if ($PSVersionTable.PSVersion.Major -lt 5) { Stop-Setup 'PowerShell 5.1 or newer is required.' } $ConfigDirectory = Get-ConfigDirectory $BackupRoot = Join-Path $ConfigDirectory 'claudsale-backups' $fileName = if ($AppName -eq 'codex') { 'config.toml' } else { 'settings.json' } $ConfigPath = Join-Path $ConfigDirectory $fileName if ($Restore) { Invoke-Restore } else { Invoke-Setup } } catch { # Do not print exception internals: a JSON parser can include a secret input line. $safe = 'Check file permissions and existing TOML/JSON syntax. No secret is printed.' $errorObject = $_.Exception while ($null -ne $errorObject) { if ($errorObject.Data.Contains('ClaudsaleSafe')) { $safe = $errorObject.Message; break } $errorObject = $errorObject.InnerException } Write-Host ('Setup stopped: ' + $safe) -ForegroundColor Red exit 1 }