# GreenTokens setup for Windows (PowerShell): # # irm https://www.greentokens.io/setup.ps1 | iex # # Connects coding agents to GreenTokens: Claude Code, Claude Desktop, Codex # CLI, OpenCode, Grok CLI, OpenClaw and Hermes Agent. You pick the apps; it # confirms each file before changing it, backs up every file it edits, and # changes only the GreenTokens parts. Run it again and choose "Remove" to undo. # macOS and Linux: https://www.greentokens.io/setup.sh # # Claude Code and Claude Desktop keep the key in their own settings. The other # agents read it from the GREENTOKENS_API_KEY user environment variable. # # Everything runs inside one script block: a partly downloaded script fails to # parse and does nothing, and nothing it defines stays in your session. It # never calls `exit`, which would close the PowerShell window under `iex`. & { $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $BaseUrl = 'https://api.greentokens.io' $DocsUrl = 'https://www.greentokens.io/docs/clients' $KeysUrl = 'https://www.greentokens.io/dashboard/api-keys' $OpusModel = 'claude-opus-5-5' $SonnetModel = 'claude-sonnet-5' $HaikuModel = 'claude-haiku-4-5' $CodexModel = 'gpt-6-sol' $GrokModel = 'grok-4.7' $AgentModel = 'claude-sonnet-5' $KeyVar = 'GREENTOKENS_API_KEY' # Claude Desktop reads user policy here; machine policy (HKLM), when present, wins outright. $PolicyKey = 'HKCU:\SOFTWARE\Policies\Claude' $MachinePolicyKey = 'HKLM:\SOFTWARE\Policies\Claude' $DesktopValues = @('inferenceProvider', 'inferenceGatewayBaseUrl', 'inferenceGatewayApiKey') $Utf8NoBom = New-Object Text.UTF8Encoding $false # Filled in as steps finish; functions add to it, which needs no scope modifier. $Done = New-Object System.Collections.Generic.List[string] function Say([string]$Text = '') { Write-Host $Text } function Note([string]$Text) { Write-Host $Text -ForegroundColor DarkGray } function Heading([string]$Text) { Write-Host ''; Write-Host $Text } function Ok([string]$Text) { Write-Host '+ ' -ForegroundColor Green -NoNewline; Write-Host $Text } function Warn([string]$Text) { Write-Host '! ' -ForegroundColor Yellow -NoNewline; Write-Host $Text } # Stops the setup; the catch at the bottom prints the message. function Stop-Setup([string]$Text) { throw "GT_STOP:$Text" } function Show-Path([string]$Path) { if ($Path.StartsWith($HOME, [StringComparison]::OrdinalIgnoreCase)) { return '~' + $Path.Substring($HOME.Length) } return $Path } function Confirm-Choice([string]$Question, [bool]$Default) { $hint = if ($Default) { '[Y/n]' } else { '[y/N]' } while ($true) { $answer = "$(Read-Host "$Question $hint")".Trim() if ($answer -eq '') { return $Default } if ($answer -match '^(y|yes)$') { return $true } if ($answer -match '^(n|no)$') { return $false } Say 'Please answer y or n.' } } # Returns the path typed, or '' to skip. Accepts quotes, ~ and %VARIABLES%. function Read-PathAnswer([string]$Prompt) { $path = "$(Read-Host "$Prompt, or press Enter to skip")".Trim().Trim('"').Trim("'") if ($path -eq '~') { $path = $HOME } elseif ($path.StartsWith('~\') -or $path.StartsWith('~/')) { $path = Join-Path $HOME $path.Substring(2) } return [Environment]::ExpandEnvironmentVariables($path) } function Test-Command([string]$Name) { return $null -ne (Get-Command $Name -ErrorAction SilentlyContinue) } # --- API key ---------------------------------------------------------------- function Read-Secret([string]$Prompt) { $secure = Read-Host $Prompt -AsSecureString $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } # The HTTP status of a free, authenticated call (token counting is never # billed), or 0 when GreenTokens can't be reached. function Test-Key([string]$Key) { $body = '{"model":"' + $HaikuModel + '","messages":[{"role":"user","content":"hi"}]}' try { $response = Invoke-WebRequest -Uri "$BaseUrl/v1/messages/count_tokens" -Method Post -UseBasicParsing -TimeoutSec 20 ` -Headers @{ 'x-api-key' = $Key; 'anthropic-version' = '2023-06-01' } -ContentType 'application/json' -Body $body return [int]$response.StatusCode } catch { $exception = $_.Exception if ($exception.PSObject.Properties['Response'] -and $exception.Response) { return [int]$exception.Response.StatusCode } return 0 } } function Read-Key { Heading 'API key' Say 'Paste your GreenTokens API key. It starts with sk-gt- and stays hidden as you type.' Note "No key yet? Create one at $KeysUrl" foreach ($attempt in 1..3) { $key = (Read-Secret 'API key') -replace '\s', '' if ($key -eq '') { Warn 'No key entered.'; continue } # Only characters an API key can have. This also keeps it safe to write into JSON, TOML, YAML and a .reg file. if ($key -notmatch '^[A-Za-z0-9._~+/=-]+$') { Warn "That key has characters an API key can't have. Copy it again from the dashboard." continue } if (-not $key.StartsWith('sk-gt-')) { if (-not (Confirm-Choice "That doesn't look like a GreenTokens key (they start with sk-gt-). Use it anyway?" $false)) { continue } } $code = Test-Key $key if ($code -ge 200 -and $code -lt 300) { Ok 'Key works.'; return $key } if ($code -eq 401 -or $code -eq 403) { Warn "GreenTokens didn't accept that key."; continue } if ($code -eq 0) { Warn "Couldn't reach GreenTokens to check the key." } else { Warn "Couldn't check the key (HTTP $code)." } if (Confirm-Choice 'Save it anyway?' $false) { return $key } Stop-Setup 'Nothing was changed.' } Stop-Setup 'No working key after 3 tries. Nothing was changed.' } # --- File editing ----------------------------------------------------------- # # Each Edit-* function takes a file's current text ('' for a new file), the # mode ('set' or 'remove') and the key, and returns the new text. When there # is nothing to remove, the text comes back unchanged. function Split-Lines([string]$Text) { if ($Text -eq '') { return , @() } $lines = $Text -split "\r?\n" if ($lines[-1] -eq '') { $lines = $lines[0..($lines.Count - 2)] } return , @($lines) } # Joins lines into file text, dropping trailing blank lines. function Join-Lines($Lines) { $list = @($Lines) $end = $list.Count while ($end -gt 0 -and $list[$end - 1] -match '^\s*$') { $end-- } if ($end -eq 0) { return '' } return ($list[0..($end - 1)] -join "`n") + "`n" } function Test-JsonObject($Value) { return $null -ne $Value -and $Value.GetType().FullName -eq 'System.Management.Automation.PSCustomObject' } function Set-JsonProperty($Object, [string]$Name, $Value) { if ($Object.PSObject.Properties[$Name]) { $Object.$Name = $Value } else { $Object | Add-Member -NotePropertyName $Name -NotePropertyValue $Value } } function Read-JsonObject([string]$Text) { # PowerShell 7 reads JSON with comments but writes it back without them, # so refuse those files rather than silently dropping the comments. if (($Text -replace '"(?:[^"\\]|\\.)*"', '""') -match '//|/\*') { throw 'The file has comments.' } $data = if ($Text.Trim()) { ConvertFrom-Json -InputObject $Text } else { New-Object PSObject } if (-not (Test-JsonObject $data)) { throw 'The file is not a JSON object.' } return $data } # Claude Code's settings.json: the GreenTokens variables in its "env" block. function Edit-ClaudeCode([string]$Text, [string]$Mode, [string]$Key) { $data = Read-JsonObject $Text $envProperty = $data.PSObject.Properties['env'] if ($envProperty -and -not (Test-JsonObject $envProperty.Value)) { throw '"env" is not an object.' } if ($Mode -eq 'set') { if (-not $envProperty) { $data | Add-Member -NotePropertyName 'env' -NotePropertyValue (New-Object PSObject) } $data.env.PSObject.Properties.Remove('ANTHROPIC_API_KEY') Set-JsonProperty $data.env 'ANTHROPIC_BASE_URL' $BaseUrl Set-JsonProperty $data.env 'ANTHROPIC_AUTH_TOKEN' $Key Set-JsonProperty $data.env 'ANTHROPIC_DEFAULT_OPUS_MODEL' $OpusModel Set-JsonProperty $data.env 'ANTHROPIC_DEFAULT_SONNET_MODEL' $SonnetModel Set-JsonProperty $data.env 'ANTHROPIC_DEFAULT_HAIKU_MODEL' $HaikuModel } else { if (-not $envProperty -or $data.env.ANTHROPIC_BASE_URL -ne $BaseUrl) { return $Text } foreach ($name in @('ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL')) { $data.env.PSObject.Properties.Remove($name) } if (@($data.env.PSObject.Properties).Count -eq 0) { $data.PSObject.Properties.Remove('env') } } return (ConvertTo-Json -InputObject $data -Depth 100) + "`n" } # OpenCode's opencode.json: a "greentokens" provider and the default model. function Edit-Opencode([string]$Text, [string]$Mode, [string]$Key) { $data = Read-JsonObject $Text $providerProperty = $data.PSObject.Properties['provider'] if ($providerProperty -and -not (Test-JsonObject $providerProperty.Value)) { throw '"provider" is not an object.' } if ($Mode -eq 'set') { if (-not $Text.Trim()) { Set-JsonProperty $data '$schema' 'https://opencode.ai/config.json' } if (-not $providerProperty) { $data | Add-Member -NotePropertyName 'provider' -NotePropertyValue (New-Object PSObject) } $provider = [pscustomobject][ordered]@{ npm = '@ai-sdk/openai-compatible' name = 'GreenTokens' options = [pscustomobject][ordered]@{ baseURL = "$BaseUrl/v1"; apiKey = '{env:GREENTOKENS_API_KEY}' } models = [pscustomobject][ordered]@{ 'claude-sonnet-5' = [pscustomobject]@{ name = 'Claude Sonnet 5' } 'claude-opus-5-5' = [pscustomobject]@{ name = 'Claude Opus 5.5' } 'gpt-6-sol' = [pscustomobject]@{ name = 'GPT-6 Sol' } 'glm-5.3' = [pscustomobject]@{ name = 'GLM-5.3' } } } Set-JsonProperty $data.provider 'greentokens' $provider Set-JsonProperty $data 'model' "greentokens/$AgentModel" } else { $changed = $false if ($providerProperty -and $data.provider.PSObject.Properties['greentokens']) { $data.provider.PSObject.Properties.Remove('greentokens') if (@($data.provider.PSObject.Properties).Count -eq 0) { $data.PSObject.Properties.Remove('provider') } $changed = $true } if ($data.PSObject.Properties['model'] -and "$($data.model)".StartsWith('greentokens/')) { $data.PSObject.Properties.Remove('model') $changed = $true } if (-not $changed) { return $Text } } return (ConvertTo-Json -InputObject $data -Depth 100) + "`n" } # Codex's config.toml: the top-level model and model_provider (which must # come before any [table]) and a [model_providers.greentokens] table. Remove # drops the table, and the top-level keys only while they point at it. function Edit-Codex([string]$Text, [string]$Mode, [string]$Key) { $lines = Split-Lines $Text $set = $Mode -eq 'set' $inTable = $false $providerIsOurs = $false foreach ($line in $lines) { if ($line -cmatch '^\s*\[') { $inTable = $true } elseif (-not $inTable -and $line -cmatch '^\s*model_provider\s*=\s*["'']greentokens["'']') { $providerIsOurs = $true } } $out = New-Object System.Collections.Generic.List[string] if ($set) { $out.Add("model = `"$CodexModel`""); $out.Add('model_provider = "greentokens"') } $inTable = $false $skip = $false foreach ($line in $lines) { if ($line -cmatch '^\s*\[') { $inTable = $true $skip = $line -cmatch '^\s*\[\s*model_providers\.greentokens\s*\]\s*(#.*)?$' } if ($skip) { continue } if (-not $inTable -and ($set -or $providerIsOurs) -and $line -cmatch '^\s*model(_provider)?\s*=') { continue } $out.Add($line) } $result = Join-Lines $out if ($set) { $result += "`n[model_providers.greentokens]`nname = `"GreenTokens`"`nbase_url = `"$BaseUrl/v1`"`n" + "env_key = `"$KeyVar`"`nwire_api = `"responses`"`n" } return $result } # Grok CLI's config.toml: a [model."..."] table per model and [models] # default. Our tables are the [model.*] ones whose base_url is GreenTokens; # remove drops those, and the default only if it named one. function Edit-Grok([string]$Text, [string]$Mode, [string]$Key) { $lines = Split-Lines $Text $set = $Mode -eq 'set' $ours = @{ $GrokModel = 'Grok 4.7'; $AgentModel = 'Claude Sonnet 5' } # First pass: which [model.*] tables to drop. When setting, that includes # any table with one of our model names, since TOML refuses the same table twice. $section = 0 $sections = @{} $hasModels = $false foreach ($line in $lines) { if ($line -cmatch '^\s*\[') { $section++ $name = if ($line -match '"([^"]*)"') { $Matches[1] } else { '' } $sections[$section] = @{ IsModel = $line -cmatch '^\s*\[\s*model\.'; Name = $name; Ours = $false } if ($line -cmatch '^\s*\[\s*models\s*\]') { $hasModels = $true } } elseif ($section -gt 0 -and $line.Contains('base_url') -and $line.Contains("`"$BaseUrl/v1`"")) { $sections[$section].Ours = $true } } $dropped = @() foreach ($entry in $sections.GetEnumerator()) { $s = $entry.Value if ($s.IsModel -and ($s.Ours -or ($set -and $ours.ContainsKey($s.Name)))) { $s.Drop = $true; $dropped += $s.Name } } $out = New-Object System.Collections.Generic.List[string] $section = 0 $inModels = $false foreach ($line in $lines) { $isHeader = $line -cmatch '^\s*\[' if ($isHeader) { $section++; $inModels = $line -cmatch '^\s*\[\s*models\s*\]' } if ($section -gt 0 -and $sections[$section].ContainsKey('Drop')) { continue } if ($inModels -and $line -cmatch '^\s*default\s*=') { $value = if ($line -match '"([^"]*)"') { $Matches[1] } else { '' } if ($set -or $dropped -contains $value) { continue } } $out.Add($line) if ($isHeader -and $inModels -and $set) { $out.Add("default = `"$GrokModel`"") } } $result = Join-Lines $out if ($set) { if (-not $hasModels) { $result += "`n[models]`ndefault = `"$GrokModel`"`n" } foreach ($model in @($GrokModel, $AgentModel)) { $result += "`n[model.`"$model`"]`nmodel = `"$model`"`nname = `"$($ours[$model]) (GreenTokens)`"`n" + "base_url = `"$BaseUrl/v1`"`nenv_key = `"$KeyVar`"`napi_backend = `"chat_completions`"`n" } } return $result } # Hermes Agent's config.yaml: the top-level model: block (it runs until the # next line that starts at column 0). Remove drops it only when it points at # GreenTokens. function Edit-Hermes([string]$Text, [string]$Mode, [string]$Key) { $lines = Split-Lines $Text $set = $Mode -eq 'set' $inBlock = $false $ours = $false foreach ($line in $lines) { if ($line -cmatch '^model\s*:') { $inBlock = $true } elseif ($line -notmatch '^\s*$' -and $line -match '^\S') { $inBlock = $false } elseif ($inBlock -and $line.Contains("$BaseUrl/v1")) { $ours = $true } } if (-not $set -and -not $ours) { return $Text } $out = New-Object System.Collections.Generic.List[string] $inBlock = $false foreach ($line in $lines) { if ($line -cmatch '^model\s*:') { $inBlock = $true; continue } if ($inBlock -and $line -notmatch '^\s*$' -and $line -match '^\S') { $inBlock = $false } if ($inBlock) { continue } $out.Add($line) } $result = Join-Lines $out if ($set) { $result += "`nmodel:`n provider: custom`n default: $AgentModel`n base_url: $BaseUrl/v1`n key_env: $KeyVar`n" } return $result } # Hermes Agent's .env: one GREENTOKENS_API_KEY= line. function Edit-HermesEnv([string]$Text, [string]$Mode, [string]$Key) { $lines = Split-Lines $Text $lines = @($lines | Where-Object { $_ -cnotmatch "^\s*(export\s+)?$KeyVar\s*=" }) if ($Mode -eq 'set') { $lines += "$KeyVar=$Key" } return Join-Lines $lines } # Runs an Edit-* function on a file and writes the result back, after a # backup. Returns 'changed', 'unchanged' or 'failed'. function Update-File([string]$Mode, [string]$Path, [string]$Editor, [string]$Key) { $exists = Test-Path -LiteralPath $Path -PathType Leaf if (-not $exists -and $Mode -eq 'remove') { return 'unchanged' } try { $old = if ($exists) { [IO.File]::ReadAllText($Path) } else { '' } $new = & $Editor $old $Mode $Key if ($new -ceq $old) { return 'unchanged' } $dir = Split-Path -Parent $Path if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } if ($exists) { $backup = "$Path.backup-$(Get-Date -Format 'yyyyMMdd-HHmmss')" Copy-Item -LiteralPath $Path -Destination $backup Note "Backup: $(Show-Path $backup)" } # UTF-8 without a byte-order mark, which some of these apps can't parse. [IO.File]::WriteAllText($Path, $new, $Utf8NoBom) return 'changed' } catch { return 'failed' } } # Reports an Update-File result; returns $false when it failed. function Show-Edit([string]$Result, [string]$Mode, [string]$Path, [string]$Format) { switch ($Result) { 'changed' { if ($Mode -eq 'set') { Ok "Updated $(Show-Path $Path)." } else { Ok "Removed GreenTokens from $(Show-Path $Path)." } } 'unchanged' { if ($Mode -eq 'set') { Ok "$(Show-Path $Path) was already set up." } else { Say "No GreenTokens settings in $(Show-Path $Path)." } } default { Warn "Couldn't update $(Show-Path $Path): it couldn't be read as $Format, or couldn't be written. Nothing was changed." } } return $Result -ne 'failed' } # --- Apps ------------------------------------------------------------------- function Invoke-ClaudeCode([string]$Mode, [string]$Path, [string]$Key) { $hadApiKey = $Mode -eq 'set' -and (Test-Path -LiteralPath $Path) -and ([IO.File]::ReadAllText($Path) -match '"ANTHROPIC_API_KEY"') $result = Update-File $Mode $Path 'Edit-ClaudeCode' $Key if (-not (Show-Edit $result $Mode $Path 'JSON')) { return } if ($Mode -eq 'remove') { if ($result -eq 'changed') { $Done.Add('Claude Code: new sessions use your previous setup again.') } return } if ($hadApiKey) { Note 'Removed ANTHROPIC_API_KEY from that file, since it would clash with the GreenTokens key.' } if ($env:ANTHROPIC_API_KEY -or $env:ANTHROPIC_BASE_URL) { Warn 'Your environment also sets ANTHROPIC_API_KEY or ANTHROPIC_BASE_URL, which can override these settings.' Say ' If Claude Code does not use GreenTokens, remove them in System Properties > Environment Variables.' } $Done.Add('Claude Code: start a new session with `claude` and it uses GreenTokens.') } function Invoke-Codex([string]$Mode, [string]$Path, [string]$Key) { $result = Update-File $Mode $Path 'Edit-Codex' $Key if (-not (Show-Edit $result $Mode $Path 'TOML')) { return } if ($Mode -eq 'set') { $Done.Add("Codex CLI: run ``codex``; it uses $CodexModel on GreenTokens. Pick another with ``codex --model``.") } elseif ($result -eq 'changed') { $Done.Add('Codex CLI: back to its default provider.') } } function Invoke-Opencode([string]$Mode, [string]$Path, [string]$Key) { $result = Update-File $Mode $Path 'Edit-Opencode' $Key if (-not (Show-Edit $result $Mode $Path "JSON (files with comments aren't supported)")) { return } if ($Mode -eq 'set') { $Done.Add('OpenCode: run `opencode`; GreenTokens models are under the GreenTokens provider.') } elseif ($result -eq 'changed') { $Done.Add('OpenCode: GreenTokens provider removed.') } } function Invoke-Grok([string]$Mode, [string]$Path, [string]$Key) { $result = Update-File $Mode $Path 'Edit-Grok' $Key if (-not (Show-Edit $result $Mode $Path 'TOML')) { return } if ($Mode -eq 'set') { $Done.Add("Grok CLI: run ``grok`` (uses $GrokModel) or ``grok -m $AgentModel``.") } elseif ($result -eq 'changed') { $Done.Add('Grok CLI: GreenTokens models removed.') } } function Invoke-Hermes([string]$Mode, [string]$Path, [string]$Key) { $result = Update-File $Mode $Path 'Edit-Hermes' $Key if (-not (Show-Edit $result $Mode $Path 'YAML')) { return } $envFile = Join-Path (Split-Path -Parent $Path) '.env' $result = Update-File $Mode $envFile 'Edit-HermesEnv' $Key if (-not (Show-Edit $result $Mode $envFile 'a .env file')) { return } if ($Mode -eq 'set') { $Done.Add("Hermes Agent: run ``hermes``; it uses $AgentModel on GreenTokens.") } else { $Done.Add('Hermes Agent: run `hermes model` to choose a provider again.') } } # Runs the openclaw command and returns @{ Ok; Output }. Errors it prints are # captured, not thrown. function Invoke-OpenClawCommand([string]$Command, [string[]]$Arguments) { $ErrorActionPreference = 'Continue' $output = & $Command @Arguments 2>&1 | Out-String return @{ Ok = ($LASTEXITCODE -eq 0); Output = $output.Trim() } } # OpenClaw is set up through its own `openclaw config` command, as our docs # show, rather than by editing its config file. function Invoke-OpenClaw([string]$Mode, [string]$Path, [string]$Key) { if ($Mode -eq 'set') { $json = '{"baseUrl":"' + $BaseUrl + '/v1","apiKey":"${GREENTOKENS_API_KEY}","api":"openai-completions","models":[' + '{"id":"claude-sonnet-5","name":"Claude Sonnet 5"},{"id":"claude-opus-5-5","name":"Claude Opus 5.5"},' + '{"id":"gpt-6-sol","name":"GPT-6 Sol"},{"id":"glm-5.3","name":"GLM-5.3"}]}' # Windows PowerShell, and newer versions calling a .cmd (as npm installs # openclaw), drop the quotes inside an argument unless they're escaped. $passing = Get-Variable PSNativeCommandArgumentPassing -ValueOnly -ErrorAction SilentlyContinue if ($PSVersionTable.PSVersion -lt [version]'7.3' -or $passing -eq 'Legacy' -or ($passing -ne 'Standard' -and $Path -match '\.(cmd|bat)$')) { $json = $json -replace '"', '\"' } $run = Invoke-OpenClawCommand $Path @('config', 'set', 'models.providers.greentokens', $json, '--strict-json', '--merge') if ($run.Ok) { $run = Invoke-OpenClawCommand $Path @('config', 'set', 'agents.defaults.model.primary', "greentokens/$AgentModel") } if ($run.Ok) { Ok 'Added the GreenTokens provider to OpenClaw.' $Done.Add("OpenClaw: its default model is now greentokens/$AgentModel.") } else { Warn "OpenClaw didn't accept the settings:" Say $run.Output Say " Follow $DocsUrl/openclaw to add them by hand." } return } $run = Invoke-OpenClawCommand $Path @('config', 'unset', 'models.providers.greentokens') if ($run.Ok) { Ok 'Removed the GreenTokens provider from OpenClaw.' $primary = Invoke-OpenClawCommand $Path @('config', 'get', 'agents.defaults.model.primary') if ($primary.Ok -and $primary.Output -match 'greentokens/') { Invoke-OpenClawCommand $Path @('config', 'unset', 'agents.defaults.model.primary') | Out-Null } $Done.Add('OpenClaw: GreenTokens removed. Pick a default model again if it was using GreenTokens.') } else { Warn "OpenClaw couldn't remove the provider:" Say $run.Output Say " Remove models.providers.greentokens from OpenClaw's config by hand." } } # --- Claude Desktop --------------------------------------------------------- function Find-ClaudeDesktop { try { $package = Get-AppxPackage -Name '*Claude*' -ErrorAction SilentlyContinue | Where-Object { $_.Publisher -like '*Anthropic*' } | Select-Object -First 1 if ($package -and $package.InstallLocation) { return $package.InstallLocation } } catch { } foreach ($path in @("$env:LOCALAPPDATA\AnthropicClaude\claude.exe", "$env:LOCALAPPDATA\Programs\Claude\Claude.exe")) { if (Test-Path -LiteralPath $path) { return $path } } return '' } function Get-PolicyValue([string]$Name) { $item = Get-ItemProperty -LiteralPath $PolicyKey -ErrorAction SilentlyContinue if ($null -eq $item -or -not $item.PSObject.Properties[$Name]) { return $null } return $item.$Name } function Test-MachinePolicy { $item = Get-Item -LiteralPath $MachinePolicyKey -ErrorAction SilentlyContinue return $null -ne $item -and $item.ValueCount -gt 0 } # Writes Claude Desktop's user policy: `$Values` maps names to strings, or to # $null to delete them. Windows lets only administrators write policy keys, # so this imports a .reg file through an administrator prompt. It targets # this account's hive by SID, so it lands here even if Windows asks for a # different administrator's password. function Set-DesktopPolicy([System.Collections.IDictionary]$Values) { $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value $lines = @('Windows Registry Editor Version 5.00', '', "[HKEY_USERS\$sid\SOFTWARE\Policies\Claude]") foreach ($name in $Values.Keys) { if ($null -eq $Values[$name]) { $lines += "`"$name`"=-" } else { $lines += "`"$name`"=`"$($Values[$name])`"" } } $regFile = Join-Path $env:TEMP "greentokens-$([guid]::NewGuid()).reg" [IO.File]::WriteAllText($regFile, ($lines -join "`r`n") + "`r`n", [Text.Encoding]::Unicode) try { $process = Start-Process -FilePath "$env:SystemRoot\System32\reg.exe" -ArgumentList @('import', "`"$regFile`"") ` -Verb RunAs -Wait -PassThru -WindowStyle Hidden return $process.ExitCode -eq 0 } catch { # The administrator prompt was declined. return $false } finally { Remove-Item -LiteralPath $regFile -Force -ErrorAction SilentlyContinue } } function Invoke-ClaudeDesktop([string]$Mode, [string]$Path, [string]$Key) { if ($Mode -eq 'remove') { if ((Get-PolicyValue 'inferenceGatewayBaseUrl') -ne $BaseUrl) { Say "Claude Desktop isn't set up for GreenTokens. Nothing to remove." return } Say 'Windows will ask for administrator permission to remove the settings.' $values = [ordered]@{} foreach ($name in $DesktopValues) { $values[$name] = $null } if (-not (Set-DesktopPolicy $values) -or $null -ne (Get-PolicyValue 'inferenceGatewayBaseUrl')) { Warn "The settings couldn't be removed." return } Ok 'Removed GreenTokens from Claude Desktop.' $Done.Add('Claude Desktop: quit it fully and reopen it to return to its normal sign-in.') return } if (Test-MachinePolicy) { Warn "Claude Desktop on this computer is managed by an administrator's machine policy, so it would ignore these settings." return } Say 'Windows will ask for administrator permission to save the settings.' $ok = Set-DesktopPolicy ([ordered]@{ inferenceProvider = 'gateway' inferenceGatewayBaseUrl = $BaseUrl inferenceGatewayApiKey = $Key }) if (-not $ok -or (Get-PolicyValue 'inferenceGatewayBaseUrl') -ne $BaseUrl) { Warn "Claude Desktop wasn't changed: the settings couldn't be saved." return } Ok 'Claude Desktop is set to use GreenTokens.' $Done.Add('Claude Desktop: quit it fully (right-click its tray icon > Quit) and reopen it. On the sign-in screen, choose the third-party option, not the Anthropic sign-in.') } # --- Choosing apps and paths ------------------------------------------------ function New-App([string]$Id, [string]$Name, [string]$Default, [bool]$Found, [string]$Kind, [bool]$UsesKeyVar) { return @{ Id = $Id; Name = $Name; Default = $Default; Found = $Found; Kind = $Kind; UsesKeyVar = $UsesKeyVar; Selected = $false; Path = '' } } function Get-Apps { $claudeDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME '.claude' } $codexDir = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $HOME '.codex' } $opencodeDir = Join-Path $(if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME '.config' }) 'opencode' $opencodeFile = Join-Path $opencodeDir 'opencode.json' if (-not (Test-Path -LiteralPath $opencodeFile) -and (Test-Path -LiteralPath (Join-Path $opencodeDir 'opencode.jsonc'))) { $opencodeFile = Join-Path $opencodeDir 'opencode.jsonc' } $grokDir = Join-Path $HOME '.grok' $hermesDir = Join-Path $HOME '.hermes' $desktop = Find-ClaudeDesktop $openclaw = Get-Command openclaw -ErrorAction SilentlyContinue $openclawPath = if ($openclaw) { $openclaw.Source } else { '' } return @( (New-App 'ClaudeCode' 'Claude Code' (Join-Path $claudeDir 'settings.json') ((Test-Command claude) -or (Test-Path -LiteralPath $claudeDir)) 'file' $false), (New-App 'ClaudeDesktop' 'Claude Desktop' $desktop ([bool]$desktop) 'desktop' $false), (New-App 'Codex' 'Codex CLI' (Join-Path $codexDir 'config.toml') ((Test-Command codex) -or (Test-Path -LiteralPath $codexDir)) 'file' $true), (New-App 'Opencode' 'OpenCode' $opencodeFile ((Test-Command opencode) -or (Test-Path -LiteralPath $opencodeDir)) 'file' $true), (New-App 'Grok' 'Grok CLI' (Join-Path $grokDir 'config.toml') ((Test-Command grok) -or (Test-Path -LiteralPath $grokDir)) 'file' $true), (New-App 'OpenClaw' 'OpenClaw' $openclawPath ([bool]$openclawPath) 'command' $true), (New-App 'Hermes' 'Hermes Agent' (Join-Path $hermesDir 'config.yaml') ((Test-Command hermes) -or (Test-Path -LiteralPath $hermesDir)) 'file' $true) ) } # Lists the apps and marks the ones chosen as Selected. function Select-Apps($Apps, [string]$Mode) { if ($Mode -eq 'set') { Heading 'Which apps should use GreenTokens?' } else { Heading 'Which apps should GreenTokens be removed from?' } $found = @() for ($i = 0; $i -lt $Apps.Count; $i++) { Write-Host (' {0}) {1,-16} ' -f ($i + 1), $Apps[$i].Name) -NoNewline if ($Apps[$i].Found) { Write-Host 'found' -ForegroundColor Green; $found += ($i + 1) } else { Write-Host 'not found' -ForegroundColor DarkGray } } while ($true) { if ($found.Count -gt 0) { $answer = "$(Read-Host "Type the numbers (for example: 1 3), `"all`", or press Enter for the found ones [$($found -join ' ')]")".Trim() if ($answer -eq '') { $answer = $found -join ' ' } } else { $answer = "$(Read-Host 'Type the numbers (for example: 1 3) or "all"')".Trim() } foreach ($app in $Apps) { $app.Selected = $false } $valid = $answer -ne '' foreach ($token in ($answer -split '[\s,]+' | Where-Object { $_ })) { if ($token -eq 'all') { foreach ($app in $Apps) { $app.Selected = $true } } elseif ($token -match '^\d+$' -and [int]$token -ge 1 -and [int]$token -le $Apps.Count) { $Apps[[int]$token - 1].Selected = $true } else { $valid = $false } } if ($valid) { return } Say 'Type numbers from the list, separated by spaces.' } } # Confirms (or asks for) an app's path into its Path; '' skips it. function Select-AppPath($App) { Heading $App.Name $path = $App.Default switch ($App.Kind) { 'desktop' { if ($path) { Say "Found Claude Desktop at $(Show-Path $path)." if (Confirm-Choice 'Is this right?' $true) { $App.Path = $path; return } } else { Say "Claude Desktop wasn't found on this computer." } while ($true) { $answer = Read-PathAnswer 'Path to Claude.exe or its folder' if ($answer -eq '') { break } if (Test-Path -LiteralPath $answer) { $App.Path = $answer; return } Warn "$answer doesn't exist." } } 'command' { if ($path) { Say "Found the openclaw command at $(Show-Path $path)." if (Confirm-Choice 'Is this right?' $true) { $App.Path = $path; return } } else { Say "The openclaw command wasn't found." } while ($true) { $answer = Read-PathAnswer 'Path to the openclaw command' if ($answer -eq '') { break } if (Test-Path -LiteralPath $answer -PathType Leaf) { $App.Path = $answer; return } Warn "$answer doesn't exist." } } default { if ($App.Found) { Say "Found $($App.Name). Its config file is:" } else { Say "$($App.Name) wasn't found on this computer. Its config file would be:" } Say " $(Show-Path $path)" if (Confirm-Choice 'Is this the right file?' $true) { $App.Path = $path; return } $answer = Read-PathAnswer "Path to $($App.Name)'s config file" if ($answer -ne '') { if (Test-Path -LiteralPath $answer -PathType Container) { $answer = Join-Path $answer (Split-Path -Leaf $path) } $App.Path = $answer return } } } Say "Skipping $($App.Name)." } # --- Main ------------------------------------------------------------------- try { if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { Stop-Setup 'This script is for Windows. On macOS or Linux, run: curl -fsSL https://www.greentokens.io/setup.sh | bash' } # Windows PowerShell 5.1 may not offer TLS 1.2 by default. [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 Write-Host '' Write-Host 'GreenTokens setup' -ForegroundColor Green Say 'Connects your coding agents to GreenTokens.' Say '' Say ' 1) Connect apps to GreenTokens' Say ' 2) Remove GreenTokens settings' do { $action = "$(Read-Host 'Choose 1 or 2 [1]')".Trim(); if ($action -eq '') { $action = '1' } } until ($action -eq '1' -or $action -eq '2') $mode = if ($action -eq '1') { 'set' } else { 'remove' } $apps = Get-Apps Select-Apps $apps $mode foreach ($app in $apps) { if ($app.Selected) { Select-AppPath $app } } $chosen = @($apps | Where-Object { $_.Path }) if ($chosen.Count -eq 0) { Say '' Say 'Nothing selected, so nothing was changed.' return } $key = '' $keyApps = @($chosen | Where-Object { $_.UsesKeyVar } | ForEach-Object { $_.Name }) if ($mode -eq 'set') { if ($keyApps.Count -gt 0) { Heading 'API key variable' Say "$($keyApps -join ', ') read your key from the $KeyVar variable. It will be saved as a user environment variable." } $key = Read-Key if ($keyApps.Count -gt 0) { Heading 'API key variable' [Environment]::SetEnvironmentVariable($KeyVar, $key, 'User') Set-Item -Path "env:$KeyVar" -Value $key Ok "Saved $KeyVar." $Done.Add("Open a new terminal window so apps see $KeyVar.") } } foreach ($app in $chosen) { Heading $app.Name & "Invoke-$($app.Id)" $mode $app.Path $key } if ($mode -eq 'remove' -and [Environment]::GetEnvironmentVariable($KeyVar, 'User')) { Heading 'API key variable' Say "$KeyVar is still set. Apps you still use with GreenTokens need it." if (Confirm-Choice 'Remove it?' $true) { [Environment]::SetEnvironmentVariable($KeyVar, $null, 'User') Remove-Item -Path "env:$KeyVar" -ErrorAction SilentlyContinue Ok "Removed $KeyVar." } } Heading 'Done' if ($Done.Count -eq 0) { Say 'Nothing was changed.' } foreach ($line in $Done) { Say "- $line" } Note "Help: $DocsUrl" } catch { $message = "$($_.Exception.Message)" if ($message.StartsWith('GT_STOP:')) { Write-Host $message.Substring(8) -ForegroundColor Red } else { Write-Host "Setup stopped: $message" -ForegroundColor Red } } }