<# .SYNOPSIS Bookworm Portable - 全自动一键安装器 .DESCRIPTION 全新电脑从零到 Bookworm 完全就绪,最大程度自动化。 7 阶段: 环境检测 → 依赖安装 → 网络诊断 → 仓库克隆 → 凭证解密 → MCP 验证 → 启动 需要人工输入时弹出 GUI 对话框。 .USAGE .\auto-setup.ps1 .\auto-setup.ps1 -SkipLaunch # 安装但不启动 #> param( [switch]$SkipLaunch ) $ErrorActionPreference = "Stop" # ─── 路径定义 ──────────────────────────────────────── # PS2EXE 兼容: $MyInvocation.MyCommand.Path 在 EXE 启动时为空,需用 Process MainModule $ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } elseif ([System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName -match '\.exe$') { Split-Path -Parent ([System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName) } elseif ($MyInvocation.MyCommand.Path) { Split-Path -Parent $MyInvocation.MyCommand.Path } else { $PWD.Path } $ClaudeDir = Join-Path $env:USERPROFILE ".claude" $BackupDir = Join-Path $env:USERPROFILE ".claude.bw-backup" $GitUrl = "https://code.letcareme.com/bookworm/bookworm-config.git" $BootUrl = "https://code.letcareme.com/bookworm/bookworm-boot.git" $BootDir = Join-Path $ScriptDir "bookworm-boot" $SecretsEnc = Join-Path $BootDir "secrets.enc" $TOTAL_PHASES = 7 # ─── GUI 初始化 ───────────────────────────────────── Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing [System.Windows.Forms.Application]::EnableVisualStyles() # ─── 日志 + 进度 (PS2EXE -NoConsole -NoOutput 模式: console 全静默) ── # 所有 Log-X 输出走文件 + GUI 进度窗口 (避免被 PS2EXE 弹窗化) $BWLogFile = Join-Path $env:TEMP "bookworm-setup-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" function Bw-Log($level, $msg) { try { Add-Content -Path $BWLogFile -Value "[$(Get-Date -Format 'HH:mm:ss')] [$level] $msg" -Encoding utf8 } catch {} } function Log-OK($msg) { Bw-Log "OK" $msg; Update-Progress-SubStatus "$msg" } function Log-Info($msg) { Bw-Log "INFO" $msg; Update-Progress-SubStatus "$msg" } function Log-Warn($msg) { Bw-Log "WARN" $msg } function Log-Fail($msg) { Bw-Log "FAIL" $msg } function Log-Phase($n, $title) { Bw-Log "PHASE" "[$n/$TOTAL_PHASES] $title" Update-Progress $n $title } # ─── GUI 进度窗口 (常驻顶部, 替代 console 输出) ──── $global:BWProgressForm = $null $global:BWPhaseLabel = $null $global:BWStatusLabel = $null $global:BWProgressBar = $null function Show-ProgressForm { # 统一品牌色 $brandBlue = [System.Drawing.Color]::FromArgb(88, 101, 242) # Bookworm 蓝紫 $brandDark = [System.Drawing.Color]::FromArgb(30, 31, 46) $uiFont = "Segoe UI" $global:BWProgressForm = New-Object System.Windows.Forms.Form $global:BWProgressForm.Text = "Bookworm Portable Setup v1.5.1" $global:BWProgressForm.Size = New-Object System.Drawing.Size(520, 230) $global:BWProgressForm.StartPosition = "CenterScreen" $global:BWProgressForm.FormBorderStyle = "FixedDialog" $global:BWProgressForm.MaximizeBox = $false $global:BWProgressForm.MinimizeBox = $false $global:BWProgressForm.TopMost = $false # P2: 不遮挡其他窗口 $global:BWProgressForm.ControlBox = $true # P0 F1: 允许关闭 (触发确认) $global:BWProgressForm.BackColor = [System.Drawing.Color]::White # X 按钮关闭时弹确认 $global:BWProgressForm.Add_FormClosing({ param($s, $e) if (-not $global:BWInstallDone) { $r = [System.Windows.Forms.MessageBox]::Show( "安装尚未完成。`n确定要取消安装吗?", "取消安装", "YesNo", "Warning") if ($r -eq "No") { $e.Cancel = $true; return } Bw-Log "ABORT" "用户手动取消安装" } }) $titleLabel = New-Object System.Windows.Forms.Label $titleLabel.Location = New-Object System.Drawing.Point(20, 16) $titleLabel.Size = New-Object System.Drawing.Size(480, 26) $titleLabel.Text = "Bookworm 智能助手 — 自动安装中" $titleLabel.Font = New-Object System.Drawing.Font($uiFont, 12, [System.Drawing.FontStyle]::Bold) $titleLabel.ForeColor = $brandDark $global:BWProgressForm.Controls.Add($titleLabel) $global:BWPhaseLabel = New-Object System.Windows.Forms.Label $global:BWPhaseLabel.Location = New-Object System.Drawing.Point(20, 50) $global:BWPhaseLabel.Size = New-Object System.Drawing.Size(480, 22) $global:BWPhaseLabel.Text = "[0/$TOTAL_PHASES] 初始化..." $global:BWPhaseLabel.Font = New-Object System.Drawing.Font($uiFont, 10) $global:BWPhaseLabel.ForeColor = $brandBlue $global:BWProgressForm.Controls.Add($global:BWPhaseLabel) $global:BWStatusLabel = New-Object System.Windows.Forms.Label $global:BWStatusLabel.Location = New-Object System.Drawing.Point(20, 78) $global:BWStatusLabel.Size = New-Object System.Drawing.Size(480, 22) $global:BWStatusLabel.Text = "" $global:BWStatusLabel.Font = New-Object System.Drawing.Font($uiFont, 9) $global:BWStatusLabel.ForeColor = [System.Drawing.Color]::FromArgb(120, 120, 140) $global:BWProgressForm.Controls.Add($global:BWStatusLabel) $global:BWProgressBar = New-Object System.Windows.Forms.ProgressBar $global:BWProgressBar.Location = New-Object System.Drawing.Point(20, 112) $global:BWProgressBar.Size = New-Object System.Drawing.Size(480, 20) $global:BWProgressBar.Minimum = 0 $global:BWProgressBar.Maximum = $TOTAL_PHASES $global:BWProgressBar.Value = 0 $global:BWProgressBar.Style = [System.Windows.Forms.ProgressBarStyle]::Continuous # P3: 平滑 $global:BWProgressForm.Controls.Add($global:BWProgressBar) $global:BWElapsedLabel = New-Object System.Windows.Forms.Label $global:BWElapsedLabel.Location = New-Object System.Drawing.Point(400, 136) $global:BWElapsedLabel.Size = New-Object System.Drawing.Size(100, 18) $global:BWElapsedLabel.Text = "" $global:BWElapsedLabel.Font = New-Object System.Drawing.Font($uiFont, 8) $global:BWElapsedLabel.ForeColor = [System.Drawing.Color]::Silver $global:BWElapsedLabel.TextAlign = [System.Drawing.ContentAlignment]::TopRight $global:BWProgressForm.Controls.Add($global:BWElapsedLabel) $hint = New-Object System.Windows.Forms.Label $hint.Location = New-Object System.Drawing.Point(20, 136) $hint.Size = New-Object System.Drawing.Size(380, 32) $hint.Text = "首次安装约 5-10 分钟 (依赖下载)`n关闭窗口可取消安装" $hint.Font = New-Object System.Drawing.Font($uiFont, 8) $hint.ForeColor = [System.Drawing.Color]::Silver $global:BWProgressForm.Controls.Add($hint) $global:BWProgressForm.Show() | Out-Null $global:BWProgressForm.Refresh() [System.Windows.Forms.Application]::DoEvents() } # 全局安装完成标记 (Close-ProgressForm 前设为 $true, 避免 X 按钮弹确认) $global:BWInstallDone = $false # 全局计时器 $global:BWStartTime = [System.Diagnostics.Stopwatch]::StartNew() function Update-Progress($phase, $title) { if ($global:BWProgressForm -and -not $global:BWProgressForm.IsDisposed) { try { $global:BWPhaseLabel.Text = "[$phase/$TOTAL_PHASES] $title" $global:BWStatusLabel.Text = "" $global:BWProgressBar.Value = [Math]::Min($phase, $TOTAL_PHASES) $global:BWProgressForm.Refresh() [System.Windows.Forms.Application]::DoEvents() } catch {} } } function Update-Progress-SubStatus($msg) { if ($global:BWProgressForm -and -not $global:BWProgressForm.IsDisposed -and $global:BWStatusLabel) { try { $shortMsg = if ($msg.Length -gt 70) { $msg.Substring(0, 67) + "..." } else { $msg } $global:BWStatusLabel.Text = $shortMsg # 刷新总耗时 if ($global:BWElapsedLabel -and $global:BWStartTime) { $sec = [int]$global:BWStartTime.Elapsed.TotalSeconds $global:BWElapsedLabel.Text = "$([int]($sec / 60))m $($sec % 60)s" } $global:BWStatusLabel.Refresh() [System.Windows.Forms.Application]::DoEvents() } catch {} } } function Close-ProgressForm { $global:BWInstallDone = $true # 关闭时不再弹 "取消安装?" 确认 if ($global:BWProgressForm -and -not $global:BWProgressForm.IsDisposed) { try { $global:BWProgressForm.Close(); $global:BWProgressForm.Dispose() } catch {} } } function Test-Cmd($cmd) { [bool](Get-Command $cmd -ErrorAction SilentlyContinue) } # ─── 非阻塞子进程执行 (解决 PS2EXE UI 冻结) ─────────── # 所有耗时子进程都必须经过这两个函数, 保持 GUI 消息泵活跃 function Wait-ProcessWithUI { <# 替代 System.Diagnostics.Process.WaitForExit(N) 在等待期间每 200ms 泵一次 DoEvents, 防止 "(未响应)" #> param( [System.Diagnostics.Process]$proc, [int]$timeoutMs = 60000, [string]$label = "" ) $sw = [System.Diagnostics.Stopwatch]::StartNew() while (-not $proc.HasExited -and $sw.ElapsedMilliseconds -lt $timeoutMs) { [System.Windows.Forms.Application]::DoEvents() # 每 5 秒更新一次副状态, 显示等待耗时 if ($label -and ($sw.ElapsedMilliseconds % 5000) -lt 250) { $elapsed = [int]($sw.ElapsedMilliseconds / 1000) Update-Progress-SubStatus "$label ($($elapsed)s)" } Start-Sleep -Milliseconds 200 } if (-not $proc.HasExited) { try { $proc.Kill() } catch {} Bw-Log "WARN" "子进程超时 ($timeoutMs ms): $label" return $false } return $true } function Run-CmdWithUI { <# 替代 & cmd args 2>&1 | ForEach-Object { Write-Host } 将阻塞调用转为 Start-Process + Wait-ProcessWithUI #> param( [string]$exe, [string[]]$arguments, [string]$label = "", [int]$timeoutMs = 180000, # 默认 3 分钟 [switch]$captureOutput # 返回 stdout 内容 ) Bw-Log "CMD" "$exe $($arguments -join ' ')" Update-Progress-SubStatus $label $outFile = Join-Path $env:TEMP "bw-cmd-out-$(Get-Random).tmp" $errFile = Join-Path $env:TEMP "bw-cmd-err-$(Get-Random).tmp" try { $proc = Start-Process -FilePath $exe -ArgumentList $arguments ` -NoNewWindow -PassThru ` -RedirectStandardOutput $outFile ` -RedirectStandardError $errFile $ok = Wait-ProcessWithUI $proc $timeoutMs $label $exitCode = if ($proc.HasExited) { $proc.ExitCode } else { -1 } # 日志记录 stdout/stderr (不超过 20 行) if (Test-Path $outFile) { $out = Get-Content $outFile -TotalCount 20 -ErrorAction SilentlyContinue if ($out) { $out | ForEach-Object { Bw-Log "OUT" $_ } } } if (Test-Path $errFile) { $err = Get-Content $errFile -TotalCount 10 -ErrorAction SilentlyContinue if ($err) { $err | ForEach-Object { Bw-Log "ERR" $_ } } } if ($captureOutput -and (Test-Path $outFile)) { return @{ OK = ($ok -and $exitCode -eq 0); Output = (Get-Content $outFile -Raw -ErrorAction SilentlyContinue); ExitCode = $exitCode } } return @{ OK = ($ok -and $exitCode -eq 0); ExitCode = $exitCode } } finally { Remove-Item $outFile, $errFile -Force -ErrorAction SilentlyContinue } } # ─── GUI 对话框 ───────────────────────────────────── function Show-MsgBox($text, $title = "Bookworm 安装", $buttons = "OK", $icon = "Information") { [System.Windows.Forms.MessageBox]::Show($text, $title, $buttons, $icon) } function Parse-AuthCode-GUI { param([string]$code) $code = $code.Trim() if ($code -notmatch '^BW-(\d{8})-([A-Fa-f0-9]{24})$') { return $null } $expiryStr = $Matches[1] $token = $Matches[2].ToLower() $today = (Get-Date).ToString("yyyyMMdd") if ([int]$expiryStr -lt [int]$today) { return 'EXPIRED' } return $token } function Show-AuthCodeDialog($attempt = 1, $maxAttempts = 3) { $form = New-Object System.Windows.Forms.Form $form.Text = "Bookworm - 授权码验证 ($attempt/$maxAttempts)" $form.Size = New-Object System.Drawing.Size(480, 240) $form.StartPosition = "CenterScreen" $form.FormBorderStyle = "FixedDialog" $form.MaximizeBox = $false $form.MinimizeBox = $false $form.TopMost = $true $label = New-Object System.Windows.Forms.Label $label.Location = New-Object System.Drawing.Point(20, 18) $label.Size = New-Object System.Drawing.Size(440, 36) $label.Text = "请输入管理员提供的授权码:`n格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXXXXXX" $label.Font = New-Object System.Drawing.Font("Segoe UI", 9) $form.Controls.Add($label) # 授权码可见 (用于粘贴验证), 不用 PasswordChar $codeBox = New-Object System.Windows.Forms.TextBox $codeBox.Location = New-Object System.Drawing.Point(20, 65) $codeBox.Size = New-Object System.Drawing.Size(430, 30) $codeBox.Font = New-Object System.Drawing.Font("Consolas", 11) $codeBox.CharacterCasing = "Upper" # 自动转大写 $form.Controls.Add($codeBox) $hint = New-Object System.Windows.Forms.Label $hint.Location = New-Object System.Drawing.Point(20, 100) $hint.Size = New-Object System.Drawing.Size(440, 20) $hint.Text = "提示: 直接粘贴管理员发送的授权码即可 (Ctrl+V)" $hint.Font = New-Object System.Drawing.Font("Segoe UI", 8) $hint.ForeColor = [System.Drawing.Color]::Gray $form.Controls.Add($hint) $btnOK = New-Object System.Windows.Forms.Button $btnOK.Location = New-Object System.Drawing.Point(250, 145) $btnOK.Size = New-Object System.Drawing.Size(90, 35) $btnOK.Text = "验证" $btnOK.DialogResult = [System.Windows.Forms.DialogResult]::OK $form.AcceptButton = $btnOK $form.Controls.Add($btnOK) $btnCancel = New-Object System.Windows.Forms.Button $btnCancel.Location = New-Object System.Drawing.Point(350, 145) $btnCancel.Size = New-Object System.Drawing.Size(90, 35) $btnCancel.Text = "取消安装" $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel $form.CancelButton = $btnCancel $form.Controls.Add($btnCancel) $form.Add_Shown({ $codeBox.Focus() }) $result = $form.ShowDialog() if ($result -eq [System.Windows.Forms.DialogResult]::OK) { return $codeBox.Text.Trim() } return $null } function Show-GiteaCredentialDialog { $form = New-Object System.Windows.Forms.Form $form.Text = "Bookworm - Gitea 登录" $form.Size = New-Object System.Drawing.Size(420, 300) $form.StartPosition = "CenterScreen" $form.FormBorderStyle = "FixedDialog" $form.MaximizeBox = $false $form.TopMost = $true $form.BackColor = [System.Drawing.Color]::White $lblInfo = New-Object System.Windows.Forms.Label $lblInfo.Location = New-Object System.Drawing.Point(20, 15) $lblInfo.Size = New-Object System.Drawing.Size(360, 40) $lblInfo.Text = "输入 Gitea 账号 (code.letcareme.com)`n用于下载 Bookworm 配置文件,由管理员提供。" $lblInfo.Font = New-Object System.Drawing.Font("Segoe UI", 9) $form.Controls.Add($lblInfo) $lblUser = New-Object System.Windows.Forms.Label $lblUser.Location = New-Object System.Drawing.Point(20, 65) $lblUser.Size = New-Object System.Drawing.Size(80, 25) $lblUser.Text = "用户名:" $form.Controls.Add($lblUser) $txtUser = New-Object System.Windows.Forms.TextBox $txtUser.Location = New-Object System.Drawing.Point(100, 63) $txtUser.Size = New-Object System.Drawing.Size(280, 25) $txtUser.Font = New-Object System.Drawing.Font("Consolas", 11) $form.Controls.Add($txtUser) $lblPass = New-Object System.Windows.Forms.Label $lblPass.Location = New-Object System.Drawing.Point(20, 105) $lblPass.Size = New-Object System.Drawing.Size(80, 25) $lblPass.Text = "密码:" $form.Controls.Add($lblPass) $txtPass = New-Object System.Windows.Forms.TextBox $txtPass.Location = New-Object System.Drawing.Point(100, 103) $txtPass.Size = New-Object System.Drawing.Size(280, 25) $txtPass.PasswordChar = '*' $txtPass.Font = New-Object System.Drawing.Font("Consolas", 11) $form.Controls.Add($txtPass) # P1 F10: 空值验证提示 $lblError = New-Object System.Windows.Forms.Label $lblError.Location = New-Object System.Drawing.Point(100, 135) $lblError.Size = New-Object System.Drawing.Size(280, 20) $lblError.Text = "" $lblError.Font = New-Object System.Drawing.Font("Segoe UI", 8) $lblError.ForeColor = [System.Drawing.Color]::Red $form.Controls.Add($lblError) $btnOK = New-Object System.Windows.Forms.Button $btnOK.Location = New-Object System.Drawing.Point(200, 165) $btnOK.Size = New-Object System.Drawing.Size(90, 35) $btnOK.Text = "登录" $form.Controls.Add($btnOK) $btnCancel = New-Object System.Windows.Forms.Button $btnCancel.Location = New-Object System.Drawing.Point(300, 165) $btnCancel.Size = New-Object System.Drawing.Size(80, 35) $btnCancel.Text = "取消" $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel $form.CancelButton = $btnCancel $form.Controls.Add($btnCancel) # OK 按钮手动验证 (不用 DialogResult, 防止空值直接关闭) $btnOK.Add_Click({ if (-not $txtUser.Text.Trim() -or -not $txtPass.Text) { $lblError.Text = "用户名和密码不能为空" return } $form.DialogResult = [System.Windows.Forms.DialogResult]::OK $form.Close() }) $form.AcceptButton = $btnOK $form.Add_Shown({ $txtUser.Focus() }) $result = $form.ShowDialog() if ($result -eq [System.Windows.Forms.DialogResult]::OK) { return @{ User = $txtUser.Text.Trim(); Pass = $txtPass.Text } } return $null } # ─── openssl 检测 ──────────────────────────────────── function Find-OpenSSL { $cmd = Get-Command openssl -ErrorAction SilentlyContinue if ($cmd) { return $cmd.Source } $paths = @( "C:\Program Files\Git\usr\bin\openssl.exe", "D:\Git\usr\bin\openssl.exe", "C:\Program Files\Git\mingw64\bin\openssl.exe", "D:\Git\mingw64\bin\openssl.exe" ) return $paths | Where-Object { Test-Path $_ } | Select-Object -First 1 } # ─── 凭证缓存 (Windows Credential Manager) ───────── function Get-CachedSecrets { try { $regPath = "HKCU:\Software\Bookworm\CachedEnv" if (-not (Test-Path $regPath)) { return $false } $expiry = (Get-ItemProperty $regPath -Name "_expiry" -ErrorAction SilentlyContinue)._expiry if (-not $expiry -or [datetime]$expiry -le (Get-Date)) { Remove-Item $regPath -Recurse -Force -ErrorAction SilentlyContinue return $false } $props = Get-ItemProperty $regPath -ErrorAction SilentlyContinue $loaded = 0 foreach ($p in $props.PSObject.Properties) { if ($p.Name -match '^[A-Z_]+$') { [System.Environment]::SetEnvironmentVariable($p.Name, $p.Value, "Process") $loaded++ } } return ($loaded -gt 0 -and $env:ANTHROPIC_API_KEY) } catch { return $false } } function Save-SecretsToCache { try { $regPath = "HKCU:\Software\Bookworm\CachedEnv" if (-not (Test-Path $regPath)) { New-Item $regPath -Force | Out-Null } $keys = @("ANTHROPIC_API_KEY","ANTHROPIC_BASE_URL","GITHUB_PERSONAL_ACCESS_TOKEN", "SLACK_BOT_TOKEN","ATLASSIAN_API_TOKEN","BROWSERBASE_API_KEY","FIRECRAWL_API_KEY") foreach ($k in $keys) { $v = [System.Environment]::GetEnvironmentVariable($k, "Process") if ($v) { Set-ItemProperty $regPath -Name $k -Value $v -Force } } Set-ItemProperty $regPath -Name "_expiry" -Value (Get-Date).Date.AddDays(1).ToString("o") -Force } catch {} } # ─── 桌面快捷方式 ────────────────────────────────── function New-DesktopShortcuts { try { $shell = New-Object -ComObject WScript.Shell $desktop = $shell.SpecialFolders("Desktop") # 桌面专用图标 (Bookworm 蓝紫渐变 B 圆, 多尺寸 ICO) $iconPath = Join-Path $BootDir "bookworm-desktop.ico" if (-not (Test-Path $iconPath)) { # 回退到 EXE 图标 (bookworm.ico) $iconPath = Join-Path $BootDir "bookworm.ico" } # 快速启动 (bat 文件位于 bookworm-boot 仓库内) $shortcut = $shell.CreateShortcut("$desktop\Bookworm.lnk") $batPath = Join-Path $BootDir "启动Bookworm.bat" if (-not (Test-Path $batPath)) { $batPath = Join-Path $BootDir "Bookworm-OneClick.bat" } $shortcut.TargetPath = $batPath $shortcut.WorkingDirectory = $BootDir $shortcut.Description = "Bookworm Smart Assistant - 智能助手" if (Test-Path $iconPath) { $shortcut.IconLocation = "$iconPath,0" } $shortcut.Save() # 更新启动 $shortcut2 = $shell.CreateShortcut("$desktop\更新Bookworm.lnk") $updateBat = Join-Path $BootDir "更新并启动Bookworm.bat" if (Test-Path $updateBat) { $shortcut2.TargetPath = $updateBat $shortcut2.WorkingDirectory = $BootDir $shortcut2.Description = "更新并启动 Bookworm" if (Test-Path $iconPath) { $shortcut2.IconLocation = "$iconPath,0" } $shortcut2.Save() } Log-OK "桌面快捷方式已创建 (含 Bookworm 图标)" } catch { Log-Warn "快捷方式创建失败: $_" } } # ======================================================================== # 启动: 显示 GUI 进度窗口 (替代 console banner, PS2EXE -NoOutput 兼容) # ======================================================================== Bw-Log "INIT" "Bookworm Portable Setup 启动 - 日志: $BWLogFile" Show-ProgressForm # ======================================================================== # Phase 1: 环境检测 + 依赖自动安装 # ======================================================================== Log-Phase 1 "环境检测 + 依赖自动安装" # 刷新 PATH $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") $deps = @( @{ Name = "Node.js"; Cmd = "node"; WingetId = "OpenJS.NodeJS.LTS"; NpmPkg = $null; PipPkg = $null } @{ Name = "Git"; Cmd = "git"; WingetId = "Git.Git"; NpmPkg = $null; PipPkg = $null } @{ Name = "PowerShell 7"; Cmd = "pwsh"; WingetId = "Microsoft.PowerShell"; NpmPkg = $null; PipPkg = $null } @{ Name = "Python 3.12"; Cmd = "python"; WingetId = "Python.Python.3.12"; NpmPkg = $null; PipPkg = $null } @{ Name = "Claude Code"; Cmd = "claude"; WingetId = $null; NpmPkg = "@anthropic-ai/claude-code"; PipPkg = $null } ) $hasWinget = Test-Cmd "winget" $installed = @() foreach ($dep in $deps) { if (Test-Cmd $dep.Cmd) { $ver = try { & $dep.Cmd --version 2>$null | Select-Object -First 1 } catch { "installed" } Log-OK "$($dep.Name) $ver" } else { Log-Warn "$($dep.Name) 未安装, 正在自动安装..." if ($dep.WingetId -and $hasWinget) { try { $r = Run-CmdWithUI "winget" @("install", $dep.WingetId, "--accept-source-agreements", "--accept-package-agreements") "安装 $($dep.Name)" 300000 $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") if (Test-Cmd $dep.Cmd) { Log-OK "$($dep.Name) 安装成功" $installed += $dep.Name } else { Log-Fail "$($dep.Name) 安装后仍无法找到, 可能需要重启终端" } } catch { Log-Fail "$($dep.Name) 安装失败: $_" } } elseif ($dep.NpmPkg -and (Test-Cmd "npm")) { try { $r = Run-CmdWithUI "npm" @("i", "-g", $dep.NpmPkg) "npm 安装 $($dep.Name)" 120000 $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") if (Test-Cmd $dep.Cmd) { Log-OK "$($dep.Name) 安装成功" $installed += $dep.Name } } catch { Log-Fail "$($dep.Name) npm 安装失败: $_" } } elseif (-not $hasWinget) { Log-Fail "$($dep.Name) 需要手动安装 (winget 不可用)" Show-MsgBox "$($dep.Name) 未安装且 winget 不可用。`n请手动安装后重新运行。`n`nNode.js: https://nodejs.org`nGit: https://git-scm.com" "缺少依赖" "OK" "Error" } } } # Claude Code 依赖 npm, 需要在 Node.js 安装后再检查 if (-not (Test-Cmd "claude") -and (Test-Cmd "npm")) { Log-Info "安装 Claude Code..." $r = Run-CmdWithUI "npm" @("i", "-g", "@anthropic-ai/claude-code") "安装 Claude Code (首次约 2 分钟)" 180000 $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") if (Test-Cmd "claude") { Log-OK "Claude Code 安装成功" } else { Log-Fail "Claude Code 安装失败" } } # uv (Python 包管理器, 可选依赖) - 完全静默, 失败不阻断不弹窗 # 安装策略: 1) winget astral-sh.uv 2) Astral 官方脚本 3) pip fallback $uvLogFile = Join-Path $env:TEMP "bookworm-uv-install.log" $uvInstalled = $false if (Test-Cmd "uv") { $uvVer = try { (& uv --version 2>$null | Select-Object -First 1) } catch { "installed" } Log-OK "uv $uvVer (已存在)" $uvInstalled = $true } else { Log-Info "安装 uv (Python 包管理器, 可选)..." # 静默执行子进程, 错误全部捕获到日志文件, 绝不冒泡到 PS2EXE $prevErrPref = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" # 方案 A: winget (最可靠) if (Test-Cmd "winget") { try { $null = & winget install --id=astral-sh.uv -e --silent --accept-source-agreements --accept-package-agreements 2>&1 | Out-File -FilePath $uvLogFile -Encoding utf8 -Append } catch { "[winget] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append } $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") # winget 装到 %LOCALAPPDATA%\Microsoft\WinGet\Packages\astral-sh.uv_* $uvCargoBin = "$env:LOCALAPPDATA\Microsoft\WinGet\Links" if (Test-Path $uvCargoBin) { $env:Path += ";$uvCargoBin" } if (Test-Cmd "uv") { $uvInstalled = $true } } # 方案 B: Astral 官方一行脚本 (走 https://astral.sh/uv/install.ps1) if (-not $uvInstalled) { try { $null = & powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-File -FilePath $uvLogFile -Encoding utf8 -Append } catch { "[astral] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append } # Astral 脚本默认装到 %USERPROFILE%\.local\bin $localBin = Join-Path $env:USERPROFILE ".local\bin" if (Test-Path $localBin) { $env:Path += ";$localBin" } if (Test-Cmd "uv") { $uvInstalled = $true } } # 方案 C: pip fallback (Python 已装且前两个都失败) if (-not $uvInstalled -and (Test-Cmd "python")) { try { $null = & python -m pip install --quiet uv 2>&1 | Out-File -FilePath $uvLogFile -Encoding utf8 -Append } catch { "[pip] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append } try { $pyScripts = Join-Path (Split-Path (& python -c "import sys; print(sys.executable)") -Parent) "Scripts" if (Test-Path $pyScripts) { $env:Path += ";$pyScripts" } } catch {} if (Test-Cmd "uv") { $uvInstalled = $true } } $ErrorActionPreference = $prevErrPref if ($uvInstalled) { Log-OK "uv 安装成功" $installed += "uv" } else { # 静默 fallback: 仅写日志文件, 不调 Log-Warn 避免 PS2EXE 弹窗 "[fail] uv 三种安装方式均失败, Python MCP 将不可用. 详见上方日志." | Out-File -FilePath $uvLogFile -Encoding utf8 -Append Log-Info "uv 未就绪 (可选, 不影响核心功能, 详情: $uvLogFile)" } } # OpenSSL (随 Git 安装) $opensslCmd = Find-OpenSSL if ($opensslCmd) { Log-OK "OpenSSL: $opensslCmd" } else { Log-Warn "OpenSSL 未找到 (凭证解密可能失败)" } # 最终检查 (核心四件套必须) if (-not (Test-Cmd "node") -or -not (Test-Cmd "git") -or -not (Test-Cmd "claude") -or -not (Test-Cmd "pwsh")) { $missing = @() if (-not (Test-Cmd "node")) { $missing += "Node.js" } if (-not (Test-Cmd "git")) { $missing += "Git" } if (-not (Test-Cmd "pwsh")) { $missing += "PowerShell 7" } if (-not (Test-Cmd "claude")) { $missing += "Claude Code" } Show-MsgBox "以下核心依赖安装失败: $($missing -join ', ')`n`n请手动安装后重新运行。" "安装中断" "OK" "Error" exit 1 } # 定位 pwsh.exe 完整路径 (供后续 settings.json 配置使用) $PwshPath = (Get-Command pwsh -ErrorAction SilentlyContinue).Source if (-not $PwshPath) { # winget 默认安装路径 $defaultPaths = @( "$env:ProgramFiles\PowerShell\7\pwsh.exe", "${env:ProgramFiles(x86)}\PowerShell\7\pwsh.exe", "$env:LOCALAPPDATA\Microsoft\PowerShell\pwsh.exe" ) $PwshPath = $defaultPaths | Where-Object { Test-Path $_ } | Select-Object -First 1 } if ($PwshPath) { Log-OK "PowerShell 7 路径: $PwshPath" } else { Log-Warn "pwsh 可执行但无法定位完整路径, 使用 'pwsh'" $PwshPath = "pwsh" } # 可选依赖检查 (不阻断, 用 Log-Info 避免 PS2EXE 弹窗化) $optionalMissing = @() if (-not (Test-Cmd "python")) { $optionalMissing += "Python 3.12" } if (-not (Test-Cmd "uv")) { $optionalMissing += "uv" } if ($optionalMissing.Count -gt 0) { Log-Info "可选依赖未就绪: $($optionalMissing -join ', ') — 仅影响 Python 类 MCP, 核心功能正常" } if ($installed.Count -gt 0) { Log-OK "本次新安装: $($installed -join ', ')" } # ======================================================================== # Phase 2: 网络诊断 # ======================================================================== Log-Phase 2 "网络诊断" # 代理检测 $env:NO_PROXY = "bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1" $env:no_proxy = $env:NO_PROXY $proxyFound = $false # .NET 系统代理 if (-not $env:HTTPS_PROXY) { try { $proxyUri = [System.Net.WebRequest]::DefaultWebProxy.GetProxy("https://api.anthropic.com") if ($proxyUri -and $proxyUri.Authority -ne "api.anthropic.com") { $env:HTTPS_PROXY = "http://$($proxyUri.Authority)" $env:HTTP_PROXY = $env:HTTPS_PROXY Log-OK "系统代理: $($env:HTTPS_PROXY)" $proxyFound = $true } } catch {} } # 注册表 IE 代理 if (-not $proxyFound -and -not $env:HTTPS_PROXY) { 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 Log-OK "IE 代理: $proxy" $proxyFound = $true } } catch {} } # 端口扫描 if (-not $proxyFound -and -not $env:HTTPS_PROXY) { $ports = @(7890,7891,7893,10792,10793,10808,10809,1080,1087,8080,8118) foreach ($port in $ports) { try { $tcp = New-Object System.Net.Sockets.TcpClient $ar = $tcp.BeginConnect("127.0.0.1", $port, $null, $null) $ok = $ar.AsyncWaitHandle.WaitOne(500) if ($ok) { $tcp.EndConnect($ar); $tcp.Close() $env:HTTPS_PROXY = "http://127.0.0.1:$port" $env:HTTP_PROXY = $env:HTTPS_PROXY Log-OK "本地代理端口: $port" $proxyFound = $true break } $tcp.Close() } catch {} } } if ($env:HTTPS_PROXY) { $proxyFound = $true } if (-not $proxyFound) { Log-Warn "未检测到代理/VPN" $r = Show-MsgBox "未检测到代理/VPN 软件。`n国内 Claude Code 需要代理才能启动。`n`n请先启动代理软件 (Clash / V2Ray / 快柠檬),`n然后点击 '重试'。`n`n或点击 '忽略' 继续 (可能失败)。" "网络警告" "AbortRetryIgnore" "Warning" if ($r -eq "Retry") { # 重试代理检测 try { $proxyUri = [System.Net.WebRequest]::DefaultWebProxy.GetProxy("https://api.anthropic.com") if ($proxyUri -and $proxyUri.Authority -ne "api.anthropic.com") { $env:HTTPS_PROXY = "http://$($proxyUri.Authority)" $env:HTTP_PROXY = $env:HTTPS_PROXY Log-OK "系统代理: $($env:HTTPS_PROXY)" } } catch {} } elseif ($r -eq "Abort") { exit 1 } } Log-OK "NO_PROXY: bww.letcareme.com, code.letcareme.com" # 连通性测试 Log-Info "测试网络连通性..." $netTests = @( @{ Name = "Gitea 代码仓库"; Url = "https://code.letcareme.com"; Direct = $true } @{ Name = "API 中转站"; Url = "https://bww.letcareme.com"; Direct = $true } @{ Name = "Claude API"; Url = "https://api.anthropic.com"; Direct = $false } ) foreach ($t in $netTests) { try { $req = [System.Net.HttpWebRequest]::Create($t.Url) $req.Timeout = 8000 $req.Method = "HEAD" if ($t.Direct) { $req.Proxy = [System.Net.GlobalProxySelection]::GetEmptyWebProxy() } $resp = $req.GetResponse() $code = [int]$resp.StatusCode $resp.Close() Log-OK "$($t.Name) ($($t.Url)) - HTTP $code" } catch { $errMsg = $_.Exception.InnerException.Message if (-not $errMsg) { $errMsg = $_.Exception.Message } # 非 200 但能连上也算成功 (如 401, 403) if ($errMsg -match '40[0-9]|30[0-9]') { Log-OK "$($t.Name) - 可达 (需认证)" } else { Log-Warn "$($t.Name) - 不可达: $($errMsg.Substring(0, [Math]::Min(60, $errMsg.Length)))" } } } # ======================================================================== # Phase 3: 仓库克隆 # ======================================================================== Log-Phase 3 "同步 Bookworm 配置" # 配置 git credential helper git config --global credential.helper store 2>$null # 克隆/更新 config 仓库 (.claude/) — 使用 Run-CmdWithUI 防止 UI 冻结 if (Test-Path (Join-Path $ClaudeDir ".git")) { Log-Info "配置仓库已存在, 更新中..." try { Run-CmdWithUI "git" @("-C", $ClaudeDir, "stash") "git stash" 15000 | Out-Null $r = Run-CmdWithUI "git" @("-C", $ClaudeDir, "pull", "--rebase") "同步配置仓库" 120000 if ($r.OK) { Log-OK "配置仓库已更新" } else { Log-Warn "git pull 失败, 使用本地版本" } Run-CmdWithUI "git" @("-C", $ClaudeDir, "stash", "pop") "git stash pop" 15000 | Out-Null } catch { Log-Warn "git pull 失败, 使用本地版本" } } elseif (Test-Path $ClaudeDir) { Log-Info "备份现有 .claude/ 并克隆..." if (Test-Path $BackupDir) { Remove-Item $BackupDir -Recurse -Force } Rename-Item $ClaudeDir $BackupDir $cred = Show-GiteaCredentialDialog $cloneUrl = if ($cred) { $GitUrl -replace '://', "://$($cred.User):$($cred.Pass)@" } else { $GitUrl } $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库" 180000 if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) { Log-OK "配置仓库克隆成功 (旧目录已备份)" } else { Log-Fail "克隆失败" if (Test-Path $BackupDir) { Rename-Item $BackupDir $ClaudeDir } Show-MsgBox "配置仓库克隆失败。`n请检查网络和 Gitea 账号密码。" "克隆失败" "OK" "Error" exit 1 } } else { Log-Info "首次安装, 克隆配置仓库..." $cred = Show-GiteaCredentialDialog $cloneUrl = if ($cred) { $GitUrl -replace '://', "://$($cred.User):$($cred.Pass)@" } else { $GitUrl } $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库" 180000 if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) { Log-OK "配置仓库克隆成功" } else { Log-Fail "克隆失败" Show-MsgBox "配置仓库克隆失败。`n请检查网络连接和 Gitea 账号。" "克隆失败" "OK" "Error" exit 1 } } # 创建本地运行时目录 $dirs = @("debug","sessions","cache","backups","telemetry","shell-snapshots","projects","memory") foreach ($d in $dirs) { $p = Join-Path $ClaudeDir $d if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null } } # ─── 克隆/更新 bookworm-boot (含 crypto-helper.js + secrets-*.enc + install.ps1) ─── if (Test-Path (Join-Path $BootDir ".git")) { Log-Info "boot 仓库已存在, 更新中..." try { $r = Run-CmdWithUI "git" @("-C", $BootDir, "pull", "--rebase") "同步 boot 仓库" 120000 if ($r.OK) { Log-OK "boot 仓库已更新" } else { Log-Warn "boot 仓库更新失败, 使用本地版本" } } catch { Log-Warn "boot 仓库更新失败, 使用本地版本" } } else { Log-Info "克隆 boot 仓库 (含解密工具与凭证)..." if (-not $cred) { $cred = Show-GiteaCredentialDialog } $bootCloneUrl = if ($cred) { $BootUrl -replace '://', "://$($cred.User):$($cred.Pass)@" } else { $BootUrl } $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $bootCloneUrl, $BootDir) "克隆 boot 仓库" 180000 if (-not (Test-Path (Join-Path $BootDir "crypto-helper.js"))) { Log-Fail "启动工具包下载失败" Show-MsgBox "Bookworm 启动工具包下载失败。`n`n请检查:`n1. Gitea 账号和密码是否正确`n2. 网络连接是否正常`n3. 代理软件是否已启动`n`n然后重新运行安装器即可。" "下载失败" "OK" "Error" exit 1 } Log-OK "boot 仓库克隆成功 → $BootDir" } # ======================================================================== # Phase 4: 凭证解密 (GUI 弹窗) # ======================================================================== Log-Phase 4 "凭证解密" $secretsDecrypted = $false # 先查缓存 if (Get-CachedSecrets) { Log-OK "从本日缓存加载凭证 (免密)" $secretsDecrypted = $true } # 再解密 (缓存命中则跳过) if (-not $secretsDecrypted) { $cryptoHelper = Join-Path $BootDir "crypto-helper.js" $useNode = (Test-Cmd "node") -and (Test-Path $cryptoHelper) if (-not $useNode -and -not $opensslCmd) { Log-Fail "无解密工具 (需要 Node.js 或 OpenSSL)" } elseif ((Test-Path $SecretsEnc) -or (Get-ChildItem $BootDir -Filter "secrets-*.enc" -ErrorAction SilentlyContinue)) { $validAttempts = 0 while ($validAttempts -lt 3) { $rawCode = Show-AuthCodeDialog ($validAttempts + 1) 3 if (-not $rawCode) { Log-Warn "用户跳过授权码输入" break } $token = Parse-AuthCode-GUI $rawCode if ($token -eq 'EXPIRED') { Show-MsgBox "授权码已过期。`n请联系管理员获取新授权码。" "授权码过期" "OK" "Warning" continue } if (-not $token) { Show-MsgBox "格式错误。`n正确格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXXXXXX`n`n请检查后重新粘贴。" "格式错误" "OK" "Warning" continue } $validAttempts++ # 按 token 前8位定位 .enc 文件 (多用户独立 Key),回退 secrets.enc $fileId = $token.Substring(0, 8) $encFile = Join-Path $BootDir "secrets-$fileId.enc" if (-not (Test-Path $encFile)) { $encFile = $SecretsEnc } if (-not (Test-Path $encFile)) { Show-MsgBox "未找到对应凭证文件。`n请确认管理员已推送 secrets-$fileId.enc 到 Gitea`n并重新运行安装器(会自动拉取)。" "文件未找到" "OK" "Warning" $token = $null continue } try { if ($useNode) { $decrypted = & node $cryptoHelper decrypt $token $encFile 2>&1 $decExit = $LASTEXITCODE } else { $decrypted = & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in $encFile -pass "pass:$token" 2>$null $decExit = $LASTEXITCODE } $token = $null if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'WRONG_PASSWORD|WRONG_FORMAT|bad decrypt|bad magic') { $count = 0 foreach ($line in $decrypted -split "`n") { $line = $line.Trim() if (-not $line -or $line -notmatch '=') { continue } $key = ($line -split '=', 2)[0].Trim() $value = ($line -split '=', 2)[1].Trim() if ($key -and $value) { [System.Environment]::SetEnvironmentVariable($key, $value, "Process") Log-OK "已注入: $key" $count++ } } $decrypted = $null $secretsDecrypted = $true $cacheResult = Show-MsgBox "授权码验证成功 ($count 个凭证)。`n`n是否缓存至今日 23:59?`n(下次启动免输授权码)" "本日免密" "YesNo" "Question" if ($cacheResult -eq "Yes") { Save-SecretsToCache Log-OK "凭证已缓存至今日 23:59" } break } else { $token = $null $remaining = 3 - $validAttempts if ($remaining -gt 0) { Show-MsgBox "授权码无效(解密失败),剩余重试: $remaining 次" "验证失败" "OK" "Warning" } else { Show-MsgBox "3 次验证均失败。`n请联系管理员重新获取授权码。" "解密失败" "OK" "Error" } } } catch { $token = $null Log-Warn "解密异常: $_" } } } else { Log-Warn "未找到任何 secrets*.enc,跳过凭证解密" } } # end if (-not $secretsDecrypted) # ======================================================================== # Phase 5: 配置渲染 # ======================================================================== Log-Phase 5 "配置渲染" $templateFile = Join-Path $ClaudeDir "settings.template.json" $settingsFile = Join-Path $ClaudeDir "settings.json" if (Test-Path $templateFile) { $claudeRoot = $ClaudeDir.Replace('\', '/') $homeDir = $env:USERPROFILE # pwsh 路径转正斜杠供 JSON 使用 (C:/Program Files/PowerShell/7/pwsh.exe) $pwshJsonPath = if ($PwshPath) { $PwshPath.Replace('\', '/') } else { "pwsh" } $content = Get-Content $templateFile -Raw $content = $content -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot $content = $content -replace '\{\{HOME\}\}', ($homeDir -replace '\\', '\\') $content = $content -replace '\{\{PWSH_PATH\}\}', ($pwshJsonPath -replace '\\', '\\') Set-Content $settingsFile -Value $content -Encoding UTF8 Log-OK "settings.json 已渲染 (ROOT=$claudeRoot, SHELL=$pwshJsonPath)" # settings.local.template.json $localTpl = Join-Path $ClaudeDir "settings.local.template.json" $localSet = Join-Path $ClaudeDir "settings.local.json" if (Test-Path $localTpl) { $lc = Get-Content $localTpl -Raw $lc = $lc -replace '\{\{CLAUDE_ROOT\}\}', $claudeRoot $lc = $lc -replace '\{\{HOME\}\}', ($homeDir -replace '\\', '\\') $lc = $lc -replace '\{\{USERNAME\}\}', $env:USERNAME $lc = $lc -replace '\{\{PWSH_PATH\}\}', ($pwshJsonPath -replace '\\', '\\') Set-Content $localSet -Value $lc -Encoding UTF8 Log-OK "settings.local.json 已渲染" } } else { Log-Warn "settings.template.json 不存在, 跳过渲染" } $env:CLAUDE_HOME = $ClaudeDir # ======================================================================== # Phase 6: MCP 验证 + 自动安装 # ======================================================================== Log-Phase 6 "MCP 服务验证 + 预安装" # ── 6a: Bookworm 完整性检查 ── $skillCount = 0; $hookCount = 0 $skillsDir = Join-Path $ClaudeDir "skills" $hooksDir = Join-Path $ClaudeDir "hooks" if (Test-Path $skillsDir) { $skillCount = (Get-ChildItem $skillsDir -Directory -ErrorAction SilentlyContinue).Count } if (Test-Path $hooksDir) { $hookCount = (Get-ChildItem $hooksDir -Filter "*.js" -File -ErrorAction SilentlyContinue).Count } $claudeMdOK = $false $claudeMdPath = Join-Path $ClaudeDir "CLAUDE.md" if (Test-Path $claudeMdPath) { $cm = Get-Content $claudeMdPath -Raw -ErrorAction SilentlyContinue $claudeMdOK = $cm -match "Bookworm" } $settingsOK = $false if (Test-Path $settingsFile) { $sc = Get-Content $settingsFile -Raw -ErrorAction SilentlyContinue $settingsOK = $sc -match '"hooks"' } $checks = @( @{ Name = "CLAUDE.md (Bookworm 指令)"; OK = $claudeMdOK } @{ Name = "Skills ($skillCount 个)"; OK = ($skillCount -gt 50) } @{ Name = "Hooks ($hookCount 个)"; OK = ($hookCount -gt 10) } @{ Name = "Settings hooks 配置"; OK = $settingsOK } ) $allOK = $true foreach ($c in $checks) { if ($c.OK) { Log-OK $c.Name } else { Log-Fail $c.Name; $allOK = $false } } # ── 6b: API 凭证检查 ── Log-Info "API 凭证检查..." if ($env:ANTHROPIC_API_KEY) { Log-OK "ANTHROPIC_API_KEY 已配置" } else { Log-Fail "ANTHROPIC_API_KEY 未配置" } if ($env:ANTHROPIC_BASE_URL) { Log-OK "ANTHROPIC_BASE_URL 已配置" } else { Log-Warn "ANTHROPIC_BASE_URL 未配置 (将使用默认)" } # ── 6c: MCP npx 包预缓存 (非阻塞 UI) ── Log-Info "MCP 预安装 (npx 包预缓存)..." $npxPackages = @( @{ Name = "context7"; Pkg = "@upstash/context7-mcp@2.1.1" } @{ Name = "sequential-thinking"; Pkg = "@modelcontextprotocol/server-sequential-thinking@2025.12.18" } @{ Name = "playwright"; Pkg = "@playwright/mcp@0.0.68" } @{ Name = "session-continuity"; Pkg = "claude-session-continuity-mcp@1.13.0" } @{ Name = "notebooklm"; Pkg = "notebooklm-mcp@latest" } @{ Name = "cloudflare-docs"; Pkg = "mcp-remote" } @{ Name = "chrome-devtools"; Pkg = "chrome-devtools-mcp@0.18.1" } @{ Name = "github"; Pkg = "@modelcontextprotocol/server-github" } @{ Name = "slack"; Pkg = "@modelcontextprotocol/server-slack" } @{ Name = "firecrawl"; Pkg = "firecrawl-mcp" } @{ Name = "mcp-image"; Pkg = "mcp-image" } @{ Name = "google-drive"; Pkg = "@piotr-agier/google-drive-mcp" } ) $mcpOK = 0; $mcpFail = 0 foreach ($mcp in $npxPackages) { $idx = $mcpOK + $mcpFail + 1 $label = "[$idx/$($npxPackages.Count)] $($mcp.Name)" Update-Progress-SubStatus "$label ..." try { $outTmp = Join-Path $env:TEMP "bw-npm-$($mcp.Name).tmp" $errTmp = Join-Path $env:TEMP "bw-npm-$($mcp.Name)-err.tmp" $proc = Start-Process npm.cmd -ArgumentList "cache", "add", $mcp.Pkg ` -NoNewWindow -PassThru ` -RedirectStandardOutput $outTmp ` -RedirectStandardError $errTmp $ok = Wait-ProcessWithUI $proc 60000 $label if ($ok -and $proc.ExitCode -eq 0) { Bw-Log "OK" "$label cached" $mcpOK++ } else { throw "exit=$($proc.ExitCode)" } Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue } catch { Bw-Log "WARN" "$label failed: $_" $mcpFail++ } } Log-OK "npx 预缓存: $mcpOK/$($npxPackages.Count) 成功" # ── 6d: Playwright 浏览器安装 (非阻塞 UI) ── Log-Info "Playwright 浏览器安装..." try { $pwBrowserPath = Join-Path $env:USERPROFILE "AppData\Local\ms-playwright" if (Test-Path (Join-Path $pwBrowserPath "chromium-*")) { Log-OK "Playwright Chromium 已存在" } else { $outTmp = Join-Path $env:TEMP "bw-playwright.tmp" $errTmp = Join-Path $env:TEMP "bw-playwright-err.tmp" $pwProc = Start-Process npx.cmd -ArgumentList "-y", "playwright", "install", "chromium" ` -NoNewWindow -PassThru ` -RedirectStandardOutput $outTmp ` -RedirectStandardError $errTmp $pwOk = Wait-ProcessWithUI $pwProc 300000 "下载 Chromium (~150MB)" Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue if (-not $pwOk) { Log-Warn "Playwright 下载超时, 跳过" } elseif (Test-Path (Join-Path $pwBrowserPath "chromium-*")) { Log-OK "Playwright Chromium 安装成功" } else { Log-Warn "Playwright Chromium 安装可能未完成" } } } catch { Log-Warn "Playwright 浏览器安装失败: $_ (不影响核心功能)" } # ── 6e: Python MCP (uvx) 验证 (非阻塞 UI) ── if (Test-Cmd "uvx") { Log-Info "Python MCP 验证 (uvx)..." $uvxPackages = @( @{ Name = "windows-mcp"; Args = @("--python", "3.13", "windows-mcp") } @{ Name = "atlassian"; Args = @("mcp-atlassian") } ) foreach ($pkg in $uvxPackages) { try { $outTmp = Join-Path $env:TEMP "bw-uvx-$($pkg.Name).tmp" $errTmp = Join-Path $env:TEMP "bw-uvx-$($pkg.Name)-err.tmp" $installArgs = @("tool", "install") + $pkg.Args $proc = Start-Process uv -ArgumentList $installArgs ` -NoNewWindow -PassThru ` -RedirectStandardOutput $outTmp ` -RedirectStandardError $errTmp $ok = Wait-ProcessWithUI $proc 90000 "uvx $($pkg.Name)" Remove-Item $outTmp, $errTmp -Force -ErrorAction SilentlyContinue if ($ok) { Bw-Log "OK" "uvx $($pkg.Name) ready" } } catch { Bw-Log "WARN" "uvx $($pkg.Name): $_" } } } else { Bw-Log "INFO" "uvx 不可用, 跳过 Python MCP" } # ── 6f: 可选 API Key 提示 ── $optional = @( @{ Key = "GITHUB_PERSONAL_ACCESS_TOKEN"; Name = "GitHub MCP" } @{ Key = "FIRECRAWL_API_KEY"; Name = "Firecrawl MCP" } @{ Key = "SLACK_BOT_TOKEN"; Name = "Slack MCP" } @{ Key = "BROWSERBASE_API_KEY"; Name = "Browserbase MCP" } @{ Key = "GEMINI_API_KEY"; Name = "MCP Image / Browserbase" } @{ Key = "ATLASSIAN_API_TOKEN"; Name = "Atlassian MCP" } ) $missingOpt = $optional | Where-Object { -not [System.Environment]::GetEnvironmentVariable($_.Key, "Process") } if ($missingOpt.Count -gt 0) { foreach ($m in $missingOpt) { Bw-Log "INFO" "可选 Key 未配置: $($m.Name) ($($m.Key))" } } # ======================================================================== # Phase 7: 完成 + 启动 # ======================================================================== Log-Phase 7 "安装完成" # 关闭 GUI 进度窗口 (后续由 Show-MsgBox 显示成功/失败) Close-ProgressForm # 创建桌面快捷方式 New-DesktopShortcuts if ($allOK -and $env:ANTHROPIC_API_KEY) { Bw-Log "DONE" "安装成功 ($skillCount Skills / $hookCount Hooks)" Show-MsgBox "Bookworm Portable 安装成功!`n`n$skillCount Skills / $hookCount Hooks 全部就绪`n`n点击确定后将自动启动 Claude Code。`n`n日志: $BWLogFile" "安装成功" "OK" "Information" | Out-Null if (-not $SkipLaunch) { # 启动 Claude Code: 用户的 Claude Code 由 cmd 窗口启动 (PS2EXE -NoConsole 下会创建新窗口) Start-Process -FilePath "cmd.exe" -ArgumentList "/k", "claude --dangerously-skip-permissions" } } else { Bw-Log "DONE" "安装完成但部分受限 allOK=$allOK hasKey=$($env:ANTHROPIC_API_KEY -ne $null)" $issues = @() if (-not $allOK) { $issues += "- Bookworm 配置不完整" } if (-not $env:ANTHROPIC_API_KEY) { $issues += "- API 凭证未解密" } $issueText = $issues -join "`n" $launchResult = Show-MsgBox "安装完成, 但存在以下问题:`n$issueText`n`n是否仍然启动 Claude Code?`n(将以受限模式运行)`n`n日志: $BWLogFile" "安装警告" "YesNo" "Warning" if ($launchResult -eq "Yes" -and -not $SkipLaunch) { Start-Process -FilePath "cmd.exe" -ArgumentList "/k", "claude --dangerously-skip-permissions" } }