Public/Doctor.ps1

# Diagnostics for the failure modes that actually bite when two backends share a machine.
# Every check is read-only and never triggers an AWS login.

function Test-CcsHealth {
    <#
    .SYNOPSIS
        Report configuration problems across all profiles and this shell.
    .OUTPUTS
        Objects with Severity (Error|Warning|Info), Check, Message, Fix.
    #>

    [CmdletBinding()]
    param()

    $findings = [System.Collections.Generic.List[object]]::new()
    $add = {
        param($Severity, $Check, $Message, $Fix)
        $findings.Add([pscustomobject]@{
            PSTypeName = 'Ccs.Finding'
            Severity = $Severity; Check = $Check; Message = $Message; Fix = $Fix
        })
    }

    # A persisted CLAUDE_CONFIG_DIR defeats per-shell switching: every new shell,
    # the VS Code extension and scheduled jobs would all inherit one profile.
    foreach ($scope in @('User', 'Machine')) {
        $persisted = [Environment]::GetEnvironmentVariable('CLAUDE_CONFIG_DIR', $scope)
        if ($persisted) {
            & $add 'Warning' 'persisted-config-dir' `
                "CLAUDE_CONFIG_DIR is set permanently in $scope scope ($persisted)" `
                "[Environment]::SetEnvironmentVariable('CLAUDE_CONFIG_DIR', `$null, '$scope')"
        }
    }

    foreach ($var in $script:AuthOverrideEnvVars) {
        if (Test-Path "env:$var") {
            & $add 'Warning' 'auth-override-env' `
                "$var is set in this shell and bypasses profile authentication" `
                "Remove-Item env:$var"
        }
    }

    $config = Get-CcsConfig
    $activeName = Get-CcsActiveProfileName -Config $config
    if (-not $activeName) {
        & $add 'Warning' 'unknown-active-profile' `
            "CLAUDE_CONFIG_DIR ($env:CLAUDE_CONFIG_DIR) belongs to no registered profile" `
            "ccs use <name>, or register it with ccs capture"
    }

    foreach ($p in $config.profiles.PSObject.Properties) {
        $findings.AddRange(@(Test-CcsProfileHealth -Name $p.Name -Config $config))
    }

    # Claude Desktop / Cowork read a machine policy, not CLAUDE_CONFIG_DIR, so no
    # per-shell switch can move them. Worth stating rather than debugging twice.
    $policy = Get-ItemProperty 'HKCU:\SOFTWARE\Policies\Claude' -ErrorAction SilentlyContinue
    if ($policy -and $policy.inferenceProvider) {
        & $add 'Info' 'desktop-policy' `
            "Claude Desktop/Cowork is pinned to '$($policy.inferenceProvider)' by HKCU\SOFTWARE\Policies\Claude (profile: $($policy.inferenceBedrockProfile))" `
            'Machine-wide policy — ccs only switches the CLI. Ask IT before changing it.'
    }

    $findings
}

function Test-CcsProfileHealth {
    <#
    .SYNOPSIS
        Checks scoped to a single profile.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Name,
        $Config = (Get-CcsConfig)
    )

    $findings = [System.Collections.Generic.List[object]]::new()
    $add = {
        param($Severity, $Check, $Message, $Fix)
        $findings.Add([pscustomobject]@{
            PSTypeName = 'Ccs.Finding'
            Severity = $Severity; Check = "$Name/$Check"; Message = $Message; Fix = $Fix
        })
    }

    $resolved = Resolve-CcsProfile -Name $Name -Config $Config
    if (-not $resolved.Exists) {
        & $add 'Error' 'missing-dir' "Profile directory does not exist: $($resolved.Dir)" `
            "ccs capture $Name"
        return $findings
    }
    if (-not $resolved.Settings) {
        & $add 'Error' 'missing-settings' "No readable settings.json in $($resolved.Dir)" `
            "ccs capture $Name -Force"
        return $findings
    }

    $envMap = $resolved.Env

    if ($resolved.Backend -eq 'bedrock') {
        $missing = @($script:RequiredTelemetryKeys | Where-Object { -not $envMap.ContainsKey($_) })
        if ($missing.Count -gt 0) {
            & $add 'Error' 'telemetry-incomplete' `
                "Bedrock profile is missing required telemetry keys: $($missing -join ', ')" `
                'Re-capture from the company settings.json — CCWB can refuse credentials when telemetry enforcement is on'
        }

        $helper = $resolved.Settings.otelHeadersHelper
        if (-not $helper) {
            & $add 'Warning' 'otel-helper-missing' 'No otelHeadersHelper configured' `
                'Re-capture from the company settings.json'
        } elseif (-not (Test-Path -LiteralPath $helper)) {
            & $add 'Error' 'otel-helper-path' "otelHeadersHelper points at a missing file: $helper" `
                'Reinstall the company package (install.bat)'
        }

        $awsProfile = $envMap['AWS_PROFILE']
        if (-not $awsProfile) {
            & $add 'Error' 'aws-profile-unset' 'Bedrock profile has no AWS_PROFILE' `
                'Re-capture from the company settings.json'
        } elseif ($awsProfile -notin (Get-CcsAwsProfileNames)) {
            & $add 'Error' 'aws-profile-unknown' `
                "AWS_PROFILE '$awsProfile' is not declared in $(Get-CcsAwsConfigPath)" `
                'Run the company install.bat to write the profile'
        }

        $credProcess = Join-Path (Get-CcwbHome) 'credential-process.exe'
        if (-not (Test-Path -LiteralPath $credProcess)) {
            & $add 'Error' 'credential-process-missing' "Not found: $credProcess" `
                'Run the company install.bat'
        }

        $ccwbConfig = $null
        try { $ccwbConfig = Read-CcsJson -Path (Join-Path (Get-CcwbHome) 'config.json') } catch { }

        # The OIDC client the credential process authenticates as. When IT rotates
        # the package they disable the previous client, and the only symptom is a
        # "Client disabled" page in the browser — invisible from the Claude side.
        if ($ccwbConfig -and $awsProfile) {
            $entry = if ($ccwbConfig.PSObject.Properties[$awsProfile]) { $ccwbConfig.$awsProfile } else { $null }
            if (-not $entry) {
                & $add 'Error' 'ccwb-profile-mismatch' `
                    "CCWB config.json has no entry for AWS profile '$awsProfile'" `
                    'Reinstall the company package so both sides name the same profile'
            } else {
                $installed = Get-Item -LiteralPath $credProcess -ErrorAction SilentlyContinue
                $stamp = if ($installed) { $installed.LastWriteTime.ToString('yyyy-MM-dd') } else { 'unknown' }
                & $add 'Info' 'ccwb-package' `
                    "CCWB client_id '$($entry.client_id)', credential process built $stamp" `
                    "If sign-in shows 'Client disabled', IT rotated the OIDC client - reinstall the latest package, then run: ccs adopt"

                if (-not $entry.PSObject.Properties['expected_otlp_endpoint']) {
                    & $add 'Warning' 'ccwb-outdated' `
                        'CCWB config has no expected_otlp_endpoint, which newer packages always set' `
                        'Likely older than the package IT currently ships - reinstall from the latest zip'
                }

                $expected = $entry.expected_otlp_endpoint
                $actual = $envMap['OTEL_EXPORTER_OTLP_ENDPOINT']
                if ($expected -and $actual -and $expected -ne $actual) {
                    & $add 'Error' 'telemetry-endpoint-mismatch' `
                        "Telemetry endpoint '$actual' differs from the enforced '$expected'" `
                        'Re-capture from the company settings.json'
                }
            }
        }
    } else {
        # A stale Bedrock key in a subscription profile silently overrides the shell.
        foreach ($key in @('CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_MANTLE', 'ANTHROPIC_BASE_URL')) {
            if ($envMap.ContainsKey($key)) {
                & $add 'Warning' 'stale-provider-env' `
                    "Subscription profile carries $key=$($envMap[$key]) in its settings env block, which wins over shell variables" `
                    "Remove the key from $($resolved.SettingsPath)"
            }
        }
    }

    # A lock directory older than Claude Code's staleness window is a leftover from
    # a killed process; a fresh one means a live session is mid-refresh.
    $locks = Get-CcsLockDirs -ConfigHome $resolved.Dir
    foreach ($lockName in @('OauthRefresh', 'Legacy')) {
        $lock = $locks.$lockName
        if (Test-Path -LiteralPath $lock) {
            $age = (Get-Date) - (Get-Item -LiteralPath $lock).LastWriteTime
            if ($age.TotalSeconds -gt 60) {
                & $add 'Warning' 'stale-lock' `
                    "Lock $lock is $([int]$age.TotalSeconds)s old — likely left by a killed Claude Code" `
                    "Remove-Item -LiteralPath '$lock' -Recurse"
            } else {
                & $add 'Info' 'active-lock' `
                    "Claude Code is refreshing credentials right now ($lock)" `
                    'Wait a few seconds before switching this profile'
            }
        }
    }

    $findings
}