2026-04-06 14:42:51 +08:00
<#
. SYNOPSIS
Bookworm Portable - 全自动一键安装器
. DESCRIPTION
全新电脑从零到 Bookworm 完全就绪 , 最大程度自动化 。
7 阶段 : 环境检测 → 依赖安装 → 网络诊断 → 仓库克隆 → 凭证解密 → MCP 验证 → 启动
需要人工输入时弹出 GUI 对话框 。
. USAGE
. \ auto-setup . ps1
. \ auto-setup . ps1 -SkipLaunch # 安装但不启动
#>
param (
[ switch ] $SkipLaunch
)
$ErrorActionPreference = " Stop "
# ─── 路径定义 ────────────────────────────────────────
$ScriptDir = if ( $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 "
$SecretsEnc = Join-Path $ScriptDir " secrets.enc "
$TOTAL_PHASES = 7
# ─── GUI 初始化 ─────────────────────────────────────
Add-Type -AssemblyName System . Windows . Forms
Add-Type -AssemblyName System . Drawing
[ System.Windows.Forms.Application ] :: EnableVisualStyles ( )
# ─── 颜色输出 ────────────────────────────────────────
function Log-OK($msg ) { Write-Host " [OK] $msg " -ForegroundColor Green }
function Log-Info($msg ) { Write-Host " [..] $msg " -ForegroundColor Cyan }
function Log-Warn($msg ) { Write-Host " [!] $msg " -ForegroundColor Yellow }
function Log-Fail($msg ) { Write-Host " [!!] $msg " -ForegroundColor Red }
function Log-Phase($n , $title ) {
Write-Host " "
Write-Host " [ $n / $TOTAL_PHASES ] $title " -ForegroundColor White -BackgroundColor DarkBlue
Write-Progress -Activity " Bookworm 自动安装 " -Status " $title " -PercentComplete ( [ int ] ( $n / $TOTAL_PHASES * 100 ) )
}
function Test-Cmd($cmd ) { [ bool ] ( Get-Command $cmd -ErrorAction SilentlyContinue ) }
# ─── GUI 对话框 ─────────────────────────────────────
function Show-MsgBox($text , $title = " Bookworm 安装 " , $buttons = " OK " , $icon = " Information " ) {
[ System.Windows.Forms.MessageBox ] :: Show ( $text , $title , $buttons , $icon )
}
function Show-PasswordDialog($prompt = " 输入主密码解密凭证 " , $attempt = 1 , $maxAttempts = 3 ) {
$form = New-Object System . Windows . Forms . Form
$form . Text = " Bookworm - 凭证解密 ( $attempt / $maxAttempts ) "
$form . Size = New-Object System . Drawing . Size ( 420 , 220 )
$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 , 20 )
$label . Size = New-Object System . Drawing . Size ( 360 , 40 )
$label . Text = $prompt
$label . Font = New-Object System . Drawing . Font ( " Segoe UI " , 10 )
$form . Controls . Add ( $label )
$passBox = New-Object System . Windows . Forms . TextBox
$passBox . Location = New-Object System . Drawing . Point ( 20 , 70 )
$passBox . Size = New-Object System . Drawing . Size ( 360 , 30 )
$passBox . PasswordChar = '*'
$passBox . Font = New-Object System . Drawing . Font ( " Consolas " , 12 )
$form . Controls . Add ( $passBox )
$btnOK = New-Object System . Windows . Forms . Button
$btnOK . Location = New-Object System . Drawing . Point ( 200 , 120 )
$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 ( 300 , 120 )
$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 )
$form . Add_Shown ( { $passBox . Focus ( ) } )
$result = $form . ShowDialog ( )
if ( $result -eq [ System.Windows.Forms.DialogResult ] :: OK ) {
return $passBox . Text
}
return $null
}
function Show-GiteaCredentialDialog {
$form = New-Object System . Windows . Forms . Form
$form . Text = " Bookworm - Gitea 登录 "
$form . Size = New-Object System . Drawing . Size ( 420 , 280 )
$form . StartPosition = " CenterScreen "
$form . FormBorderStyle = " FixedDialog "
$form . MaximizeBox = $false
$form . TopMost = $true
$lblInfo = New-Object System . Windows . Forms . Label
$lblInfo . Location = New-Object System . Drawing . Point ( 20 , 15 )
$lblInfo . Size = New-Object System . Drawing . Size ( 360 , 25 )
$lblInfo . Text = " 输入 Gitea 账号 (code.letcareme.com) "
$lblInfo . Font = New-Object System . Drawing . Font ( " Segoe UI " , 10 )
$form . Controls . Add ( $lblInfo )
$lblUser = New-Object System . Windows . Forms . Label
$lblUser . Location = New-Object System . Drawing . Point ( 20 , 50 )
$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 , 48 )
$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 , 90 )
$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 , 88 )
$txtPass . Size = New-Object System . Drawing . Size ( 280 , 25 )
$txtPass . PasswordChar = '*'
$txtPass . Font = New-Object System . Drawing . Font ( " Consolas " , 11 )
$form . Controls . Add ( $txtPass )
$btnOK = New-Object System . Windows . Forms . Button
$btnOK . Location = New-Object System . Drawing . Point ( 200 , 140 )
$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 ( 300 , 140 )
$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 )
$form . Add_Shown ( { $txtUser . Focus ( ) } )
$result = $form . ShowDialog ( )
if ( $result -eq [ System.Windows.Forms.DialogResult ] :: OK -and $txtUser . Text -and $txtPass . Text ) {
return @ { User = $txtUser . Text ; 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 " )
# 快速启动
$shortcut = $shell . CreateShortcut ( " $desktop \Bookworm.lnk " )
$batPath = Join-Path $ScriptDir " 启动Bookworm.bat "
if ( -not ( Test-Path $batPath ) ) { $batPath = Join-Path $ScriptDir " Bookworm-OneClick.bat " }
$shortcut . TargetPath = $batPath
$shortcut . WorkingDirectory = $ScriptDir
$shortcut . Description = " Bookworm Smart Assistant "
$shortcut . Save ( )
# 更新启动
$shortcut2 = $shell . CreateShortcut ( " $desktop \更新Bookworm.lnk " )
$updateBat = Join-Path $ScriptDir " 更新并启动Bookworm.bat "
if ( Test-Path $updateBat ) {
$shortcut2 . TargetPath = $updateBat
$shortcut2 . WorkingDirectory = $ScriptDir
$shortcut2 . Description = " 更新并启动 Bookworm "
$shortcut2 . Save ( )
}
Log-OK " 桌面快捷方式已创建 "
} catch { Log-Warn " 快捷方式创建失败: $_ " }
}
# ========================================================================
# Banner
# ========================================================================
Write-Host " "
Write-Host " +---------------------------------------------------+ " -ForegroundColor Cyan
Write-Host " | | " -ForegroundColor Cyan
Write-Host " | Bookworm Portable - 全自动安装器 | " -ForegroundColor Cyan
Write-Host " | 92 Skills / 18 Agents / 34 Hooks | " -ForegroundColor Cyan
Write-Host " | | " -ForegroundColor Cyan
Write-Host " +---------------------------------------------------+ " -ForegroundColor Cyan
Write-Host " "
# ========================================================================
# Phase 1: 环境检测 + 依赖自动安装
# ========================================================================
Log-Phase 1 " 环境检测 + 依赖自动安装 "
# 刷新 PATH
$env:Path = [ System.Environment ] :: GetEnvironmentVariable ( " Path " , " Machine " ) + " ; " + [ System.Environment ] :: GetEnvironmentVariable ( " Path " , " User " )
$deps = @ (
2026-04-06 19:48:48 +08:00
@ { 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 }
2026-04-06 14:42:51 +08:00
)
$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 {
$output = winget install $dep . WingetId - -accept -source -agreements - -accept -package -agreements 2 > & 1
$output | Select-Object -Last 3 | ForEach-Object { Write-Host " $_ " -ForegroundColor Gray }
# 刷新 PATH
$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 {
Write-Host " npm i -g $( $dep . NpmPkg ) ... " -ForegroundColor Gray
npm i -g $dep . NpmPkg 2 > & 1 | Select-Object -Last 3 | ForEach-Object { Write-Host " $_ " -ForegroundColor Gray }
$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 `n Node.js: https://nodejs.org `n Git: https://git-scm.com " " 缺少依赖 " " OK " " Error "
}
}
}
# Claude Code 依赖 npm, 需要在 Node.js 安装后再检查
if ( -not ( Test-Cmd " claude " ) -and ( Test-Cmd " npm " ) ) {
Log-Info " 安装 Claude Code... "
npm i -g @anthropic -ai / claude-code 2 > & 1 | Select-Object -Last 3 | ForEach-Object { Write-Host " $_ " -ForegroundColor Gray }
$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 安装失败 " }
}
2026-04-06 19:48:48 +08:00
# uv 依赖 Python, 需要在 Python 安装后再检查
if ( Test-Cmd " python " ) {
if ( Test-Cmd " uv " ) {
$uvVer = try { & uv - -version 2 > $null | Select-Object -First 1 } catch { " installed " }
Log-OK " uv $uvVer "
} else {
Log-Info " 安装 uv (Python 包管理器)... "
try {
& python -m pip install uv - -quiet 2 > & 1 | Select-Object -Last 2 | ForEach-Object { Write-Host " $_ " -ForegroundColor Gray }
$env:Path = [ System.Environment ] :: GetEnvironmentVariable ( " Path " , " Machine " ) + " ; " + [ System.Environment ] :: GetEnvironmentVariable ( " Path " , " User " )
# pip install 的 Scripts 目录可能不在 PATH, 追加
$pyScripts = Join-Path ( Split-Path ( & python -c " import sys; print(sys.executable) " ) -Parent ) " Scripts "
if ( Test-Path $pyScripts ) { $env:Path + = " ; $pyScripts " }
if ( Test-Cmd " uv " ) {
Log-OK " uv 安装成功 "
$installed + = " uv "
} else {
Log-Warn " uv 安装后未找到, 尝试 uvx 替代检查... "
}
} catch { Log-Warn " uv 安装失败: $_ " }
}
} else {
Log-Warn " Python 未安装, 跳过 uv (部分 MCP 不可用) "
}
2026-04-06 14:42:51 +08:00
# OpenSSL (随 Git 安装)
$opensslCmd = Find-OpenSSL
if ( $opensslCmd ) { Log-OK " OpenSSL: $opensslCmd " } else { Log-Warn " OpenSSL 未找到 (凭证解密可能失败) " }
2026-04-06 19:48:48 +08:00
# 最终检查 (核心四件套必须)
if ( -not ( Test-Cmd " node " ) -or -not ( Test-Cmd " git " ) -or -not ( Test-Cmd " claude " ) -or -not ( Test-Cmd " pwsh " ) ) {
2026-04-06 14:42:51 +08:00
$missing = @ ( )
if ( -not ( Test-Cmd " node " ) ) { $missing + = " Node.js " }
if ( -not ( Test-Cmd " git " ) ) { $missing + = " Git " }
2026-04-06 19:48:48 +08:00
if ( -not ( Test-Cmd " pwsh " ) ) { $missing + = " PowerShell 7 " }
2026-04-06 14:42:51 +08:00
if ( -not ( Test-Cmd " claude " ) ) { $missing + = " Claude Code " }
2026-04-06 19:48:48 +08:00
Show-MsgBox " 以下核心依赖安装失败: $( $missing -join ', ' ) `n `n 请手动安装后重新运行。 " " 安装中断 " " OK " " Error "
2026-04-06 14:42:51 +08:00
exit 1
}
2026-04-06 19:48:48 +08:00
# 定位 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 "
}
# 可选依赖警告 (不阻断)
$optionalMissing = @ ( )
if ( -not ( Test-Cmd " python " ) ) { $optionalMissing + = " Python 3.12 " }
if ( -not ( Test-Cmd " uv " ) ) { $optionalMissing + = " uv " }
if ( $optionalMissing . Count -gt 0 ) {
Log-Warn " 可选依赖未就绪: $( $optionalMissing -join ', ' ) (部分 MCP 不可用) "
}
2026-04-06 14:42:51 +08:00
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 "
# 连通性测试
Write-Host " "
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/)
if ( Test-Path ( Join-Path $ClaudeDir " .git " ) ) {
Log-Info " 配置仓库已存在, 更新中... "
Push-Location $ClaudeDir
try {
$stash = git stash 2 > & 1
git pull - -rebase 2 > & 1 | ForEach-Object { Write-Host " $_ " -ForegroundColor Gray }
if ( $stash -notmatch 'No local changes' ) { git stash pop 2 > & 1 | Out-Null }
Log-OK " 配置仓库已更新 "
} catch { Log-Warn " git pull 失败, 使用本地版本 " }
finally { Pop-Location }
}
elseif ( Test-Path $ClaudeDir ) {
# 已有 .claude 但非 git — 备份后克隆
Log-Info " 备份现有 .claude/ 并克隆... "
if ( Test-Path $BackupDir ) { Remove-Item $BackupDir -Recurse -Force }
Rename-Item $ClaudeDir $BackupDir
# 可能需要 Gitea 凭证
$cred = Show-GiteaCredentialDialog
if ( $cred ) {
$credUrl = $GitUrl -replace '://' , " :// $( $cred . User ) : $( $cred . Pass ) @ "
git clone - -depth 1 $credUrl $ClaudeDir 2 > & 1 | ForEach-Object { Write-Host " $_ " -ForegroundColor Gray }
} else {
git clone - -depth 1 $GitUrl $ClaudeDir 2 > & 1 | ForEach-Object { Write-Host " $_ " -ForegroundColor Gray }
}
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
if ( $cred ) {
$credUrl = $GitUrl -replace '://' , " :// $( $cred . User ) : $( $cred . Pass ) @ "
git clone - -depth 1 $credUrl $ClaudeDir 2 > & 1 | ForEach-Object { Write-Host " $_ " -ForegroundColor Gray }
} else {
git clone - -depth 1 $GitUrl $ClaudeDir 2 > & 1 | ForEach-Object { Write-Host " $_ " -ForegroundColor Gray }
}
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 }
}
# ========================================================================
# Phase 4: 凭证解密 (GUI 弹窗)
# ========================================================================
Log-Phase 4 " 凭证解密 "
$secretsDecrypted = $false
# 先查缓存
if ( Get-CachedSecrets ) {
Log-OK " 从本日缓存加载凭证 (免密) "
$secretsDecrypted = $true
}
2026-04-06 19:48:48 +08:00
# 再解密 (缓存命中则跳过)
if ( -not $secretsDecrypted ) {
2026-04-06 15:04:02 +08:00
$cryptoHelper = Join-Path $ScriptDir " 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 ) {
2026-04-06 14:42:51 +08:00
for ( $attempt = 1 ; $attempt -le 3 ; $attempt + + ) {
$password = Show-PasswordDialog " 输入主密码解密凭证 `n (非 Gitea 密码, 区分大小写) " $attempt 3
if ( -not $password ) {
Log-Warn " 用户取消密码输入 "
break
}
try {
2026-04-06 15:04:02 +08:00
if ( $useNode ) {
# Node.js crypto-helper (BWENC1 格式, 跨平台一致)
$decrypted = & node $cryptoHelper decrypt $password $SecretsEnc 2 > & 1
$decExit = $LASTEXITCODE
} else {
# OpenSSL 回退 (仅支持 Salted__ 格式)
$decrypted = & $opensslCmd enc -aes - 256 -cbc -d -pbkdf2 -iter 600000 -in $SecretsEnc -pass " pass: $password " 2 > $null
$decExit = $LASTEXITCODE
}
2026-04-06 14:42:51 +08:00
$password = $null # 立即清零
2026-04-06 15:04:02 +08:00
if ( $decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'WRONG_PASSWORD|WRONG_FORMAT|bad decrypt|bad magic' ) {
2026-04-06 14:42:51 +08:00
$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 {
$password = $null
if ( $attempt -lt 3 ) {
Show-MsgBox " 密码错误, 剩余重试: $( 3 - $attempt ) 次 " " 密码错误 " " OK " " Warning "
} else {
Show-MsgBox " 3 次密码均错误。 `n 凭证未解密, Claude Code 可能无法启动。 `n 请联系管理员确认密码。 " " 解密失败 " " OK " " Error "
}
}
} catch {
$password = $null
Log-Warn " 解密异常: $_ "
}
}
}
elseif ( -not ( Test-Path $SecretsEnc ) ) {
Log-Warn " secrets.enc 不存在, 跳过凭证解密 "
}
2026-04-06 19:48:48 +08:00
} # end if (-not $secretsDecrypted)
2026-04-06 14:42:51 +08:00
# ========================================================================
# 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
2026-04-06 19:48:48 +08:00
# pwsh 路径转正斜杠供 JSON 使用 (C:/Program Files/PowerShell/7/pwsh.exe)
$pwshJsonPath = if ( $PwshPath ) { $PwshPath . Replace ( '\' , '/' ) } else { " pwsh " }
2026-04-06 14:42:51 +08:00
$content = Get-Content $templateFile -Raw
$content = $content -replace '\{\{CLAUDE_ROOT\}\}' , $claudeRoot
$content = $content -replace '\{\{HOME\}\}' , ( $homeDir -replace '\\' , '\\' )
2026-04-06 19:48:48 +08:00
$content = $content -replace '\{\{PWSH_PATH\}\}' , ( $pwshJsonPath -replace '\\' , '\\' )
2026-04-06 14:42:51 +08:00
Set-Content $settingsFile -Value $content -Encoding UTF8
2026-04-06 19:48:48 +08:00
Log-OK " settings.json 已渲染 (ROOT= $claudeRoot , SHELL= $pwshJsonPath ) "
2026-04-06 14:42:51 +08:00
# 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
2026-04-06 19:48:48 +08:00
$lc = $lc -replace '\{\{PWSH_PATH\}\}' , ( $pwshJsonPath -replace '\\' , '\\' )
2026-04-06 14:42:51 +08:00
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 验证 + 自动安装
# ========================================================================
2026-04-06 19:48:48 +08:00
Log-Phase 6 " MCP 服务验证 + 预安装 "
2026-04-06 14:42:51 +08:00
2026-04-06 19:48:48 +08:00
# ── 6a: Bookworm 完整性检查 ──
2026-04-06 14:42:51 +08:00
$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 }
}
2026-04-06 19:48:48 +08:00
# ── 6b: API 凭证检查 ──
2026-04-06 14:42:51 +08:00
Write-Host " "
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 未配置 (将使用默认) " }
2026-04-06 19:48:48 +08:00
# ── 6c: MCP npx 包预缓存 ──
2026-04-06 14:42:51 +08:00
Write-Host " "
2026-04-06 19:48:48 +08:00
Log-Info " MCP 预安装 (npx 包预缓存)... "
# Tier 1: 纯 npx, 无需 Key
$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 " }
# Tier 2: npx + Key
@ { 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 " }
)
2026-04-06 14:42:51 +08:00
2026-04-06 19:48:48 +08:00
$mcpOK = 0 ; $mcpFail = 0
foreach ( $mcp in $npxPackages ) {
$idx = $mcpOK + $mcpFail + 1
Write-Host " [ $idx / $( $npxPackages . Count ) ] $( $mcp . Name ) " -NoNewline -ForegroundColor Gray
try {
# npm cache add 只下载到缓存, 不执行 (MCP 是 stdio 长驻进程, 不能用 --help)
$proc = Start-Process npm . cmd -ArgumentList " cache " , " add " , $mcp . Pkg -NoNewWindow -PassThru -RedirectStandardOutput " NUL " -RedirectStandardError " NUL "
$exited = $proc . WaitForExit ( 60000 ) # 60 秒超时
if ( -not $exited ) { $proc . Kill ( ) ; throw " timeout " }
if ( $proc . ExitCode -eq 0 ) {
Write-Host " [cached] " -ForegroundColor Green
$mcpOK + +
} else { throw " exit $( $proc . ExitCode ) " }
} catch {
Write-Host " [FAIL: $_ ] " -ForegroundColor Red
$mcpFail + +
}
}
Log-OK " npx 预缓存: $mcpOK 成功, $mcpFail 失败 (共 $( $npxPackages . Count ) ) "
# ── 6d: Playwright 浏览器安装 ──
Write-Host " "
Log-Info " Playwright 浏览器安装... "
2026-04-06 14:42:51 +08:00
try {
2026-04-06 19:48:48 +08:00
$pwBrowserPath = Join-Path $env:USERPROFILE " AppData\Local\ms-playwright "
if ( Test-Path ( Join-Path $pwBrowserPath " chromium-* " ) ) {
Log-OK " Playwright Chromium 已存在 "
} else {
Write-Host " 下载 Chromium (首次约 150MB, 最长等 5 分钟)... " -ForegroundColor Gray
$pwProc = Start-Process npx . cmd -ArgumentList " -y " , " playwright " , " install " , " chromium " -NoNewWindow -PassThru
$pwExited = $pwProc . WaitForExit ( 300000 ) # 5 分钟超时
if ( -not $pwExited ) { $pwProc . Kill ( ) ; 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) 验证 ──
Write-Host " "
if ( Test-Cmd " uvx " ) {
Log-Info " Python MCP 验证 (uvx)... "
$uvxPackages = @ (
@ { Name = " windows-mcp " ; Args = @ ( " --python " , " 3.13 " , " windows-mcp " ) }
@ { Name = " atlassian " ; Args = @ ( " mcp-atlassian " ) }
)
2026-04-06 14:42:51 +08:00
2026-04-06 19:48:48 +08:00
foreach ( $pkg in $uvxPackages ) {
Write-Host " $( $pkg . Name ) " -NoNewline -ForegroundColor Gray
try {
# uv tool install 只下载不启动 (MCP 是 stdio 长驻进程, --help 会挂起)
$installArgs = @ ( " tool " , " install " ) + $pkg . Args
$proc = Start-Process uv -ArgumentList $installArgs -NoNewWindow -PassThru -RedirectStandardOutput " NUL " -RedirectStandardError " NUL "
$exited = $proc . WaitForExit ( 90000 ) # 90 秒超时 (Python 包较大)
if ( -not $exited ) { $proc . Kill ( ) ; throw " timeout " }
Write-Host " [ready] " -ForegroundColor Green
} catch {
Write-Host " [skip: $_ ] " -ForegroundColor Yellow
}
}
2026-04-06 14:42:51 +08:00
} else {
2026-04-06 19:48:48 +08:00
Log-Warn " uvx 不可用, 跳过 Python MCP (windows-mcp, atlassian) "
2026-04-06 14:42:51 +08:00
}
2026-04-06 19:48:48 +08:00
# ── 6f: 可选 API Key 提示 ──
2026-04-06 14:42:51 +08:00
$optional = @ (
@ { Key = " GITHUB_PERSONAL_ACCESS_TOKEN " ; Name = " GitHub MCP " }
@ { Key = " FIRECRAWL_API_KEY " ; Name = " Firecrawl MCP " }
@ { Key = " SLACK_BOT_TOKEN " ; Name = " Slack MCP " }
2026-04-06 19:48:48 +08:00
@ { Key = " BROWSERBASE_API_KEY " ; Name = " Browserbase MCP " }
@ { Key = " GEMINI_API_KEY " ; Name = " MCP Image / Browserbase " }
@ { Key = " ATLASSIAN_API_TOKEN " ; Name = " Atlassian MCP " }
2026-04-06 14:42:51 +08:00
)
$missingOpt = $optional | Where-Object { -not [ System.Environment ] :: GetEnvironmentVariable ( $_ . Key , " Process " ) }
if ( $missingOpt . Count -gt 0 ) {
Write-Host " "
2026-04-06 19:48:48 +08:00
Write-Host " 可选 MCP (Key 未配置, 不影响核心功能): " -ForegroundColor DarkGray
foreach ( $m in $missingOpt ) { Write-Host " [-] $( $m . Name ) ( $( $m . Key ) ) " -ForegroundColor DarkGray }
2026-04-06 14:42:51 +08:00
}
# ========================================================================
# Phase 7: 完成 + 启动
# ========================================================================
Log-Phase 7 " 安装完成 "
Write-Progress -Activity " Bookworm 自动安装 " -Completed
# 创建桌面快捷方式
New-DesktopShortcuts
Write-Host " "
if ( $allOK -and $env:ANTHROPIC_API_KEY ) {
Write-Host " +---------------------------------------------------+ " -ForegroundColor Green
Write-Host " | | " -ForegroundColor Green
Write-Host " | Bookworm Portable 安装成功! | " -ForegroundColor Green
Write-Host " | $skillCount Skills / $hookCount Hooks / 全部就绪 | " -ForegroundColor Green
Write-Host " | | " -ForegroundColor Green
Write-Host " +---------------------------------------------------+ " -ForegroundColor Green
if ( -not $SkipLaunch ) {
Write-Host " "
Log-Info " 正在启动 Claude Code... "
Write-Host " "
2026-04-06 19:48:48 +08:00
# 始终在独立 pwsh7 窗口中启动 (bat 调 pwsh 子进程时窗口仍属 cmd.exe)
$pwshCmd = Get-Command pwsh -ErrorAction SilentlyContinue
if ( $pwshCmd ) {
2026-04-06 20:18:30 +08:00
Start-Process $pwshCmd . Source -ArgumentList " -NoLogo " , " -NoExit " , " -Command " , " & claude --dangerously-skip-permissions " -WorkingDirectory $env:USERPROFILE -WindowStyle Normal
2026-04-06 19:48:48 +08:00
} else {
2026-04-06 20:18:30 +08:00
& claude - -dangerously -skip -permissions
2026-04-06 19:48:48 +08:00
}
2026-04-06 14:42:51 +08:00
}
} else {
Write-Host " +---------------------------------------------------+ " -ForegroundColor Yellow
Write-Host " | | " -ForegroundColor Yellow
Write-Host " | 安装完成 (部分功能可能受限) | " -ForegroundColor Yellow
Write-Host " | | " -ForegroundColor Yellow
Write-Host " +---------------------------------------------------+ " -ForegroundColor Yellow
$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 (将以受限模式运行) " " 安装警告 " " YesNo " " Warning "
if ( $launchResult -eq " Yes " -and -not $SkipLaunch ) {
2026-04-06 19:48:48 +08:00
$pwshCmd = Get-Command pwsh -ErrorAction SilentlyContinue
if ( $pwshCmd ) {
2026-04-06 20:18:30 +08:00
Start-Process $pwshCmd . Source -ArgumentList " -NoLogo " , " -NoExit " , " -Command " , " & claude --dangerously-skip-permissions " -WorkingDirectory $env:USERPROFILE -WindowStyle Normal
2026-04-06 19:48:48 +08:00
} else {
2026-04-06 20:18:30 +08:00
& claude - -dangerously -skip -permissions
2026-04-06 19:48:48 +08:00
}
2026-04-06 14:42:51 +08:00
}
}