bookworm-boot/install.ps1
bookworm 95422376ce audit(v3.0.11): P0 -ExecutionPolicy Bypass + P1 4-项自验证
落地前红队审查发现 2 个阻断性 bug + 6 个非阻断:

[P0] Bug 2: .lnk Args 缺 -ExecutionPolicy Bypass
  根因: npm 全局生成的 claude.ps1 shim 是未签名脚本.
       Phase 1 设的 RemoteSigned 在 LTSC / Group Policy AllSigned 机器
       仍会拒绝执行. 用户启动时报'脚本未数字签名'.
  修: -NoLogo -NoExit -ExecutionPolicy Bypass -File ...

[P1] Bug 8: 自验证只查 claude.ps1 路径, 不查 perms / Bypass
  根因: 验证太松, 关键参数丢失也会通过.
  修: 扩展为 4 项检查 (Target / claude.ps1 路径 / --skip-perms / Bypass),
      任一失败立即删除 .lnk + 弹错, 不交付半成品.

P1 已知限制 (PS5.1 profile / Inject fail soft) 不阻断本版.

EXE 227840 → 228352 bytes (+512)
2026-04-25 21:38:28 +08:00

870 lines
38 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<#
.SYNOPSIS
Bookworm Portable - 安装/启动脚本
.DESCRIPTION
从 Gitea 私有仓克隆 Bookworm 配置到目标机,
解密凭证, 渲染模板, 启动 Claude Code.
.USAGE
# 首次安装 (从 Gitea 克隆)
.\install.ps1
# 仅启动 (已安装过)
.\install.ps1 -StartOnly
# 指定 Gitea 地址
.\install.ps1 -GitUrl "https://code.letcareme.com/bookworm/bookworm-config.git"
#>
param(
[string]$GitUrl = "https://code.letcareme.com/bookworm/bookworm-config.git",
[switch]$StartOnly,
[switch]$SkipSecrets,
[switch]$SkipLaunch, # 仅安装不启动 (由调用方负责启动)
[switch]$AutoAccept # 豁免所有人工确认环节
)
$ErrorActionPreference = "Stop"
# ─── 路径定义 ────────────────────────────────────────
$ScriptDir = if ($MyInvocation.MyCommand.Path) {
Split-Path -Parent $MyInvocation.MyCommand.Path
} else { $PWD.Path }
$ClaudeTarget = Join-Path $env:USERPROFILE ".claude"
$BackupPath = Join-Path $env:USERPROFILE ".claude.bw-backup"
$SecretsEnc = Join-Path $ScriptDir "secrets.enc"
$TemplateFile = Join-Path $ClaudeTarget "settings.template.json"
$LocalTplFile = Join-Path $ClaudeTarget "settings.local.template.json"
$SettingsFile = Join-Path $ClaudeTarget "settings.json"
$LocalSetFile = Join-Path $ClaudeTarget "settings.local.json"
# ─── openssl 检测 ────────────────────────────────────
$cmd = Get-Command openssl -ErrorAction SilentlyContinue
$opensslCmd = if ($cmd) { $cmd.Source } else { $null }
if (-not $opensslCmd) {
$searchPaths = @("C:\Program Files\Git\usr\bin\openssl.exe", "D:\Git\usr\bin\openssl.exe", "D:\Git\mingw64\bin\openssl.exe", "C:\Program Files\Git\mingw64\bin\openssl.exe")
$opensslCmd = $searchPaths | Where-Object { Test-Path $_ } | Select-Object -First 1
}
# ─── 辅助函数 ────────────────────────────────────────
function Write-Banner {
Write-Host ""
Write-Host " +------------------------------------------+" -ForegroundColor Cyan
Write-Host " | Bookworm Portable Installer v1.6 |" -ForegroundColor Cyan
Write-Host " | Claude Code 国内一键就绪 |" -ForegroundColor Cyan
Write-Host " +------------------------------------------+" -ForegroundColor Cyan
Write-Host ""
}
function Test-Command($cmd) {
return [bool](Get-Command $cmd -ErrorAction SilentlyContinue)
}
# ─── 密码本日免输 (Windows Credential Manager) ──────
function Get-CachedSecrets {
try {
$cred = cmdkey /list 2>$null | Select-String "bookworm-secrets"
if ($cred) {
$regPath = "HKCU:\Software\Bookworm\CachedEnv"
if (Test-Path $regPath) {
Add-Type -AssemblyName System.Security
$props = Get-ItemProperty $regPath -ErrorAction SilentlyContinue
$loaded = 0
foreach ($p in $props.PSObject.Properties) {
if ($p.Name -match '^[A-Z_]+$') {
$val = $p.Value
try {
# DPAPI 解密 (Base64 → byte[] → 明文)
$bytes = [Security.Cryptography.ProtectedData]::Unprotect(
[Convert]::FromBase64String($val), $null,
[Security.Cryptography.DataProtectionScope]::CurrentUser)
$val = [Text.Encoding]::UTF8.GetString($bytes)
} catch {
# 回退: 旧版明文缓存兼容
}
[System.Environment]::SetEnvironmentVariable($p.Name, $val, "Process")
$loaded++
}
}
if ($loaded -gt 0 -and $env:ANTHROPIC_API_KEY) {
Write-Host " [OK] 从本日缓存加载 $loaded 个凭证 (免密)" -ForegroundColor Green
return $true
}
}
}
} catch {}
return $false
}
function Save-SecretsToCache {
try {
cmdkey /generic:bookworm-secrets /user:bw /pass:cached 2>$null | Out-Null
$regPath = "HKCU:\Software\Bookworm\CachedEnv"
if (-not (Test-Path $regPath)) { New-Item $regPath -Force | Out-Null }
Add-Type -AssemblyName System.Security
$envKeys = @("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "GITHUB_PERSONAL_ACCESS_TOKEN",
"SLACK_BOT_TOKEN", "ATLASSIAN_API_TOKEN", "BROWSERBASE_API_KEY",
"FIRECRAWL_API_KEY", "GEMINI_API_KEY")
foreach ($k in $envKeys) {
$v = [System.Environment]::GetEnvironmentVariable($k, "Process")
if ($v) {
# DPAPI 加密: 明文 → byte[] → ProtectedData → Base64 存入注册表
$bytes = [Text.Encoding]::UTF8.GetBytes($v)
$enc = [Security.Cryptography.ProtectedData]::Protect(
$bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
Set-ItemProperty $regPath -Name $k -Value ([Convert]::ToBase64String($enc)) -Force
}
}
$expiry = (Get-Date).Date.AddDays(1).ToString("o")
Set-ItemProperty $regPath -Name "_expiry" -Value $expiry -Force
Write-Host " [OK] 凭证已缓存至今日 23:59 (DPAPI 加密, 下次免密)" -ForegroundColor Green
} catch {}
}
function Clear-SecretsCache {
cmdkey /delete:bookworm-secrets 2>$null | Out-Null
Remove-Item "HKCU:\Software\Bookworm" -Recurse -Force -ErrorAction SilentlyContinue
}
# ─── 依赖自动安装 ────────────────────────────────────
function Install-MissingDeps {
$missing = @()
if (-not (Test-Command "node")) { $missing += "Node.js" }
if (-not (Test-Command "git")) { $missing += "Git" }
if (-not (Test-Command "claude")) { $missing += "Claude Code" }
if ($missing.Count -eq 0) { return }
$hasWinget = Test-Command "winget"
Write-Host ""
Write-Host " 缺少以下软件: $($missing -join ', ')" -ForegroundColor Yellow
if ($hasWinget) {
$auto = if ($AutoAccept) { 'y' } else { Read-Host " 是否用 winget 自动安装? (y/n)" }
if ($auto -eq 'y') {
if ($missing -contains "Node.js") {
Write-Host " 安装 Node.js..." -ForegroundColor Gray
winget install OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements 2>&1 | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" }
# 刷新 PATH
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
}
if ($missing -contains "Git") {
Write-Host " 安装 Git..." -ForegroundColor Gray
winget install Git.Git --accept-source-agreements --accept-package-agreements 2>&1 | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" }
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
}
if ($missing -contains "Claude Code" -and (Test-Command "npm")) {
Write-Host " 安装 Claude Code..." -ForegroundColor Gray
npm i -g @anthropic-ai/claude-code 2>&1 | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" }
}
# 刷新检测
Write-Host " 重新检测..." -ForegroundColor Gray
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
}
} else {
Write-Host " 请手动安装后重新运行本脚本:" -ForegroundColor Yellow
if ($missing -contains "Node.js") { Write-Host " Node.js: https://nodejs.org" -ForegroundColor Gray }
if ($missing -contains "Git") { Write-Host " Git: https://git-scm.com" -ForegroundColor Gray }
if ($missing -contains "Claude Code") { Write-Host " Claude: npm i -g @anthropic-ai/claude-code" -ForegroundColor Gray }
exit 1
}
}
# ─── 桌面快捷方式 ────────────────────────────────────
function New-DesktopShortcuts {
# v3.0.11 架构重构: .lnk 直调 pwsh + claude.ps1 绝对路径 (1 跳直链)
$desktop = [System.Environment]::GetFolderPath("Desktop")
$bootDir = $ScriptDir
$lnkPath = Join-Path $desktop "启动Bookworm.lnk"
# 定位 pwsh.exe
$pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue).Source
if (-not $pwshExe) {
foreach ($p in @("$env:ProgramFiles\PowerShell\7\pwsh.exe", "${env:ProgramFiles(x86)}\PowerShell\7\pwsh.exe")) {
if (Test-Path $p) { $pwshExe = $p; break }
}
}
if (-not $pwshExe) {
Write-Host " [!] pwsh.exe 未找到, 跳过桌面快捷方式 (建议先装 PS7)" -ForegroundColor Yellow
return
}
# 定位 claude.ps1
$claudePs1 = $null
$claudeCmd = Get-Command claude -ErrorAction SilentlyContinue
if ($claudeCmd -and $claudeCmd.Source -and $claudeCmd.Source.EndsWith('.ps1') -and (Test-Path $claudeCmd.Source)) {
$claudePs1 = $claudeCmd.Source
}
if (-not $claudePs1) {
try {
$npmPrefix = (& npm config get prefix 2>$null | Select-Object -First 1).Trim()
$candidate = Join-Path $npmPrefix "claude.ps1"
if (Test-Path $candidate) { $claudePs1 = $candidate }
} catch {}
}
if (-not $claudePs1) {
foreach ($p in @("$env:APPDATA\npm\claude.ps1", "$env:ProgramFiles\nodejs\claude.ps1")) {
if (Test-Path $p) { $claudePs1 = $p; break }
}
}
if (-not $claudePs1) {
Write-Host " [!] claude.ps1 未找到, 跳过桌面快捷方式 (Claude Code 装好后重跑可补)" -ForegroundColor Yellow
return
}
try {
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($lnkPath)
$shortcut.TargetPath = $pwshExe
# v3.0.11 P0 修复: -ExecutionPolicy Bypass 防 LTSC/AllSigned 拒绝未签名 npm shim
$shortcut.Arguments = "-NoLogo -NoExit -ExecutionPolicy Bypass -File `"$claudePs1`" --dangerously-skip-permissions"
$shortcut.WorkingDirectory = $env:USERPROFILE
$shortcut.Description = "Bookworm Smart Assistant (v3.0.11 直调)"
$iconPath = Join-Path $bootDir "bookworm-desktop.ico"
if (-not (Test-Path $iconPath)) { $iconPath = Join-Path $bootDir "bookworm.ico" }
if (Test-Path $iconPath) { $shortcut.IconLocation = "$iconPath,0" }
$shortcut.Save()
# 自验证 (4 项)
$verify = $shell.CreateShortcut($lnkPath)
$okTarget = $verify.TargetPath -eq $pwshExe
$okPath = $verify.Arguments -match [regex]::Escape($claudePs1)
$okPerm = $verify.Arguments -match "--dangerously-skip-permissions"
$okBypass = $verify.Arguments -match "-ExecutionPolicy Bypass"
if ($okTarget -and $okPath -and $okPerm -and $okBypass) {
Write-Host " [OK] 桌面快捷方式已创建并通过 4 项自验证" -ForegroundColor Green
} else {
Write-Host " [!] 桌面快捷方式自验证失败 (Target=$okTarget Path=$okPath Perm=$okPerm Bypass=$okBypass)" -ForegroundColor Yellow
Remove-Item $lnkPath -Force -EA SilentlyContinue
}
} catch {
Write-Host " [!] 桌面快捷方式创建失败: $_" -ForegroundColor Gray
}
# 迁移清理老 Bookworm.lnk
$oldLnk = Join-Path $desktop "Bookworm.lnk"
if ((Test-Path $oldLnk) -and (Test-Path $lnkPath)) {
try { Remove-Item -LiteralPath $oldLnk -Force -ErrorAction Stop } catch {}
}
}
function Parse-AuthCode {
param([string]$code)
$code = $code.Trim()
# 格式: BW-YYYYMMDD-24位HexToken
if ($code -notmatch '^BW-(\d{8})-([A-Fa-f0-9]{24})$') {
Write-Host " [!!] 格式错误,应为 BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXXXXXX" -ForegroundColor Red
return $null
}
$expiryStr = $Matches[1]
$token = $Matches[2].ToLower() # 解密用小写
$today = (Get-Date).ToString("yyyyMMdd")
if ([int]$expiryStr -lt [int]$today) {
$d = "$($expiryStr.Substring(0,4))-$($expiryStr.Substring(4,2))-$($expiryStr.Substring(6,2))"
Write-Host " [!!] 授权码已过期 (有效期至 $d)" -ForegroundColor Red
Write-Host " 请联系管理员获取新授权码" -ForegroundColor Yellow
return $null
}
return $token
}
function Resolve-SecretsFile {
param([string]$token)
# 优先找 secrets-{token前8位}.enc (多用户独立 Key),回退 secrets.enc
$fileId = $token.Substring(0, 8)
$perUser = Join-Path $ScriptDir "secrets-$fileId.enc"
if (Test-Path $perUser) { return $perUser }
if (Test-Path $SecretsEnc) { return $SecretsEnc }
return $null
}
function Decrypt-Secrets {
if ($SkipSecrets) { return }
# 优先用 Node.js 解密 (跨平台兼容性最高), 回退 openssl
$useNode = (Test-Command "node") -and (Test-Path (Join-Path $ScriptDir "crypto-helper.js"))
if (-not $useNode -and -not $opensslCmd) {
Write-Host " [!] node 和 openssl 均不可用,跳过凭证解密" -ForegroundColor Yellow
return
}
$cryptoHelper = Join-Path $ScriptDir "crypto-helper.js"
$validAttempts = 0
$totalAttempts = 0
while ($validAttempts -lt 3 -and $totalAttempts -lt 10) {
$totalAttempts++
$label = if ($validAttempts -gt 0) { " 重新输入授权码 (第 $($validAttempts+1)/3 次)" } else { " 输入授权码 (格式: BW-YYYYMMDD-...)" }
$authCodeRaw = Read-Host $label
$plainPwd = Parse-AuthCode $authCodeRaw
if (-not $plainPwd) { continue }
$validAttempts++
# 按 token 前8位定位 .enc 文件
$encFile = Resolve-SecretsFile $plainPwd
if (-not $encFile) {
Write-Host " [!!] 未找到对应的凭证文件 (secrets-*.enc / secrets.enc)" -ForegroundColor Red
Write-Host " 请确认管理员已推送对应文件到 Gitea 并重新拉取" -ForegroundColor Yellow
$plainPwd = $null
continue
}
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
if ($useNode) {
$decrypted = & node $cryptoHelper decrypt $plainPwd $encFile 2>&1
$decExit = $LASTEXITCODE
} else {
$decrypted = $plainPwd | & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -md sha256 -in $encFile -pass stdin 2>&1
$decExit = $LASTEXITCODE
}
$ErrorActionPreference = $prevEAP
$plainPwd = $null
if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'PASSWORD_ERROR|FORMAT_ERROR|bad decrypt') {
$decrypted -split "`n" | ForEach-Object {
$line = $_.Trim()
if ($line -and $line.Contains('=')) {
$eqIdx = $line.IndexOf('=')
$key = $line.Substring(0, $eqIdx).Trim()
$val = $line.Substring($eqIdx + 1).Trim()
[System.Environment]::SetEnvironmentVariable($key, $val, "Process")
Write-Host " [OK] 已注入: $key" -ForegroundColor Green
}
}
return
}
$remaining = 3 - $validAttempts
if ($remaining -gt 0) {
Write-Host " [!!] 授权码无效 (解密失败),剩余重试: $remaining" -ForegroundColor Red
}
}
Write-Host ""
Write-Host " [ABORT] 3 次授权码均无效,凭证未解密" -ForegroundColor Red
Write-Host " 请确认授权码是否正确,或联系管理员重新生成" -ForegroundColor Yellow
exit 1
}
function Render-SettingsTemplate {
if (-not (Test-Path $TemplateFile)) {
Write-Host " [!] 未找到 settings.template.json跳过渲染" -ForegroundColor Yellow
return
}
$claudeRoot = $ClaudeTarget.Replace('\', '/')
$homeDir = $env:USERPROFILE.Replace('\', '/')
# 定位 pwsh 路径 (正斜杠供 JSON)
$pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue)
$pwshJsonPath = if ($pwshExe) { $pwshExe.Source.Replace('\', '/') } else { "pwsh" }
$content = Get-Content $TemplateFile -Raw
$content = $content -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot
$content = $content -replace '\{\{HOME\}\}', $homeDir
$content = $content -replace '\{\{PWSH_PATH\}\}', $pwshJsonPath
Set-Content $SettingsFile -Value $content -Encoding UTF8
Write-Host " [OK] settings.json 已渲染 (ROOT=$claudeRoot, SHELL=$pwshJsonPath)" -ForegroundColor Green
# 渲染 settings.local.template.json (如果存在)
if (Test-Path $LocalTplFile) {
$localContent = Get-Content $LocalTplFile -Raw
$localContent = $localContent -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot
$localContent = $localContent -replace '\{\{HOME\}\}', $homeDir
$localContent = $localContent -replace '\{\{USERNAME\}\}', $env:USERNAME
$localContent = $localContent -replace '\{\{PWSH_PATH\}\}', $pwshJsonPath
Set-Content $LocalSetFile -Value $localContent -Encoding UTF8
Write-Host " [OK] settings.local.json 已渲染" -ForegroundColor Green
}
}
# ─── 代理自动检测 ────────────────────────────────────
function Detect-SystemProxy {
# 中转站在国内阿里云,不走代理
$env:NO_PROXY = "bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1"
$env:no_proxy = $env:NO_PROXY
# 如果已手动设置了 HTTPS_PROXY直接使用
if ($env:HTTPS_PROXY) {
Write-Host " [OK] 已设置 HTTPS_PROXY=$($env:HTTPS_PROXY)" -ForegroundColor Green
Write-Host " [OK] NO_PROXY=$($env:NO_PROXY)" -ForegroundColor Green
return
}
Write-Host " 检测系统代理..." -ForegroundColor Gray
# 方法1: 通过 .NET 获取系统代理 (最可靠,支持 Clash/V2Ray/快柠檬/系统代理)
try {
$proxyUri = [System.Net.WebRequest]::DefaultWebProxy.GetProxy("https://api.anthropic.com")
if ($proxyUri -and $proxyUri.Authority -ne "api.anthropic.com") {
$proxyUrl = "http://$($proxyUri.Authority)"
$env:HTTPS_PROXY = $proxyUrl
$env:HTTP_PROXY = $proxyUrl
Write-Host " [OK] 检测到系统代理: $proxyUrl" -ForegroundColor Green
return
}
} catch {}
# 方法2: 读取注册表 IE 代理设置
try {
$reg = Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings" -ErrorAction SilentlyContinue
if ($reg.ProxyEnable -eq 1 -and $reg.ProxyServer) {
$proxy = $reg.ProxyServer
if ($proxy -notmatch '^http') { $proxy = "http://$proxy" }
$env:HTTPS_PROXY = $proxy
$env:HTTP_PROXY = $proxy
Write-Host " [OK] 检测到 IE 代理: $proxy" -ForegroundColor Green
return
}
} catch {}
# 方法3: 扫描常见代理端口 (500ms 超时,避免阻塞)
$commonPorts = @(7890, 7891, 7893, 10792, 10793, 10808, 10809, 1080, 1087, 8080, 8118)
foreach ($port in $commonPorts) {
try {
$tcp = New-Object System.Net.Sockets.TcpClient
$result = $tcp.BeginConnect("127.0.0.1", $port, $null, $null)
$success = $result.AsyncWaitHandle.WaitOne(500)
if (-not $success) { $tcp.Close(); continue }
$tcp.EndConnect($result)
$tcp.Close()
$env:HTTPS_PROXY = "http://127.0.0.1:$port"
$env:HTTP_PROXY = "http://127.0.0.1:$port"
Write-Host " [OK] 检测到本地代理端口: $port" -ForegroundColor Green
return
} catch {}
}
# 未找到代理
Write-Host " [!!] 未检测到代理/VPN" -ForegroundColor Red
Write-Host ""
Write-Host " Claude Code 需要代理才能在国内使用 (启动时检查 api.anthropic.com)" -ForegroundColor Yellow
Write-Host " 请先启动代理软件 (Clash/V2Ray/快柠檬等),然后重新运行本脚本" -ForegroundColor Yellow
Write-Host ""
Write-Host " 如已有代理,可手动指定:" -ForegroundColor Gray
Write-Host " `$env:HTTPS_PROXY = 'http://127.0.0.1:端口号'" -ForegroundColor Gray
Write-Host " pwsh -ExecutionPolicy Bypass -File install.ps1" -ForegroundColor Gray
Write-Host ""
$continue = if ($AutoAccept) { 'y' } else { Read-Host " 无代理继续? (y/n无代理大概率启动失败)" }
if ($continue -ne 'y') { exit 1 }
}
# ─── 主流程 ──────────────────────────────────────────
Write-Banner
# 步骤 1: 前置检查
Write-Host "[1/9] 前置检查..." -ForegroundColor White
$checks = @(
@{ Name = "Claude Code"; OK = (Test-Command "claude") }
@{ Name = "Node.js"; OK = (Test-Command "node") }
@{ Name = "Git"; OK = (Test-Command "git") }
)
foreach ($c in $checks) {
$icon = if ($c.OK) { "[OK]" } else { "[!!]" }
$color = if ($c.OK) { "Green" } else { "Red" }
Write-Host " $icon $($c.Name)" -ForegroundColor $color
}
if (-not (Test-Command "claude") -or -not (Test-Command "node") -or -not (Test-Command "git")) {
Install-MissingDeps
}
# v3.0.5: 再次验证 — StartOnly 场景加 GUI 弹窗 (防止 console 闪退用户看不见)
# 触发条件: 用户双击老快捷方式, 但 Phase 1 之前失败导致 claude/node 未装
function Show-MissingDepGui {
param([string]$depName, [string]$installCmd)
try {
Add-Type -AssemblyName System.Windows.Forms -EA Stop
$msg = @"
$depName Bookworm
:
(Phase 1 )
1. Bookworm-Setup.exe
2.
3. ,
$installCmd
"@
[System.Windows.Forms.MessageBox]::Show($msg, "Bookworm 启动失败 — $depName 未安装", 'OK', 'Error') | Out-Null
} catch { }
}
if (-not (Test-Command "claude")) {
Write-Host "`n [ABORT] Claude Code 未安装" -ForegroundColor Red
Write-Host " 安装: npm i -g @anthropic-ai/claude-code" -ForegroundColor Gray
if ($StartOnly) { Show-MissingDepGui "Claude Code" "npm i -g @anthropic-ai/claude-code" }
exit 1
}
if (-not (Test-Command "node")) {
Write-Host "`n [ABORT] Node.js 未安装" -ForegroundColor Red
if ($StartOnly) { Show-MissingDepGui "Node.js" "https://nodejs.org/zh-cn/download 下载 LTS .msi" }
exit 1
}
if (-not (Test-Command "git")) {
Write-Host "`n [ABORT] Git 未安装" -ForegroundColor Red
if ($StartOnly) { Show-MissingDepGui "Git" "https://git-scm.com/download/win 下载 64-bit" }
exit 1
}
# 步骤 2: 代理检测 (国内必须)
Write-Host "`n[2/9] 代理检测..." -ForegroundColor White
Detect-SystemProxy
# 步骤 3: 解密凭证 (优先使用本日缓存)
Write-Host "`n[3/9] 解密凭证..." -ForegroundColor White
# 检查缓存是否过期
$cacheExpiry = $null
try {
$cacheExpiry = Get-ItemProperty "HKCU:\Software\Bookworm\CachedEnv" -Name "_expiry" -ErrorAction SilentlyContinue
} catch {}
$cacheValid = $false
if ($cacheExpiry -and $cacheExpiry._expiry) {
try { $cacheValid = [datetime]$cacheExpiry._expiry -gt (Get-Date) } catch {}
}
if ($cacheValid -and (Get-CachedSecrets)) {
# 缓存有效,跳过解密
} elseif ($AutoAccept) {
# AutoAccept 模式: 无缓存时跳过密码输入
Write-Host " [!] AutoAccept 模式: 无有效缓存,跳过凭证解密" -ForegroundColor Yellow
Write-Host " 如需凭证,请不加 -AutoAccept 手动运行一次" -ForegroundColor Yellow
} else {
Clear-SecretsCache
Decrypt-Secrets
# 解密成功后询问是否缓存
if ($env:ANTHROPIC_API_KEY) {
$cache = if ($AutoAccept) { 'y' } else { Read-Host " 今日内免密启动? (y/n)" }
if ($cache -eq 'y') { Save-SecretsToCache }
}
}
# 自动配置 git credential helper (避免 clone 时反复要密码)
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
git config --global credential.helper store 2>$null
$ErrorActionPreference = $prevEAP
# 步骤 4: 克隆/更新仓库
if (-not $StartOnly) {
Write-Host "`n[4/9] 同步 Bookworm 配置..." -ForegroundColor White
if (Test-Path $ClaudeTarget) {
$isGit = Test-Path (Join-Path $ClaudeTarget ".git")
if ($isGit) {
Write-Host " 已有仓库,执行 git pull..."
Push-Location $ClaudeTarget
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$stashOutput = git stash 2>&1
$hasStash = $stashOutput -notmatch 'No local changes'
git pull --rebase 2>&1 | ForEach-Object { Write-Host " $_" }
if ($hasStash) { git stash pop 2>&1 | Out-Null }
}
catch {
Write-Host " [WARN] git pull 失败: $_" -ForegroundColor Yellow
Write-Host " 使用本地现有版本继续" -ForegroundColor Yellow
}
finally {
$ErrorActionPreference = $prevEAP
Pop-Location
}
}
else {
# 安全克隆: 先克隆到临时目录,成功后再替换
Write-Host " 备份现有 .claude/ 并克隆..."
$tempClone = "$ClaudeTarget.bw-clone-temp"
if (Test-Path $tempClone) { Remove-Item $tempClone -Recurse -Force }
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
git clone --depth 1 $GitUrl $tempClone 2>&1 | ForEach-Object { Write-Host " $_" }
$cloneExit = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
if ($cloneExit -ne 0 -or -not (Test-Path (Join-Path $tempClone "CLAUDE.md"))) {
Write-Host " [ERROR] 克隆失败 (exit=$cloneExit)" -ForegroundColor Red
if (Test-Path $tempClone) { Remove-Item $tempClone -Recurse -Force -ErrorAction SilentlyContinue }
Write-Host " 原始 .claude/ 未被修改" -ForegroundColor Yellow
exit 1
}
# 克隆成功,执行替换
if (Test-Path $BackupPath) { Remove-Item $BackupPath -Recurse -Force }
Rename-Item $ClaudeTarget $BackupPath
Rename-Item $tempClone $ClaudeTarget
Write-Host " [OK] 克隆完成,原始配置已备份到 .claude.bw-backup/" -ForegroundColor Green
}
}
else {
Write-Host " 首次安装,克隆仓库..."
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
git clone --depth 1 $GitUrl $ClaudeTarget 2>&1 | ForEach-Object { Write-Host " $_" }
$cloneExit = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
if ($cloneExit -ne 0 -or -not (Test-Path (Join-Path $ClaudeTarget "CLAUDE.md"))) {
Write-Host " [ERROR] 克隆失败 (exit=$cloneExit)" -ForegroundColor Red
Write-Host ""
Write-Host " 可能原因:" -ForegroundColor Yellow
Write-Host " - Gitea 服务不可达 (检查 https://code.letcareme.com)" -ForegroundColor Yellow
Write-Host " - 网络连接问题 (检查 DNS 和防火墙)" -ForegroundColor Yellow
Write-Host " - Git 凭证错误 (检查用户名密码)" -ForegroundColor Yellow
Write-Host ""
Write-Host " 离线模式: 如有本地 .claude 备份,运行:" -ForegroundColor Gray
Write-Host " Copy-Item .claude.bw-backup .claude -Recurse" -ForegroundColor Gray
exit 1
}
}
}
else {
Write-Host "`n[4/9] StartOnly 模式,跳过同步" -ForegroundColor Gray
# 静默检测远程更新
$configDir = Join-Path $env:USERPROFILE ".claude"
if (Test-Path (Join-Path $configDir ".git")) {
$prevEAP2 = $ErrorActionPreference
$ErrorActionPreference = "Continue"
git -C $configDir fetch --quiet 2>$null
$behind = git -C $configDir rev-list "HEAD..origin/main" --count 2>$null
$ErrorActionPreference = $prevEAP2
if ($behind -and [int]$behind -gt 0) {
Write-Host " [!] Bookworm 有 $behind 个新更新可用" -ForegroundColor Yellow
Write-Host " 双击 '更新并启动Bookworm.bat' 可同步最新版本" -ForegroundColor Yellow
}
}
}
# 步骤 5: 完整性校验
$integrityFile = Join-Path $ClaudeTarget "integrity.sha256"
if (Test-Path $integrityFile) {
Write-Host "`n[5/9] 完整性校验..." -ForegroundColor White
$failures = @()
Get-Content $integrityFile | ForEach-Object {
if ($_ -match '^([a-f0-9]{64})\s+(.+)$') {
$expectedHash = $Matches[1]
$filePath = Join-Path $ClaudeTarget $Matches[2]
if (Test-Path $filePath) {
$actualHash = (Get-FileHash $filePath -Algorithm SHA256).Hash.ToLower()
if ($actualHash -ne $expectedHash) {
$failures += $Matches[2]
}
}
}
}
if ($failures.Count -gt 0) {
Write-Host " [WARN] 以下文件哈希不匹配:" -ForegroundColor Yellow
$failures | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow }
Write-Host " 可能原因: 仓库内容被修改或本地有改动" -ForegroundColor Yellow
$continue = if ($AutoAccept) { 'y' } else { Read-Host " 继续? (y/n)" }
if ($continue -ne 'y') { exit 1 }
} else {
Write-Host " [OK] 所有文件完整性校验通过" -ForegroundColor Green
}
} else {
Write-Host "`n[5/9] 跳过完整性校验 (无 integrity.sha256)" -ForegroundColor Gray
}
# 步骤 6: 渲染 settings.json
Write-Host "`n[6/9] 渲染配置模板..." -ForegroundColor White
Render-SettingsTemplate
# 步骤 7: 确保必要目录存在
Write-Host "`n[7/9] 初始化本地目录..." -ForegroundColor White
$localDirs = @("debug", "sessions", "cache", "backups", "telemetry", "shell-snapshots", "projects", "memory")
foreach ($d in $localDirs) {
$dirPath = Join-Path $ClaudeTarget $d
if (-not (Test-Path $dirPath)) {
New-Item -ItemType Directory -Path $dirPath -Force | Out-Null
Write-Host " 创建: $d/" -ForegroundColor Gray
}
}
# 设置环境变量 (进程级)
$env:CLAUDE_HOME = $ClaudeTarget
Write-Host " [OK] CLAUDE_HOME=$ClaudeTarget" -ForegroundColor Green
# 验证环境变量传递
$nodeCheck = & node -e "console.log(process.env.CLAUDE_HOME || 'NOT_SET')" 2>$null
if ($nodeCheck -eq $ClaudeTarget) {
Write-Host " [OK] Node.js 环境变量传递验证通过" -ForegroundColor Green
} else {
Write-Host " [WARN] Node.js 环境变量传递异常hooks 可能无法正确解析路径" -ForegroundColor Yellow
}
# 步骤 8: Bookworm 完整性验证 + MCP 检查
Write-Host "`n[8/9] Bookworm 系统验证..." -ForegroundColor White
# --- Bookworm vs 原生 Claude Code 检测 ---
$bwChecks = @()
$claudeMd = Join-Path $ClaudeTarget "CLAUDE.md"
$skillsDir = Join-Path $ClaudeTarget "skills"
$hooksDir = Join-Path $ClaudeTarget "hooks"
$settingsF = Join-Path $ClaudeTarget "settings.json"
# 检查 CLAUDE.md
if (Test-Path $claudeMd) {
$content = Get-Content $claudeMd -Raw -ErrorAction SilentlyContinue
if ($content -match "Bookworm") {
$bwChecks += @{ Name = "CLAUDE.md (Bookworm 指令)"; OK = $true }
} else {
$bwChecks += @{ Name = "CLAUDE.md (缺少 Bookworm 指令)"; OK = $false }
}
} else {
$bwChecks += @{ Name = "CLAUDE.md (文件不存在!)"; OK = $false }
}
# v3.0.5: 阈值按脱敏分发版 (bookworm-portable-config.git) 实际内容定
# 管理员自用的完整版 (bookworm-config.git) 含 90+ skills, 分发版精简到核心 14+
$skillCount = 0
if (Test-Path $skillsDir) {
$skillCount = (Get-ChildItem $skillsDir -Directory -ErrorAction SilentlyContinue).Count
}
$bwChecks += @{ Name = "Skills ($skillCount 个)"; OK = ($skillCount -ge 10) }
# 检查 Hooks
$hookCount = 0
if (Test-Path $hooksDir) {
$hookCount = (Get-ChildItem $hooksDir -Filter "*.js" -File -ErrorAction SilentlyContinue).Count
}
$bwChecks += @{ Name = "Hooks ($hookCount 个)"; OK = ($hookCount -ge 3) }
# 检查 settings.json hooks 配置
$hasHooks = $false
if (Test-Path $settingsF) {
$sContent = Get-Content $settingsF -Raw -ErrorAction SilentlyContinue
if ($sContent -match '"hooks"') { $hasHooks = $true }
}
$bwChecks += @{ Name = "Settings hooks 配置"; OK = $hasHooks }
# 输出验证结果
$allOK = $true
foreach ($c in $bwChecks) {
$icon = if ($c.OK) { "[OK]" } else { "[!!]" }
$color = if ($c.OK) { "Green" } else { "Red" }
Write-Host " $icon $($c.Name)" -ForegroundColor $color
if (-not $c.OK) { $allOK = $false }
}
if (-not $allOK) {
Write-Host ""
Write-Host " ╔══════════════════════════════════════════════════════╗" -ForegroundColor Yellow
Write-Host " ║ [!] 警告: Bookworm 系统核心资产不足 ║" -ForegroundColor Yellow
Write-Host " ║ Claude Code 将以原生模式启动 (无 Skills/Hooks) ║" -ForegroundColor Yellow
Write-Host " ║ 建议: 检查网络后不加 -StartOnly 重新运行同步 ║" -ForegroundColor Yellow
Write-Host " ╚══════════════════════════════════════════════════════╝" -ForegroundColor Yellow
} else {
Write-Host " [OK] Bookworm 分发版就绪 ($skillCount Skills / $hookCount Hooks / Settings)" -ForegroundColor Green
}
# --- MCP 依赖检查 (中文提醒) ---
Write-Host ""
Write-Host " MCP 服务检查:" -ForegroundColor Gray
$mcpWarnings = @()
# Python (askui/pywinauto/com-server 需要)
$hasPython = [bool](Get-Command python -ErrorAction SilentlyContinue)
if (-not $hasPython) {
$mcpWarnings += " [!] Python 未安装 - askui/pywinauto/com-server MCP 不可用"
$mcpWarnings += " 安装: https://www.python.org/downloads/ 或 winget install Python.Python.3.12"
}
# Playwright (浏览器自动化 MCP) — 轻量检测,避免 npx 触发安装
$hasPlaywright = $false
try {
$pwPath = & npm list -g @playwright/mcp 2>$null
if ($pwPath -and $pwPath -notmatch 'empty') { $hasPlaywright = $true }
} catch {}
if (-not $hasPlaywright) {
$mcpWarnings += " [!] Playwright MCP 未安装 - 浏览器自动化功能不可用"
$mcpWarnings += " 安装: npm i -g @playwright/mcp && npx playwright install"
}
# 检查关键 API Key 环境变量
$apiChecks = @(
@{ Key = "ANTHROPIC_API_KEY"; Name = "Claude API (中转站)" }
@{ Key = "ANTHROPIC_BASE_URL"; Name = "API 中转站地址" }
)
foreach ($ak in $apiChecks) {
$val = [System.Environment]::GetEnvironmentVariable($ak.Key, "Process")
if (-not $val) {
$mcpWarnings += " [!] $($ak.Key) 未设置 - $($ak.Name)不可用"
$mcpWarnings += " 需要管理员重新加密凭证并更新 secrets.enc"
}
}
# 可选 MCP API Key 检查
$optionalApis = @(
@{ Key = "GITHUB_PERSONAL_ACCESS_TOKEN"; Name = "GitHub MCP"; Cmd = "仓库管理/代码搜索" }
@{ Key = "SLACK_BOT_TOKEN"; Name = "Slack MCP"; Cmd = "消息发送/频道管理" }
@{ Key = "BROWSERBASE_API_KEY"; Name = "Browserbase MCP"; Cmd = "云端浏览器" }
@{ Key = "FIRECRAWL_API_KEY"; Name = "Firecrawl MCP"; Cmd = "网页抓取/搜索" }
)
$missingOptional = @()
foreach ($api in $optionalApis) {
$val = [System.Environment]::GetEnvironmentVariable($api.Key, "Process")
if (-not $val) {
$missingOptional += $api
}
}
if ($mcpWarnings.Count -gt 0) {
foreach ($w in $mcpWarnings) {
Write-Host $w -ForegroundColor Yellow
}
}
if ($missingOptional.Count -gt 0) {
Write-Host ""
Write-Host " 可选 MCP 服务 (未配置, 不影响核心功能):" -ForegroundColor Gray
foreach ($m in $missingOptional) {
Write-Host " [-] $($m.Name) ($($m.Cmd))" -ForegroundColor DarkGray
}
Write-Host " 如需使用, 请联系管理员将 API Key 加入 secrets.enc" -ForegroundColor DarkGray
}
if ($mcpWarnings.Count -eq 0) {
Write-Host " [OK] 核心 API 已配置" -ForegroundColor Green
}
# 步骤 9: 启动 Claude Code
Write-Host "`n[9/9] 启动 Claude Code..." -ForegroundColor White
Write-Host ""
if ($allOK) {
Write-Host " ╔══════════════════════════════════════════╗" -ForegroundColor Green
Write-Host " ║ Bookworm 就绪! 正在启动 Claude Code... ║" -ForegroundColor Green
Write-Host " ╚══════════════════════════════════════════╝" -ForegroundColor Green
} else {
Write-Host " ╔══════════════════════════════════════════╗" -ForegroundColor Yellow
Write-Host " ║ 原生模式启动 (Bookworm 不完整) ║" -ForegroundColor Yellow
Write-Host " ╚══════════════════════════════════════════╝" -ForegroundColor Yellow
}
Write-Host ""
# 首次安装: 创建桌面快捷方式 + 打开使用教程
if (-not $StartOnly) {
New-DesktopShortcuts
$guidePath = Join-Path $ScriptDir "guide.html"
if (Test-Path $guidePath) {
Start-Process $guidePath
Write-Host " [OK] 使用教程已在浏览器打开" -ForegroundColor Gray
}
} else {
# StartOnly 路径 (老 Bookworm.lnk 指向此): 跑幂等迁移, 单次 ~10ms
# 让只从不点「更新Bookworm」的老用户也自动完成快捷方式命名统一
New-DesktopShortcuts
}
# 启动 Claude Code (同步执行, 窗口类型由调用方 .bat 决定)
if ($SkipLaunch) {
Write-Host " [OK] 安装完成 (由调用方负责启动)" -ForegroundColor Green
} else {
& claude --dangerously-skip-permissions
}