<# .SYNOPSIS Bookworm Portable 体检工具 (v3.1.2) .DESCRIPTION 13 维度自检, 覆盖 auto-setup.ps1 7 阶段所有安装产物. 输出彩色 PASS/WARN/FAIL 报告, 不修改任何文件. 维度: [1] PowerShell 7 (pwsh) [2] Node.js [3] Git [4] Claude Code (claude.ps1 可达) [5] 桌面 .lnk (启动/更新 + Args 契约) [6] DPAPI 凭证 (HKCU CachedEnv) [7] Profile BW_CRED 块 (PS7 + PS5.1) [8] Profile BW_CLIP 块 (PS7) [9] 环境变量 (ANTHROPIC_*) [10] ~/.claude 完整性 (CLAUDE.md / Skills / Hooks / Settings) [11] API 中转站连通 (bww.letcareme.com) [12] Worker 连通 (bookworm-router) [13] Gitea 连通 (code.letcareme.com) .NOTES 用法: pwsh -NoProfile -ExecutionPolicy Bypass -File bw-doctor.ps1 日志: $env:TEMP\bw-doctor.log 退出码: 0 = 全 PASS / 1 = 有 FAIL #> $ErrorActionPreference = "Continue" $doctorLog = Join-Path $env:TEMP "bw-doctor.log" $pass = 0; $warn = 0; $fail = 0 $results = @() function Log-Doctor { param([string]$Msg) try { "[$([DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss'))] $Msg" | Out-File -FilePath $doctorLog -Append -Encoding UTF8 -EA SilentlyContinue } catch {} } function Report { param([string]$Dim, [string]$Status, [string]$Detail) $color = switch ($Status) { 'PASS' { 'Green' } 'WARN' { 'Yellow' } 'FAIL' { 'Red' } default { 'Gray' } } $icon = switch ($Status) { 'PASS' { [char]0x2714 } 'WARN' { '!' } 'FAIL' { [char]0x2718 } default { '?' } } $line = " $icon [$Status] $Dim" if ($Detail) { $line += " — $Detail" } Write-Host $line -ForegroundColor $color Log-Doctor "$Status $Dim $Detail" switch ($Status) { 'PASS' { $script:pass++ } 'WARN' { $script:warn++ } 'FAIL' { $script:fail++ } } $script:results += @{ Dim = $Dim; Status = $Status; Detail = $Detail } } function Test-Cmd($name) { return [bool](Get-Command $name -EA SilentlyContinue) } # ── Banner ── Write-Host "" Write-Host " +------------------------------------------+" -ForegroundColor Cyan Write-Host " | Bookworm Doctor v3.1.2 |" -ForegroundColor Cyan Write-Host " | 13 维度健康体检 |" -ForegroundColor Cyan Write-Host " +------------------------------------------+" -ForegroundColor Cyan Write-Host "" Log-Doctor "=== Bookworm Doctor v3.1.2 START ===" # ══════════════════════════════════════════════════════ # [1/13] PowerShell 7 # ══════════════════════════════════════════════════════ Write-Host " [1/13] PowerShell 7" -ForegroundColor Cyan $pwshCmd = Get-Command pwsh -EA SilentlyContinue if ($pwshCmd) { try { $pwshVer = (& pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>$null).Trim() Report "[1] PowerShell 7" "PASS" "v$pwshVer ($($pwshCmd.Source))" } catch { Report "[1] PowerShell 7" "WARN" "pwsh 可达但版本查询失败" } } else { Report "[1] PowerShell 7" "FAIL" "pwsh 不在 PATH — 桌面 .lnk 强依赖 pwsh.exe" } # ══════════════════════════════════════════════════════ # [2/13] Node.js # ══════════════════════════════════════════════════════ Write-Host " [2/13] Node.js" -ForegroundColor Cyan if (Test-Cmd "node") { try { $nodeVer = (& node --version 2>$null).Trim() Report "[2] Node.js" "PASS" "$nodeVer" } catch { Report "[2] Node.js" "WARN" "node 可达但版本查询失败" } } else { Report "[2] Node.js" "FAIL" "node 不在 PATH — Claude Code 强依赖" } # ══════════════════════════════════════════════════════ # [3/13] Git # ══════════════════════════════════════════════════════ Write-Host " [3/13] Git" -ForegroundColor Cyan if (Test-Cmd "git") { try { $gitVer = (& git --version 2>$null).Trim() Report "[3] Git" "PASS" "$gitVer" } catch { Report "[3] Git" "WARN" "git 可达但版本查询失败" } } else { Report "[3] Git" "FAIL" "git 不在 PATH — 配置同步强依赖" } # ══════════════════════════════════════════════════════ # [4/13] Claude Code # ══════════════════════════════════════════════════════ Write-Host " [4/13] Claude Code" -ForegroundColor Cyan $claudePs1Found = $null $claudeCmd = Get-Command claude -EA SilentlyContinue if ($claudeCmd -and $claudeCmd.Source -and $claudeCmd.Source.EndsWith('.ps1') -and (Test-Path $claudeCmd.Source)) { $claudePs1Found = $claudeCmd.Source } if (-not $claudePs1Found) { try { $npmPrefix = (& npm config get prefix 2>$null | Select-Object -First 1).Trim() $candidate = Join-Path $npmPrefix "claude.ps1" if (Test-Path $candidate) { $claudePs1Found = $candidate } } catch {} } if (-not $claudePs1Found) { foreach ($p in @("$env:APPDATA\npm\claude.ps1", "$env:ProgramFiles\nodejs\claude.ps1", "$env:LOCALAPPDATA\npm\claude.ps1")) { if (Test-Path $p) { $claudePs1Found = $p; break } } } if ($claudePs1Found) { try { $claudeVer = (& claude --version 2>$null | Select-Object -First 1).Trim() Report "[4] Claude Code" "PASS" "$claudeVer ($claudePs1Found)" } catch { Report "[4] Claude Code" "PASS" "claude.ps1 存在: $claudePs1Found (版本查询跳过)" } } else { Report "[4] Claude Code" "FAIL" "claude.ps1 不可达 — 运行 npm i -g @anthropic-ai/claude-code" } # ══════════════════════════════════════════════════════ # [5/13] 桌面 .lnk # ══════════════════════════════════════════════════════ Write-Host " [5/13] 桌面 .lnk" -ForegroundColor Cyan $desktop = [Environment]::GetFolderPath('Desktop') $requiredLnks = @('启动Bookworm.lnk', '更新Bookworm.lnk') $optionalLnks = @('体检Bookworm.lnk', '卸载Bookworm.lnk') $lnkMissing = @() $lnkPresent = @() foreach ($n in $requiredLnks) { $p = Join-Path $desktop $n if (Test-Path $p) { $lnkPresent += $n } else { $lnkMissing += $n } } $optPresent = @() foreach ($n in $optionalLnks) { $p = Join-Path $desktop $n if (Test-Path $p) { $optPresent += $n } } if ($lnkMissing.Count -eq 0) { # 验证启动 .lnk Args 契约 (4 项: pwsh TargetPath / bw-launch.ps1 / --dangerously-skip-permissions / -ExecutionPolicy Bypass) $launchLnk = Join-Path $desktop '启动Bookworm.lnk' $argsOK = $true $argsDetail = "" try { $shell = New-Object -ComObject WScript.Shell $sc = $shell.CreateShortcut($launchLnk) $checks = @( @{ Name = "TargetPath=pwsh"; OK = ($sc.TargetPath -match 'pwsh\.exe$') } @{ Name = "bw-launch.ps1"; OK = ($sc.Arguments -match 'bw-launch\.ps1') } @{ Name = "--dangerously-skip-permissions"; OK = ($sc.Arguments -match '--dangerously-skip-permissions') } @{ Name = "-ExecutionPolicy Bypass"; OK = ($sc.Arguments -match '-ExecutionPolicy Bypass') } ) $badChecks = $checks | Where-Object { -not $_.OK } if ($badChecks) { $argsOK = $false $argsDetail = "契约失败: " + (($badChecks | ForEach-Object { $_.Name }) -join ', ') } } catch { $argsOK = $false; $argsDetail = "读取 .lnk 异常" } if ($argsOK) { $extra = if ($optPresent.Count -gt 0) { " + $($optPresent -join '/')" } else { "" } Report "[5] 桌面 .lnk" "PASS" "$($lnkPresent -join ' + ')$extra — 4 项契约 OK" } else { Report "[5] 桌面 .lnk" "FAIL" "$argsDetail" } } else { Report "[5] 桌面 .lnk" "FAIL" "缺失: $($lnkMissing -join ', ') — 重跑 Bookworm-Setup.exe" } # ══════════════════════════════════════════════════════ # [6/13] DPAPI 凭证 # ══════════════════════════════════════════════════════ Write-Host " [6/13] DPAPI 凭证" -ForegroundColor Cyan $regPath = "HKCU:\Software\Bookworm\CachedEnv" if (Test-Path $regPath) { try { Add-Type -AssemblyName System.Security -EA Stop $props = Get-ItemProperty $regPath -EA Stop $envNames = $props.PSObject.Properties | Where-Object { $_.Name -match '^[A-Z_]+$' } $decrypted = 0 foreach ($ev in $envNames) { try { $bytes = [System.Security.Cryptography.ProtectedData]::Unprotect( [Convert]::FromBase64String($ev.Value), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser) $decrypted++ } catch {} } if ($envNames.Count -gt 0 -and $decrypted -eq $envNames.Count) { Report "[6] DPAPI 凭证" "PASS" "$decrypted/$($envNames.Count) 密钥可解密" } elseif ($decrypted -gt 0) { Report "[6] DPAPI 凭证" "WARN" "$decrypted/$($envNames.Count) 可解密 (部分失败)" } else { Report "[6] DPAPI 凭证" "FAIL" "0/$($envNames.Count) 可解密 — DPAPI 可能跨用户失效" } } catch { Report "[6] DPAPI 凭证" "WARN" "HKCU 存在但读取异常: $($_.Exception.Message)" } } else { Report "[6] DPAPI 凭证" "FAIL" "HKCU:\Software\Bookworm\CachedEnv 不存在 — 未安装或已卸载" } # ══════════════════════════════════════════════════════ # [7/13] Profile BW_CRED 块 # ══════════════════════════════════════════════════════ Write-Host " [7/13] Profile BW_CRED" -ForegroundColor Cyan $credBlockOK = 0; $credBlockMissing = @() foreach ($entry in @( @{ Name = "PS7"; Path = (Join-Path $env:USERPROFILE "Documents\PowerShell\profile.ps1") } @{ Name = "PS5.1"; Path = (Join-Path $env:USERPROFILE "Documents\WindowsPowerShell\profile.ps1") } )) { if (Test-Path $entry.Path) { $c = Get-Content $entry.Path -Raw -EA SilentlyContinue if ($c -match 'BW_CRED_START' -and $c -match 'BW_CRED_END') { $credBlockOK++ } else { $credBlockMissing += $entry.Name } } else { $credBlockMissing += "$($entry.Name)(文件不存在)" } } if ($credBlockOK -ge 1 -and $credBlockMissing.Count -eq 0) { Report "[7] Profile BW_CRED" "PASS" "PS7 + PS5.1 双 profile sentinel 完整" } elseif ($credBlockOK -ge 1) { Report "[7] Profile BW_CRED" "WARN" "部分缺失: $($credBlockMissing -join ', ')" } else { Report "[7] Profile BW_CRED" "FAIL" "BW_CRED 块不存在 — 启动时无法自动加载凭证" } # ══════════════════════════════════════════════════════ # [8/13] Profile BW_CLIP 块 # ══════════════════════════════════════════════════════ Write-Host " [8/13] Profile BW_CLIP" -ForegroundColor Cyan $clipProfile = Join-Path $env:USERPROFILE "Documents\PowerShell\profile.ps1" if (Test-Path $clipProfile) { $c = Get-Content $clipProfile -Raw -EA SilentlyContinue if ($c -match 'BW_CLIP_START' -and $c -match 'BW_CLIP_END') { Report "[8] Profile BW_CLIP" "PASS" "截图粘贴助手 sentinel 完整" } else { Report "[8] Profile BW_CLIP" "WARN" "BW_CLIP 块缺失 (截图粘贴功能不可用, 非核心)" } } else { Report "[8] Profile BW_CLIP" "WARN" "PS7 profile.ps1 不存在" } # ══════════════════════════════════════════════════════ # [9/13] 环境变量 # ══════════════════════════════════════════════════════ Write-Host " [9/13] 环境变量" -ForegroundColor Cyan $envChecks = @( @{ Name = "ANTHROPIC_API_KEY"; Required = $true } @{ Name = "ANTHROPIC_BASE_URL"; Required = $false } @{ Name = "ANTHROPIC_MODEL"; Required = $false } ) $envOK = @(); $envMissing = @(); $envOptMissing = @() foreach ($e in $envChecks) { $val = [Environment]::GetEnvironmentVariable($e.Name, 'User') if ($val) { $masked = if ($e.Name -eq 'ANTHROPIC_API_KEY') { $val.Substring(0, [Math]::Min(7, $val.Length)) + '...' } else { $val } $envOK += "$($e.Name)=$masked" } elseif ($e.Required) { $envMissing += $e.Name } else { $envOptMissing += $e.Name } } if ($envMissing.Count -eq 0) { $detail = ($envOK -join '; ') if ($envOptMissing.Count -gt 0) { $detail += " (可选未设: $($envOptMissing -join ', '))" } Report "[9] 环境变量" "PASS" $detail } else { Report "[9] 环境变量" "FAIL" "缺失: $($envMissing -join ', ')" } # ══════════════════════════════════════════════════════ # [10/13] ~/.claude 完整性 # ══════════════════════════════════════════════════════ Write-Host " [10/13] ~/.claude 完整性" -ForegroundColor Cyan $claudeDir = Join-Path $env:USERPROFILE ".claude" $intChecks = @() # CLAUDE.md $claudeMdPath = Join-Path $claudeDir "CLAUDE.md" $claudeMdOK = $false if (Test-Path $claudeMdPath) { $cm = Get-Content $claudeMdPath -Raw -EA SilentlyContinue $claudeMdOK = $cm -match "Bookworm" } $intChecks += @{ Name = "CLAUDE.md"; OK = $claudeMdOK } # Skills $skillsDir = Join-Path $claudeDir "skills" $skillCount = 0 if (Test-Path $skillsDir) { $skillCount = @(Get-ChildItem $skillsDir -Directory -EA SilentlyContinue).Count } $intChecks += @{ Name = "Skills ($skillCount, 需>=10)"; OK = ($skillCount -ge 10) } # Hooks $hooksDir = Join-Path $claudeDir "hooks" $hookCount = 0 if (Test-Path $hooksDir) { $hookCount = @(Get-ChildItem $hooksDir -Filter "*.js" -File -EA SilentlyContinue).Count } $intChecks += @{ Name = "Hooks ($hookCount, 需>=3)"; OK = ($hookCount -ge 3) } # Settings hooks $settingsFile = Join-Path $claudeDir "settings.json" $settingsOK = $false if (Test-Path $settingsFile) { $sc = Get-Content $settingsFile -Raw -EA SilentlyContinue $settingsOK = $sc -match '"hooks"' } $intChecks += @{ Name = "Settings hooks"; OK = $settingsOK } $intFails = $intChecks | Where-Object { -not $_.OK } if ($intFails.Count -eq 0) { Report "[10] ~/.claude 完整性" "PASS" "CLAUDE.md + $skillCount Skills + $hookCount Hooks + Settings" } else { $failNames = ($intFails | ForEach-Object { $_.Name }) -join ', ' Report "[10] ~/.claude 完整性" "FAIL" "缺失: $failNames" } # ══════════════════════════════════════════════════════ # [11/13] API 中转站连通 # ══════════════════════════════════════════════════════ Write-Host " [11/13] API 中转站" -ForegroundColor Cyan $apiBaseUrl = [Environment]::GetEnvironmentVariable('ANTHROPIC_BASE_URL', 'User') if (-not $apiBaseUrl) { $apiBaseUrl = $env:ANTHROPIC_BASE_URL } if (-not $apiBaseUrl) { $apiBaseUrl = "https://bww.letcareme.com" } try { # 中转站对境外 IP 返 503, NO_PROXY 直连 $savedProxy = $env:NO_PROXY $env:NO_PROXY = "bww.letcareme.com,letcareme.com,localhost,127.0.0.1" $resp = Invoke-WebRequest -Uri "$apiBaseUrl/v1/models" -Method Head -TimeoutSec 10 -UseBasicParsing -EA Stop $env:NO_PROXY = $savedProxy Report "[11] API 中转站" "PASS" "$apiBaseUrl (HTTP $($resp.StatusCode))" } catch { $env:NO_PROXY = $savedProxy $errMsg = $_.Exception.Message if ($errMsg -match '40[0-9]|301|302|503') { Report "[11] API 中转站" "PASS" "$apiBaseUrl 可达 (HTTP 错误码 = 网络通)" } else { Report "[11] API 中转站" "FAIL" "$apiBaseUrl 不可达: $errMsg" } } # ══════════════════════════════════════════════════════ # [12/13] Worker 连通 # ══════════════════════════════════════════════════════ Write-Host " [12/13] Worker" -ForegroundColor Cyan try { $workerUrl = "https://bookworm-router.bookworm-api.workers.dev/config" $resp = Invoke-RestMethod -Uri $workerUrl -TimeoutSec 10 -EA Stop Report "[12] Worker" "PASS" "bookworm-router /config 可达" } catch { $errMsg = $_.Exception.Message if ($errMsg -match '403|404') { Report "[12] Worker" "PASS" "Worker 可达 (HTTP 错误码 = 网络通)" } else { Report "[12] Worker" "WARN" "Worker 不可达: $errMsg (非核心, 仅影响默认 model)" } } # ══════════════════════════════════════════════════════ # [13/13] Gitea 连通 # ══════════════════════════════════════════════════════ Write-Host " [13/13] Gitea" -ForegroundColor Cyan try { $savedProxy = $env:NO_PROXY $env:NO_PROXY = "code.letcareme.com,letcareme.com,localhost,127.0.0.1" $resp = Invoke-WebRequest -Uri "https://code.letcareme.com" -Method Head -TimeoutSec 10 -UseBasicParsing -EA Stop $env:NO_PROXY = $savedProxy Report "[13] Gitea" "PASS" "code.letcareme.com 可达" } catch { $env:NO_PROXY = $savedProxy $errMsg = $_.Exception.Message if ($errMsg -match '40[0-9]|301|302|503') { Report "[13] Gitea" "PASS" "code.letcareme.com 可达 (HTTP 错误码 = 网络通)" } else { Report "[13] Gitea" "FAIL" "code.letcareme.com 不可达: $errMsg — 配置同步将失败" } } # ══════════════════════════════════════════════════════ # Summary # ══════════════════════════════════════════════════════ $total = $pass + $warn + $fail Write-Host "" Write-Host " +------------------------------------------+" -ForegroundColor Cyan Write-Host " | 体检报告 |" -ForegroundColor Cyan Write-Host " +------------------------------------------+" -ForegroundColor Cyan $passLine = " | PASS: $pass / $total" $warnLine = " | WARN: $warn" $failLine = " | FAIL: $fail" Write-Host $passLine -ForegroundColor Green if ($warn -gt 0) { Write-Host $warnLine -ForegroundColor Yellow } if ($fail -gt 0) { Write-Host $failLine -ForegroundColor Red } Write-Host " |" -ForegroundColor Cyan if ($fail -eq 0 -and $warn -eq 0) { Write-Host " | [ALL GREEN] Bookworm 完全健康" -ForegroundColor Green } elseif ($fail -eq 0) { Write-Host " | [HEALTHY] 核心功能正常, 有 $warn 项可优化" -ForegroundColor Yellow } else { Write-Host " | [NEEDS FIX] $fail 项异常需修复" -ForegroundColor Red Write-Host " | 修复: 重跑 Bookworm-Setup.exe 或联系管理员" -ForegroundColor Red } Write-Host " | 日志: $doctorLog" -ForegroundColor Gray Write-Host " +------------------------------------------+" -ForegroundColor Cyan Write-Host "" Log-Doctor "=== DONE: PASS=$pass WARN=$warn FAIL=$fail ===" if ($fail -gt 0) { exit 1 } else { exit 0 }