#Requires -Version 5.1 <# .SYNOPSIS Bootstraps a Windows PC for the PwC Claude Code setup. .DESCRIPTION Installs (or upgrades) Git and Claude Code via winget, prompts for the user's PwC API key, tenant ID and email, queries the gateway /models endpoint to learn the currently-suggested OPUS/SONNET/HAIKU model IDs, and writes a fully-populated %USERPROFILE%\.claude\settings.json. The settings template is embedded directly in this script so it is fully self-contained. Falls back to embedded defaults if the API call fails or omits a suggestion. #> $ErrorActionPreference = 'Stop' # SC: shared module-level constants $script:LogTag = '[install-claude]' $script:ModelsEndpoint = 'https://idi-coding-agents.pwc.it/models' $script:ModelTiers = @('OPUS', 'SONNET', 'HAIKU') # SC: embedded settings.json template - kept inline so the script needs no extra files $script:SettingsTemplate = @' { "env": { "ANTHROPIC_BASE_URL": "https://idi-coding-agents.pwc.it", "ANTHROPIC_AUTH_TOKEN": "apikey=&tenantid=", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", "CLAUDE_CODE_SKIP_AUTH_LOGIN": "1", "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1", "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "0", "CLAUDE_CODE_ENABLE_TELEMETRY": "1", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001", "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-6", "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-7", "OTEL_METRICS_EXPORTER": "otlp", "OTEL_LOGS_EXPORTER": "otlp", "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", "OTEL_EXPORTER_OTLP_ENDPOINT": "https://idi-coding-agents.pwc.it", "OTEL_EXPORTER_OTLP_HEADERS": "authorization=Bearer apikey=&tenantid=", "OTEL_RESOURCE_ATTRIBUTES": "user.email=" }, "attribution": { "commit": "Co-Authored-By: PwC SDLC Claude Code", "pr": "Co-Authored-By: PwC SDLC Claude Code" }, "model": "sonnet[1m]", "extraKnownMarketplaces": { "anthropic-agent-skills": { "source": { "source": "github", "repo": "anthropics/skills" } } }, "effortLevel": "medium" } '@ function Write-Step { param( [Parameter(Mandatory = $true)][string] $Message ) Write-Host "$script:LogTag $Message" -ForegroundColor Cyan } function Write-WarnStep { param( [Parameter(Mandatory = $true)][string] $Message ) Write-Host "$script:LogTag $Message" -ForegroundColor Yellow } function Write-ErrStep { param( [Parameter(Mandatory = $true)][string] $Message ) Write-Host "$script:LogTag $Message" -ForegroundColor Red } function Test-WingetAvailable { # SC: winget must be on PATH; fail fast with a clear message if missing $cmd = Get-Command -Name 'winget' -ErrorAction SilentlyContinue if ($null -eq $cmd) { throw 'winget is not available. Install App Installer from the Microsoft Store, then re-run this script.' } } function Test-WingetPackageInstalled { param( [Parameter(Mandatory = $true)][string] $PackageId ) # SC: probe with `winget list`; rely on exit code AND id presence in output to avoid false positives $output = & winget list --id $PackageId -e --accept-source-agreements 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { return $false } return ($output -match [Regex]::Escape($PackageId)) } function Install-OrUpgradeWingetPackage { param( [Parameter(Mandatory = $true)][string] $PackageId, [Parameter(Mandatory = $true)][string] $FriendlyName ) $alreadyInstalled = Test-WingetPackageInstalled -PackageId $PackageId if ($alreadyInstalled) { Write-Step "$FriendlyName is already installed - running winget upgrade" $output = & winget upgrade --id $PackageId -e --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-String $code = $LASTEXITCODE # SC: winget returns non-zero when there is no update; treat that as success $noUpdate = ($output -match 'No applicable update found' -or $output -match 'No newer package versions are available' -or $output -match 'No installed package found matching input criteria') if ($code -ne 0 -and -not $noUpdate) { Write-Host $output throw "winget upgrade failed for $PackageId (exit $code)." } Write-Step "$FriendlyName is up to date." return } Write-Step "Installing $FriendlyName ($PackageId) via winget..." & winget install --id $PackageId -e --silent --accept-package-agreements --accept-source-agreements $code = $LASTEXITCODE if ($code -ne 0) { throw "winget install failed for $PackageId (exit $code)." } Write-Step "$FriendlyName installed." } function Read-NonEmptyString { param( [Parameter(Mandatory = $true)][string] $Prompt ) while ($true) { $value = Read-Host -Prompt $Prompt if (-not [string]::IsNullOrWhiteSpace($value)) { return $value.Trim() } Write-WarnStep 'Value cannot be empty. Please try again.' } } function Read-SecretString { param( [Parameter(Mandatory = $true)][string] $Prompt ) while ($true) { $secure = Read-Host -Prompt $Prompt -AsSecureString $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) try { $plain = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } if (-not [string]::IsNullOrWhiteSpace($plain)) { return $plain.Trim() } Write-WarnStep 'Value cannot be empty. Please try again.' } } function Read-Email { param( [Parameter(Mandatory = $true)][string] $Prompt ) $pattern = '^[^@\s]+@[^@\s]+\.[^@\s]+$' while ($true) { $value = Read-NonEmptyString -Prompt $Prompt if ($value -match $pattern) { return $value } Write-WarnStep 'That does not look like a valid email address. Please try again.' } } function Get-SuggestedModels { param( [Parameter(Mandatory = $true)][string] $Endpoint ) # SC: best-effort; never throws - returns an empty hashtable on any failure $result = @{} try { Write-Step "Querying $Endpoint for suggested models..." $resp = Invoke-RestMethod -Uri $Endpoint -Method GET ` -Headers @{ Accept = 'application/json' } -TimeoutSec 15 if ($null -eq $resp.data) { Write-WarnStep 'Response did not contain a `data` array - falling back to template defaults.' return $result } foreach ($item in $resp.data) { $tier = [string]$item.suggested if ($script:ModelTiers -contains $tier -and -not [string]::IsNullOrWhiteSpace([string]$item.id)) { $result[$tier] = [string]$item.id } } } catch { Write-WarnStep "Could not fetch suggested models ($($_.Exception.Message)) - falling back to template defaults." } return $result } function Set-PlaceholdersOnEnv { param( [Parameter(Mandatory = $true)][psobject] $EnvObject, [Parameter(Mandatory = $true)][hashtable] $Substitutions ) # SC: literal string replace (not -replace) so user input is never interpreted as regex foreach ($prop in @($EnvObject.PSObject.Properties)) { $val = [string]$prop.Value foreach ($pair in $Substitutions.GetEnumerator()) { $val = $val.Replace($pair.Key, $pair.Value) } $EnvObject.($prop.Name) = $val } } function Set-DefaultModels { param( [Parameter(Mandatory = $true)][psobject] $EnvObject, [Parameter(Mandatory = $true)][hashtable] $Suggested ) # SC: map tier name to env key; only override when we actually have a suggestion $tierKeyMap = @{ 'OPUS' = 'ANTHROPIC_DEFAULT_OPUS_MODEL' 'SONNET' = 'ANTHROPIC_DEFAULT_SONNET_MODEL' 'HAIKU' = 'ANTHROPIC_DEFAULT_HAIKU_MODEL' } foreach ($tier in $script:ModelTiers) { if ($Suggested.ContainsKey($tier)) { $envKey = $tierKeyMap[$tier] $EnvObject.$envKey = $Suggested[$tier] } } } function Backup-ExistingFile { param( [Parameter(Mandatory = $true)][string] $Path ) if (-not (Test-Path -LiteralPath $Path)) { return $null } $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $backup = "$Path.bak.$stamp" Move-Item -LiteralPath $Path -Destination $backup -Force return $backup } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- try { Write-Step 'Starting Claude Code install/setup for PwC.' Test-WingetAvailable Install-OrUpgradeWingetPackage -PackageId 'Git.Git' -FriendlyName 'Git' Install-OrUpgradeWingetPackage -PackageId 'Anthropic.ClaudeCode' -FriendlyName 'Claude Code' Write-Step 'Please enter your PwC credentials. Input is required for all three fields.' $apiKey = Read-SecretString -Prompt 'PwC API key (input hidden)' $tenant = Read-NonEmptyString -Prompt 'Tenant ID' $email = Read-Email -Prompt 'Corporate email' $userClaudeDir = Join-Path $env:USERPROFILE '.claude' # SC: parse the inline template - no external file dependency $settings = $script:SettingsTemplate | ConvertFrom-Json if ($null -eq $settings.env) { throw "Embedded settings template is missing an 'env' object." } $substitutions = @{ '' = $apiKey '' = $tenant '' = $email } Set-PlaceholdersOnEnv -EnvObject $settings.env -Substitutions $substitutions $suggested = Get-SuggestedModels -Endpoint $script:ModelsEndpoint Set-DefaultModels -EnvObject $settings.env -Suggested $suggested if (-not (Test-Path -LiteralPath $userClaudeDir)) { New-Item -ItemType Directory -Path $userClaudeDir -Force | Out-Null } $outPath = Join-Path $userClaudeDir 'settings.json' $backup = Backup-ExistingFile -Path $outPath if ($null -ne $backup) { Write-Step "Existing settings.json backed up to $backup" } $json = $settings | ConvertTo-Json -Depth 10 Set-Content -LiteralPath $outPath -Value $json -Encoding UTF8 Write-Step "Wrote $outPath" Write-Step 'Applied model defaults:' foreach ($tier in $script:ModelTiers) { $envKey = "ANTHROPIC_DEFAULT_${tier}_MODEL" $source = if ($suggested.ContainsKey($tier)) { 'from /models (suggested)' } else { 'from template (fallback)' } Write-Host (" {0,-32} = {1} [{2}]" -f $envKey, $settings.env.$envKey, $source) } Write-Step 'Done.' exit 0 } catch { Write-ErrStep $_.Exception.Message exit 1 }