1 line
86 KiB
JSON
1 line
86 KiB
JSON
{"repo": "cc-claws/cc-code", "files": {"npm/install.js": {"3bbca7ae6170f704278b0b0307eae8f37dacfc77": {"t": 1785746661.063357, "n": 10141, "h": "e5c673fc926830b3", "c": "#!/usr/bin/env node\n\nconst { createWriteStream, mkdirSync, chmodSync, existsSync, renameSync, unlinkSync, writeFileSync } = require(\"fs\");\nconst { join } = require(\"path\");\nconst { execSync } = require(\"child_process\");\n\nconst { homedir } = require(\"os\");\n\nconst VERSION = require(\"./package.json\").version;\nconst REPO = \"cc-claws/cc-code\";\nconst BASE_URL = `https://github.com/${REPO}/releases/download/npm-v${VERSION}`;\n\nconst PLATFORMS = {\n \"linux-x64\": { os: \"linux\", arch: \"x64\", suffix: \"linux-x86_64\", ext: \"tar.gz\" },\n \"linux-arm64\": { os: \"linux\", arch: \"arm64\", suffix: \"linux-aarch64\", ext: \"tar.gz\" },\n \"darwin-x64\": { os: \"darwin\", arch: \"x64\", suffix: \"macos-x86_64\", ext: \"tar.gz\" },\n \"darwin-arm64\": { os: \"darwin\", arch: \"arm64\", suffix: \"macos-aarch64\", ext: \"tar.gz\" },\n \"win32-x64\": { os: \"win32\", arch: \"x64\", suffix: \"windows-x86_64\", ext: \"zip\" },\n};\n\nfunction getPlatformKey() {\n const key = `${process.platform}-${process.arch}`;\n if (!PLATFORMS[key]) {\n throw new Error(`Unsupported platform: ${key}. Supported: ${Object.keys(PLATFORMS).join(\", \")}`);\n }\n return key;\n}\n\nfunction getProxyUrl() {\n // Check common proxy environment variables\n const proxy = process.env.HTTPS_PROXY || process.env.https_proxy\n || process.env.HTTP_PROXY || process.env.http_proxy\n || process.env.ALL_PROXY || process.env.all_proxy;\n return proxy || null;\n}\n\nfunction download(url) {\n const proxyUrl = getProxyUrl();\n\n if (proxyUrl) {\n return downloadViaProxy(url, proxyUrl);\n }\n return downloadDirect(url);\n}\n\nfunction downloadDirect(url) {\n const { get } = require(\"https\");\n return new Promise((resolve, reject) => {\n get(url, (res) => {\n if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n downloadDirect(res.headers.location).then(resolve, reject);\n return;\n }\n if (res.statusCode !== 200) {\n reject(new Error(`Download failed: HTTP ${res.statusCode} for ${url}`));\n return;\n }\n const chunks = [];\n res.on(\"data\", (chunk) => chunks.push(chunk));\n res.on(\"end\", () => resolve(Buffer.concat(chunks)));\n res.on(\"error\", reject);\n }).on(\"error\", reject);\n });\n}\n\nfunction downloadViaProxy(url, proxyUrl) {\n const { URL } = require(\"url\");\n const target = new URL(url);\n const proxy = new URL(proxyUrl);\n\n const isHttps = proxy.protocol === \"https:\" || proxy.protocol === \"HTTPS:\";\n const proxyModule = isHttps ? require(\"https\") : require(\"http\");\n\n const proxyOpts = {\n hostname: proxy.hostname,\n port: proxy.port || (isHttps ? 443 : 80),\n path: url,\n method: \"GET\",\n headers: { \"Host\": target.hostname, \"User-Agent\": \"cc-code-installer\" },\n };\n\n // Support proxy auth\n if (proxy.username) {\n const auth = decodeURIComponent(`${proxy.username}:${proxy.password || \"\"}`);\n proxyOpts.headers[\"Proxy-Authorization\"] = `Basic ${Buffer.from(auth).toString(\"base64\")}`;\n }\n\n console.log(` Using proxy: ${proxy.hostname}:${proxy.port || (isHttps ? 443 : 80)}`);\n\n return new Promise((resolve, reject) => {\n const req = proxyModule.request(proxyOpts, (res) => {\n if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n download(res.headers.location).then(resolve, reject);\n return;\n }\n if (res.statusCode !== 200) {\n reject(new Error(`Download failed: HTTP ${res.statusCode} for ${url}`));\n return;\n }\n const chunks = [];\n res.on(\"data\", (chunk) => chunks.push(chunk));\n res.on(\"end\", () => resolve(Buffer.concat(chunks)));\n res.on(\"error\", reject);\n });\n req.on(\"error\", reject);\n req.end();\n });\n}\n\nfunction extractTarGz(buffer, dest) {\n const tmpFile = join(dest, \"cc-code.tar.gz\");\n writeFileSync(tmpFile, buffer);\n execSync(`tar -xzf \"${tmpFile}\" -C \"${dest}\"`, { stdio: \"ignore\" });\n unlinkSync(tmpFile);\n}\n\nfunction extractZip(buffer, dest) {\n const AdmZip = require(\"adm-zip\");\n const zip = new AdmZip(buffer);\n zip.extractAllTo(dest, true);\n}\n\nfunction migrateFromClaudeCode(home = homedir()) {\n const claudeSettingsPath = join(home, \".claude\", \"settings.json\");\n const ccCodeDir = join(home, \".cc-code\");\n const ccCodeSettingsPath = join(ccCodeDir, \"settings.json\");\n\n // 已有 cc-code 配置,跳过\n if (existsSync(ccCodeSettingsPath)) {\n return true;\n }\n\n // 无 Claude Code 配置\n if (!existsSync(claudeSettingsPath)) {\n return false;\n }\n\n let claudeSettings;\n try {\n claudeSettings = JSON.parse(require(\"fs\").readFileSync(claudeSettingsPath, \"utf-8\"));\n } catch {\n return false;\n }\n\n const env = claudeSettings.env || {};\n const providers = [];\n\n // 检测 Anthropic\n const anthropicKey = env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || \"\";\n const anthropicBaseUrl = env.ANTHROPIC_BASE_URL || \"\";\n if (anthropicKey || anthropicBaseUrl) {\n const models = {};\n if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) models.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL;\n if (env.ANTHROPIC_DEFAULT_SONNET_MODEL) models.sonnet = env.ANTHROPIC_DEFAULT_SONNET_MODEL;\n if (env.ANTHROPIC_DEFAULT_HAIKU_MODEL) models.haiku = env.ANTHROPIC_DEFAULT_HAIKU_MODEL;\n const p = {\n id: \"anthropic\",\n type: \"anthropic\",\n apiKey: anthropicKey,\n };\n if (anthropicBaseUrl) p.baseUrl = anthropicBaseUrl;\n if (Object.keys(models).length > 0) p.models = models;\n providers.push(p);\n }\n\n // 检测 OpenAI 兼容\n const openaiKey = env.OPENAI_API_KEY || env.CODEX_API_KEY || \"\";\n const openaiBaseUrl = env.OPENAI_BASE_URL || env.OPENAI_API_BASE || \"\";\n if (openaiKey || openaiBaseUrl) {\n const models = {};\n if (env.OPENAI_MODEL) models.sonnet = env.OPENAI_MODEL;\n const p = {\n id: \"openai\",\n type: \"openai\",\n apiKey: openaiKey,\n };\n if (openaiBaseUrl) p.baseUrl = openaiBaseUrl;\n if (Object.keys(models).length > 0) p.models = models;\n providers.push(p);\n }\n\n if (providers.length === 0) {\n return false;\n }\n\n if (!existsSync(ccCodeDir)) {\n mkdirSync(ccCodeDir, { recursive: true });\n }\n\n // 根据第一个 provider 的可用模型决定默认激活别名\n const firstProvider = providers[0];\n let activeAlias = \"opus\";\n if (firstProvider.models) {\n if (firstProvider.models.opus) activeAlias = \"opus\";\n else if (firstProvider.models.sonnet) activeAlias = \"sonnet\";\n else if (firstProvider.models.haiku) activeAlias = \"haiku\";\n }\n\n const ccCodeSettings = {\n config: {\n active_alias: activeAlias,\n active_provider_id: firstProvider.id,\n providers,\n },\n };\n writeFileSync(ccCodeSettingsPath, JSON.stringify(ccCodeSettings, null, 2) + \"\\n\");\n console.log(\"\");\n console.log(\" Migrated ~/.claude/settings.json -> ~/.cc-code/settings.json\");\n console.log(` Found ${providers.length} provider(s): ${providers.map(p => p.type).join(\", \")}`);\n return true;\n}\n\nasync function main() {\n const key = getPlatformKey();\n const platform = PLATFORMS[key];\n const fileName = `cc-code-${platform.suffix}.${platform.ext}`;\n const url = `${BASE_URL}/${fileName}`;\n const binDir = join(__dirname, \"bin\");\n\n if (!existsSync(binDir)) {\n mkdirSync(binDir, { recursive: true });\n }\n\n console.log(`Downloading cc-code ${VERSION} for ${platform.os}-${platform.arch}...`);\n console.log(` URL: ${url}`);\n\n const buffer = await download(url);\n\n if (platform.ext === \"tar.gz\") {\n extractTarGz(buffer, binDir);\n } else {\n extractZip(buffer, binDir);\n }\n\n const extractedName = platform.os === \"win32\"\n ? `cc-code-${platform.suffix}.exe`\n : `cc-code-${platform.suffix}`;\n const finalName = platform.os === \"win32\" ? \"cc-code.exe\" : \"cc-code-bin\";\n const extractedPath = join(binDir, extractedName);\n const finalPath = join(binDir, finalName);\n\n if (existsSync(extractedPath)) {\n if (existsSync(finalPath)) unlinkSync(finalPath);\n renameSync(extractedPath, finalPath);\n }\n\n if (platform.os !== \"win32\") {\n chmodSync(finalPath, 0o755);\n const wrapperPath = join(__dirname, \"bin\", \"cc-code\");\n if (existsSync(wrapperPath)) chmodSync(wrapperPath, 0o755);\n } else {\n // Generate Windows batch wrapper so npm's cc-code.cmd/ps1 can invoke it\n const binDirPath = join(__dirname, \"bin\");\n writeFileSync(join(binDirPath, \"cc-code.cmd\"), `@echo off\\r\\n\"%~dp0cc-code.exe\" %*\\r\\n`);\n writeFileSync(join(binDirPath, \"cc-code.ps1\"), `$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent\\r\\n& \"$basedir\\\\cc-code.exe\" @args\\r\\nexit $LASTEXITCODE\\r\\n`);\n }\n\n console.log(`cc-code ${VERSION} installed successfully.`);\n\n const migrated = migrateFromClaudeCode();\n\n if (!migrated) {\n console.log(\"\");\n console.log(\"─── Quick Start ───\");\n console.log(\"\");\n console.log(\" Set your API key (pick one):\");\n console.log(\"\");\n console.log(\" # DeepSeek\");\n console.log(\" export OPENAI_API_KEY=sk-xxx\");\n console.log(\" export OPENAI_BASE_URL=https://api.deepseek.com/v1\");\n console.log(\" export OPENAI_MODEL=deepseek-chat\");\n console.log(\"\");\n console.log(\" # Anthropic\");\n console.log(\" export ANTHROPIC_API_KEY=sk-ant-xxx\");\n console.log(\"\");\n console.log(\" Or create config file:\");\n console.log(\"\");\n console.log(\" mkdir -p ~/.cc-code\");\n console.log(' cat > ~/.cc-code/settings.json << \\'EOF\\'');\n console.log(\" {\");\n console.log(' \"config\": {');\n console.log(' \"providers\": [');\n console.log(\" {\");\n console.log(' \"type\": \"openai\",');\n console.log(' \"apiKey\": \"sk-xxx\",');\n console.log(' \"baseUrl\": \"https://api.deepseek.com/v1\",');\n console.log(' \"models\": { \"sonnet\": \"deepseek-chat\" }');\n console.log(\" }\");\n console.log(\" ]\");\n console.log(\" }\");\n console.log(\" }\");\n console.log(\" EOF\");\n }\n\n console.log(\"\");\n console.log(\" Launch: cc-code\");\n console.log(\" Docs: https://github.com/cc-claws/cc-code\");\n console.log(\"\");\n}\n\nif (require.main === module) {\n main().catch((err) => {\n console.error(\"Failed to install cc-code:\", err.message);\n process.exit(1);\n });\n}\n\nmodule.exports = { migrateFromClaudeCode };\n"}, "290b176d924f33adf252bf34bd2ee7ceb6d1107a": {"t": 1785766668.1662266, "n": 10141, "h": "e5c673fc926830b3", "c": "#!/usr/bin/env node\n\nconst { createWriteStream, mkdirSync, chmodSync, existsSync, renameSync, unlinkSync, writeFileSync } = require(\"fs\");\nconst { join } = require(\"path\");\nconst { execSync } = require(\"child_process\");\n\nconst { homedir } = require(\"os\");\n\nconst VERSION = require(\"./package.json\").version;\nconst REPO = \"cc-claws/cc-code\";\nconst BASE_URL = `https://github.com/${REPO}/releases/download/npm-v${VERSION}`;\n\nconst PLATFORMS = {\n \"linux-x64\": { os: \"linux\", arch: \"x64\", suffix: \"linux-x86_64\", ext: \"tar.gz\" },\n \"linux-arm64\": { os: \"linux\", arch: \"arm64\", suffix: \"linux-aarch64\", ext: \"tar.gz\" },\n \"darwin-x64\": { os: \"darwin\", arch: \"x64\", suffix: \"macos-x86_64\", ext: \"tar.gz\" },\n \"darwin-arm64\": { os: \"darwin\", arch: \"arm64\", suffix: \"macos-aarch64\", ext: \"tar.gz\" },\n \"win32-x64\": { os: \"win32\", arch: \"x64\", suffix: \"windows-x86_64\", ext: \"zip\" },\n};\n\nfunction getPlatformKey() {\n const key = `${process.platform}-${process.arch}`;\n if (!PLATFORMS[key]) {\n throw new Error(`Unsupported platform: ${key}. Supported: ${Object.keys(PLATFORMS).join(\", \")}`);\n }\n return key;\n}\n\nfunction getProxyUrl() {\n // Check common proxy environment variables\n const proxy = process.env.HTTPS_PROXY || process.env.https_proxy\n || process.env.HTTP_PROXY || process.env.http_proxy\n || process.env.ALL_PROXY || process.env.all_proxy;\n return proxy || null;\n}\n\nfunction download(url) {\n const proxyUrl = getProxyUrl();\n\n if (proxyUrl) {\n return downloadViaProxy(url, proxyUrl);\n }\n return downloadDirect(url);\n}\n\nfunction downloadDirect(url) {\n const { get } = require(\"https\");\n return new Promise((resolve, reject) => {\n get(url, (res) => {\n if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n downloadDirect(res.headers.location).then(resolve, reject);\n return;\n }\n if (res.statusCode !== 200) {\n reject(new Error(`Download failed: HTTP ${res.statusCode} for ${url}`));\n return;\n }\n const chunks = [];\n res.on(\"data\", (chunk) => chunks.push(chunk));\n res.on(\"end\", () => resolve(Buffer.concat(chunks)));\n res.on(\"error\", reject);\n }).on(\"error\", reject);\n });\n}\n\nfunction downloadViaProxy(url, proxyUrl) {\n const { URL } = require(\"url\");\n const target = new URL(url);\n const proxy = new URL(proxyUrl);\n\n const isHttps = proxy.protocol === \"https:\" || proxy.protocol === \"HTTPS:\";\n const proxyModule = isHttps ? require(\"https\") : require(\"http\");\n\n const proxyOpts = {\n hostname: proxy.hostname,\n port: proxy.port || (isHttps ? 443 : 80),\n path: url,\n method: \"GET\",\n headers: { \"Host\": target.hostname, \"User-Agent\": \"cc-code-installer\" },\n };\n\n // Support proxy auth\n if (proxy.username) {\n const auth = decodeURIComponent(`${proxy.username}:${proxy.password || \"\"}`);\n proxyOpts.headers[\"Proxy-Authorization\"] = `Basic ${Buffer.from(auth).toString(\"base64\")}`;\n }\n\n console.log(` Using proxy: ${proxy.hostname}:${proxy.port || (isHttps ? 443 : 80)}`);\n\n return new Promise((resolve, reject) => {\n const req = proxyModule.request(proxyOpts, (res) => {\n if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n download(res.headers.location).then(resolve, reject);\n return;\n }\n if (res.statusCode !== 200) {\n reject(new Error(`Download failed: HTTP ${res.statusCode} for ${url}`));\n return;\n }\n const chunks = [];\n res.on(\"data\", (chunk) => chunks.push(chunk));\n res.on(\"end\", () => resolve(Buffer.concat(chunks)));\n res.on(\"error\", reject);\n });\n req.on(\"error\", reject);\n req.end();\n });\n}\n\nfunction extractTarGz(buffer, dest) {\n const tmpFile = join(dest, \"cc-code.tar.gz\");\n writeFileSync(tmpFile, buffer);\n execSync(`tar -xzf \"${tmpFile}\" -C \"${dest}\"`, { stdio: \"ignore\" });\n unlinkSync(tmpFile);\n}\n\nfunction extractZip(buffer, dest) {\n const AdmZip = require(\"adm-zip\");\n const zip = new AdmZip(buffer);\n zip.extractAllTo(dest, true);\n}\n\nfunction migrateFromClaudeCode(home = homedir()) {\n const claudeSettingsPath = join(home, \".claude\", \"settings.json\");\n const ccCodeDir = join(home, \".cc-code\");\n const ccCodeSettingsPath = join(ccCodeDir, \"settings.json\");\n\n // 已有 cc-code 配置,跳过\n if (existsSync(ccCodeSettingsPath)) {\n return true;\n }\n\n // 无 Claude Code 配置\n if (!existsSync(claudeSettingsPath)) {\n return false;\n }\n\n let claudeSettings;\n try {\n claudeSettings = JSON.parse(require(\"fs\").readFileSync(claudeSettingsPath, \"utf-8\"));\n } catch {\n return false;\n }\n\n const env = claudeSettings.env || {};\n const providers = [];\n\n // 检测 Anthropic\n const anthropicKey = env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || \"\";\n const anthropicBaseUrl = env.ANTHROPIC_BASE_URL || \"\";\n if (anthropicKey || anthropicBaseUrl) {\n const models = {};\n if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) models.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL;\n if (env.ANTHROPIC_DEFAULT_SONNET_MODEL) models.sonnet = env.ANTHROPIC_DEFAULT_SONNET_MODEL;\n if (env.ANTHROPIC_DEFAULT_HAIKU_MODEL) models.haiku = env.ANTHROPIC_DEFAULT_HAIKU_MODEL;\n const p = {\n id: \"anthropic\",\n type: \"anthropic\",\n apiKey: anthropicKey,\n };\n if (anthropicBaseUrl) p.baseUrl = anthropicBaseUrl;\n if (Object.keys(models).length > 0) p.models = models;\n providers.push(p);\n }\n\n // 检测 OpenAI 兼容\n const openaiKey = env.OPENAI_API_KEY || env.CODEX_API_KEY || \"\";\n const openaiBaseUrl = env.OPENAI_BASE_URL || env.OPENAI_API_BASE || \"\";\n if (openaiKey || openaiBaseUrl) {\n const models = {};\n if (env.OPENAI_MODEL) models.sonnet = env.OPENAI_MODEL;\n const p = {\n id: \"openai\",\n type: \"openai\",\n apiKey: openaiKey,\n };\n if (openaiBaseUrl) p.baseUrl = openaiBaseUrl;\n if (Object.keys(models).length > 0) p.models = models;\n providers.push(p);\n }\n\n if (providers.length === 0) {\n return false;\n }\n\n if (!existsSync(ccCodeDir)) {\n mkdirSync(ccCodeDir, { recursive: true });\n }\n\n // 根据第一个 provider 的可用模型决定默认激活别名\n const firstProvider = providers[0];\n let activeAlias = \"opus\";\n if (firstProvider.models) {\n if (firstProvider.models.opus) activeAlias = \"opus\";\n else if (firstProvider.models.sonnet) activeAlias = \"sonnet\";\n else if (firstProvider.models.haiku) activeAlias = \"haiku\";\n }\n\n const ccCodeSettings = {\n config: {\n active_alias: activeAlias,\n active_provider_id: firstProvider.id,\n providers,\n },\n };\n writeFileSync(ccCodeSettingsPath, JSON.stringify(ccCodeSettings, null, 2) + \"\\n\");\n console.log(\"\");\n console.log(\" Migrated ~/.claude/settings.json -> ~/.cc-code/settings.json\");\n console.log(` Found ${providers.length} provider(s): ${providers.map(p => p.type).join(\", \")}`);\n return true;\n}\n\nasync function main() {\n const key = getPlatformKey();\n const platform = PLATFORMS[key];\n const fileName = `cc-code-${platform.suffix}.${platform.ext}`;\n const url = `${BASE_URL}/${fileName}`;\n const binDir = join(__dirname, \"bin\");\n\n if (!existsSync(binDir)) {\n mkdirSync(binDir, { recursive: true });\n }\n\n console.log(`Downloading cc-code ${VERSION} for ${platform.os}-${platform.arch}...`);\n console.log(` URL: ${url}`);\n\n const buffer = await download(url);\n\n if (platform.ext === \"tar.gz\") {\n extractTarGz(buffer, binDir);\n } else {\n extractZip(buffer, binDir);\n }\n\n const extractedName = platform.os === \"win32\"\n ? `cc-code-${platform.suffix}.exe`\n : `cc-code-${platform.suffix}`;\n const finalName = platform.os === \"win32\" ? \"cc-code.exe\" : \"cc-code-bin\";\n const extractedPath = join(binDir, extractedName);\n const finalPath = join(binDir, finalName);\n\n if (existsSync(extractedPath)) {\n if (existsSync(finalPath)) unlinkSync(finalPath);\n renameSync(extractedPath, finalPath);\n }\n\n if (platform.os !== \"win32\") {\n chmodSync(finalPath, 0o755);\n const wrapperPath = join(__dirname, \"bin\", \"cc-code\");\n if (existsSync(wrapperPath)) chmodSync(wrapperPath, 0o755);\n } else {\n // Generate Windows batch wrapper so npm's cc-code.cmd/ps1 can invoke it\n const binDirPath = join(__dirname, \"bin\");\n writeFileSync(join(binDirPath, \"cc-code.cmd\"), `@echo off\\r\\n\"%~dp0cc-code.exe\" %*\\r\\n`);\n writeFileSync(join(binDirPath, \"cc-code.ps1\"), `$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent\\r\\n& \"$basedir\\\\cc-code.exe\" @args\\r\\nexit $LASTEXITCODE\\r\\n`);\n }\n\n console.log(`cc-code ${VERSION} installed successfully.`);\n\n const migrated = migrateFromClaudeCode();\n\n if (!migrated) {\n console.log(\"\");\n console.log(\"─── Quick Start ───\");\n console.log(\"\");\n console.log(\" Set your API key (pick one):\");\n console.log(\"\");\n console.log(\" # DeepSeek\");\n console.log(\" export OPENAI_API_KEY=sk-xxx\");\n console.log(\" export OPENAI_BASE_URL=https://api.deepseek.com/v1\");\n console.log(\" export OPENAI_MODEL=deepseek-chat\");\n console.log(\"\");\n console.log(\" # Anthropic\");\n console.log(\" export ANTHROPIC_API_KEY=sk-ant-xxx\");\n console.log(\"\");\n console.log(\" Or create config file:\");\n console.log(\"\");\n console.log(\" mkdir -p ~/.cc-code\");\n console.log(' cat > ~/.cc-code/settings.json << \\'EOF\\'');\n console.log(\" {\");\n console.log(' \"config\": {');\n console.log(' \"providers\": [');\n console.log(\" {\");\n console.log(' \"type\": \"openai\",');\n console.log(' \"apiKey\": \"sk-xxx\",');\n console.log(' \"baseUrl\": \"https://api.deepseek.com/v1\",');\n console.log(' \"models\": { \"sonnet\": \"deepseek-chat\" }');\n console.log(\" }\");\n console.log(\" ]\");\n console.log(\" }\");\n console.log(\" }\");\n console.log(\" EOF\");\n }\n\n console.log(\"\");\n console.log(\" Launch: cc-code\");\n console.log(\" Docs: https://github.com/cc-claws/cc-code\");\n console.log(\"\");\n}\n\nif (require.main === module) {\n main().catch((err) => {\n console.error(\"Failed to install cc-code:\", err.message);\n process.exit(1);\n });\n}\n\nmodule.exports = { migrateFromClaudeCode };\n"}, "bc3fb71905b878a4639d47ad2373394a49eed917": {"t": 1785766669.617847, "n": 9616, "h": "02e1fa803e39aa64", "c": "#!/usr/bin/env node\n\nconst { createWriteStream, mkdirSync, chmodSync, existsSync, renameSync, unlinkSync, writeFileSync } = require(\"fs\");\nconst { join } = require(\"path\");\nconst { execSync } = require(\"child_process\");\n\nconst { homedir } = require(\"os\");\n\nconst VERSION = require(\"./package.json\").version;\nconst REPO = \"cc-claws/cc-code\";\nconst BASE_URL = `https://github.com/${REPO}/releases/download/npm-v${VERSION}`;\n\nconst PLATFORMS = {\n \"linux-x64\": { os: \"linux\", arch: \"x64\", suffix: \"linux-x86_64\", ext: \"tar.gz\" },\n \"linux-arm64\": { os: \"linux\", arch: \"arm64\", suffix: \"linux-aarch64\", ext: \"tar.gz\" },\n \"darwin-x64\": { os: \"darwin\", arch: \"x64\", suffix: \"macos-x86_64\", ext: \"tar.gz\" },\n \"darwin-arm64\": { os: \"darwin\", arch: \"arm64\", suffix: \"macos-aarch64\", ext: \"tar.gz\" },\n \"win32-x64\": { os: \"win32\", arch: \"x64\", suffix: \"windows-x86_64\", ext: \"zip\" },\n};\n\nfunction getPlatformKey() {\n const key = `${process.platform}-${process.arch}`;\n if (!PLATFORMS[key]) {\n throw new Error(`Unsupported platform: ${key}. Supported: ${Object.keys(PLATFORMS).join(\", \")}`);\n }\n return key;\n}\n\nfunction getProxyUrl() {\n // Check common proxy environment variables\n const proxy = process.env.HTTPS_PROXY || process.env.https_proxy\n || process.env.HTTP_PROXY || process.env.http_proxy\n || process.env.ALL_PROXY || process.env.all_proxy;\n return proxy || null;\n}\n\nfunction download(url) {\n const proxyUrl = getProxyUrl();\n\n if (proxyUrl) {\n return downloadViaProxy(url, proxyUrl);\n }\n return downloadDirect(url);\n}\n\nfunction downloadDirect(url) {\n const { get } = require(\"https\");\n return new Promise((resolve, reject) => {\n get(url, (res) => {\n if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n downloadDirect(res.headers.location).then(resolve, reject);\n return;\n }\n if (res.statusCode !== 200) {\n reject(new Error(`Download failed: HTTP ${res.statusCode} for ${url}`));\n return;\n }\n const chunks = [];\n res.on(\"data\", (chunk) => chunks.push(chunk));\n res.on(\"end\", () => resolve(Buffer.concat(chunks)));\n res.on(\"error\", reject);\n }).on(\"error\", reject);\n });\n}\n\nfunction downloadViaProxy(url, proxyUrl) {\n const { URL } = require(\"url\");\n const target = new URL(url);\n const proxy = new URL(proxyUrl);\n\n const isHttps = proxy.protocol === \"https:\" || proxy.protocol === \"HTTPS:\";\n const proxyModule = isHttps ? require(\"https\") : require(\"http\");\n\n const proxyOpts = {\n hostname: proxy.hostname,\n port: proxy.port || (isHttps ? 443 : 80),\n path: url,\n method: \"GET\",\n headers: { \"Host\": target.hostname, \"User-Agent\": \"cc-code-installer\" },\n };\n\n // Support proxy auth\n if (proxy.username) {\n const auth = decodeURIComponent(`${proxy.username}:${proxy.password || \"\"}`);\n proxyOpts.headers[\"Proxy-Authorization\"] = `Basic ${Buffer.from(auth).toString(\"base64\")}`;\n }\n\n console.log(` Using proxy: ${proxy.hostname}:${proxy.port || (isHttps ? 443 : 80)}`);\n\n return new Promise((resolve, reject) => {\n const req = proxyModule.request(proxyOpts, (res) => {\n if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n download(res.headers.location).then(resolve, reject);\n return;\n }\n if (res.statusCode !== 200) {\n reject(new Error(`Download failed: HTTP ${res.statusCode} for ${url}`));\n return;\n }\n const chunks = [];\n res.on(\"data\", (chunk) => chunks.push(chunk));\n res.on(\"end\", () => resolve(Buffer.concat(chunks)));\n res.on(\"error\", reject);\n });\n req.on(\"error\", reject);\n req.end();\n });\n}\n\nfunction extractTarGz(buffer, dest) {\n const tmpFile = join(dest, \"cc-code.tar.gz\");\n writeFileSync(tmpFile, buffer);\n execSync(`tar -xzf \"${tmpFile}\" -C \"${dest}\"`, { stdio: \"ignore\" });\n unlinkSync(tmpFile);\n}\n\nfunction extractZip(buffer, dest) {\n const AdmZip = require(\"adm-zip\");\n const zip = new AdmZip(buffer);\n zip.extractAllTo(dest, true);\n}\n\nfunction migrateFromClaudeCode() {\n const home = homedir();\n const claudeSettingsPath = join(home, \".claude\", \"settings.json\");\n const ccCodeDir = join(home, \".cc-code\");\n const ccCodeSettingsPath = join(ccCodeDir, \"settings.json\");\n\n // 已有 cc-code 配置,跳过\n if (existsSync(ccCodeSettingsPath)) {\n return true;\n }\n\n // 无 Claude Code 配置\n if (!existsSync(claudeSettingsPath)) {\n return false;\n }\n\n let claudeSettings;\n try {\n claudeSettings = JSON.parse(require(\"fs\").readFileSync(claudeSettingsPath, \"utf-8\"));\n } catch {\n return false;\n }\n\n const env = claudeSettings.env || {};\n const providers = [];\n\n // 检测 Anthropic\n const anthropicKey = env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || \"\";\n const anthropicBaseUrl = env.ANTHROPIC_BASE_URL || \"\";\n if (anthropicKey || anthropicBaseUrl) {\n const p = { provider_type: \"anthropic\", api_key: anthropicKey };\n if (anthropicBaseUrl) p.base_url = anthropicBaseUrl;\n const models = {};\n if (env.ANTHROPIC_DEFAULT_SONNET_MODEL) models.default = env.ANTHROPIC_DEFAULT_SONNET_MODEL;\n if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) models.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL;\n if (env.ANTHROPIC_DEFAULT_HAIKU_MODEL) models.haiku = env.ANTHROPIC_DEFAULT_HAIKU_MODEL;\n if (Object.keys(models).length > 0) p.models = models;\n providers.push(p);\n }\n\n // 检测 OpenAI 兼容\n const openaiKey = env.OPENAI_API_KEY || env.CODEX_API_KEY || \"\";\n const openaiBaseUrl = env.OPENAI_BASE_URL || env.OPENAI_API_BASE || \"\";\n if (openaiKey || openaiBaseUrl) {\n const p = { provider_type: \"openai\", api_key: openaiKey };\n if (openaiBaseUrl) p.base_url = openaiBaseUrl;\n const models = {};\n if (env.OPENAI_MODEL) models.default = env.OPENAI_MODEL;\n if (Object.keys(models).length > 0) p.models = models;\n providers.push(p);\n }\n\n if (providers.length === 0) {\n return false;\n }\n\n if (!existsSync(ccCodeDir)) {\n mkdirSync(ccCodeDir, { recursive: true });\n }\n\n const ccCodeSettings = { config: { providers } };\n writeFileSync(ccCodeSettingsPath, JSON.stringify(ccCodeSettings, null, 2) + \"\\n\");\n console.log(\"\");\n console.log(\" Migrated ~/.claude/settings.json -> ~/.cc-code/settings.json\");\n console.log(` Found ${providers.length} provider(s): ${providers.map(p => p.provider_type).join(\", \")}`);\n return true;\n}\n\nasync function main() {\n const key = getPlatformKey();\n const platform = PLATFORMS[key];\n const fileName = `cc-code-${platform.suffix}.${platform.ext}`;\n const url = `${BASE_URL}/${fileName}`;\n const binDir = join(__dirname, \"bin\");\n\n if (!existsSync(binDir)) {\n mkdirSync(binDir, { recursive: true });\n }\n\n console.log(`Downloading cc-code ${VERSION} for ${platform.os}-${platform.arch}...`);\n console.log(` URL: ${url}`);\n\n const buffer = await download(url);\n\n if (platform.ext === \"tar.gz\") {\n extractTarGz(buffer, binDir);\n } else {\n extractZip(buffer, binDir);\n }\n\n const extractedName = platform.os === \"win32\"\n ? `cc-code-${platform.suffix}.exe`\n : `cc-code-${platform.suffix}`;\n const finalName = platform.os === \"win32\" ? \"cc-code.exe\" : \"cc-code-bin\";\n const extractedPath = join(binDir, extractedName);\n const finalPath = join(binDir, finalName);\n\n if (existsSync(extractedPath)) {\n if (existsSync(finalPath)) unlinkSync(finalPath);\n renameSync(extractedPath, finalPath);\n }\n\n if (platform.os !== \"win32\") {\n chmodSync(finalPath, 0o755);\n const wrapperPath = join(__dirname, \"bin\", \"cc-code\");\n if (existsSync(wrapperPath)) chmodSync(wrapperPath, 0o755);\n } else {\n // Generate Windows batch wrapper so npm's cc-code.cmd/ps1 can invoke it\n const binDirPath = join(__dirname, \"bin\");\n writeFileSync(join(binDirPath, \"cc-code.cmd\"), `@echo off\\r\\n\"%~dp0cc-code.exe\" %*\\r\\n`);\n writeFileSync(join(binDirPath, \"cc-code.ps1\"), `$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent\\r\\n& \"$basedir\\\\cc-code.exe\" @args\\r\\nexit $LASTEXITCODE\\r\\n`);\n }\n\n console.log(`cc-code ${VERSION} installed successfully.`);\n\n const migrated = migrateFromClaudeCode();\n\n if (!migrated) {\n console.log(\"\");\n console.log(\"─── Quick Start ───\");\n console.log(\"\");\n console.log(\" Set your API key (pick one):\");\n console.log(\"\");\n console.log(\" # DeepSeek\");\n console.log(\" export OPENAI_API_KEY=sk-xxx\");\n console.log(\" export OPENAI_BASE_URL=https://api.deepseek.com/v1\");\n console.log(\" export OPENAI_MODEL=deepseek-chat\");\n console.log(\"\");\n console.log(\" # Anthropic\");\n console.log(\" export ANTHROPIC_API_KEY=sk-ant-xxx\");\n console.log(\"\");\n console.log(\" Or create config file:\");\n console.log(\"\");\n console.log(\" mkdir -p ~/.cc-code\");\n console.log(' cat > ~/.cc-code/settings.json << \\'EOF\\'');\n console.log(\" {\");\n console.log(' \"config\": {');\n console.log(' \"providers\": [');\n console.log(\" {\");\n console.log(' \"provider_type\": \"openai\",');\n console.log(' \"api_key\": \"sk-xxx\",');\n console.log(' \"base_url\": \"https://api.deepseek.com/v1\",');\n console.log(' \"models\": { \"default\": \"deepseek-chat\" }');\n console.log(\" }\");\n console.log(\" ]\");\n console.log(\" }\");\n console.log(\" }\");\n console.log(\" EOF\");\n }\n\n console.log(\"\");\n console.log(\" Launch: cc-code\");\n console.log(\" Docs: https://github.com/cc-claws/cc-code\");\n console.log(\"\");\n}\n\nmain().catch((err) => {\n console.error(\"Failed to install cc-code:\", err.message);\n process.exit(1);\n});\n"}, "6f9d2a30ce0dd17681b823103c0b1e46f50a11a4": {"t": 1785766671.0744803, "n": 5795, "h": "032136f219924dcd", "c": "#!/usr/bin/env node\n\nconst { createWriteStream, mkdirSync, chmodSync, existsSync, renameSync, unlinkSync, writeFileSync } = require(\"fs\");\nconst { join } = require(\"path\");\nconst { execSync } = require(\"child_process\");\n\nconst VERSION = require(\"./package.json\").version;\nconst REPO = \"cc-claws/cc-code\";\nconst BASE_URL = `https://github.com/${REPO}/releases/download/npm-v${VERSION}`;\n\nconst PLATFORMS = {\n \"linux-x64\": { os: \"linux\", arch: \"x64\", suffix: \"linux-x86_64\", ext: \"tar.gz\" },\n \"linux-arm64\": { os: \"linux\", arch: \"arm64\", suffix: \"linux-aarch64\", ext: \"tar.gz\" },\n \"darwin-x64\": { os: \"darwin\", arch: \"x64\", suffix: \"macos-x86_64\", ext: \"tar.gz\" },\n \"darwin-arm64\": { os: \"darwin\", arch: \"arm64\", suffix: \"macos-aarch64\", ext: \"tar.gz\" },\n \"win32-x64\": { os: \"win32\", arch: \"x64\", suffix: \"windows-x86_64\", ext: \"zip\" },\n};\n\nfunction getPlatformKey() {\n const key = `${process.platform}-${process.arch}`;\n if (!PLATFORMS[key]) {\n throw new Error(`Unsupported platform: ${key}. Supported: ${Object.keys(PLATFORMS).join(\", \")}`);\n }\n return key;\n}\n\nfunction getProxyUrl() {\n // Check common proxy environment variables\n const proxy = process.env.HTTPS_PROXY || process.env.https_proxy\n || process.env.HTTP_PROXY || process.env.http_proxy\n || process.env.ALL_PROXY || process.env.all_proxy;\n return proxy || null;\n}\n\nfunction download(url) {\n const proxyUrl = getProxyUrl();\n\n if (proxyUrl) {\n return downloadViaProxy(url, proxyUrl);\n }\n return downloadDirect(url);\n}\n\nfunction downloadDirect(url) {\n const { get } = require(\"https\");\n return new Promise((resolve, reject) => {\n get(url, (res) => {\n if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n downloadDirect(res.headers.location).then(resolve, reject);\n return;\n }\n if (res.statusCode !== 200) {\n reject(new Error(`Download failed: HTTP ${res.statusCode} for ${url}`));\n return;\n }\n const chunks = [];\n res.on(\"data\", (chunk) => chunks.push(chunk));\n res.on(\"end\", () => resolve(Buffer.concat(chunks)));\n res.on(\"error\", reject);\n }).on(\"error\", reject);\n });\n}\n\nfunction downloadViaProxy(url, proxyUrl) {\n const { URL } = require(\"url\");\n const target = new URL(url);\n const proxy = new URL(proxyUrl);\n\n const isHttps = proxy.protocol === \"https:\" || proxy.protocol === \"HTTPS:\";\n const proxyModule = isHttps ? require(\"https\") : require(\"http\");\n\n const proxyOpts = {\n hostname: proxy.hostname,\n port: proxy.port || (isHttps ? 443 : 80),\n path: url,\n method: \"GET\",\n headers: { \"Host\": target.hostname, \"User-Agent\": \"cc-code-installer\" },\n };\n\n // Support proxy auth\n if (proxy.username) {\n const auth = decodeURIComponent(`${proxy.username}:${proxy.password || \"\"}`);\n proxyOpts.headers[\"Proxy-Authorization\"] = `Basic ${Buffer.from(auth).toString(\"base64\")}`;\n }\n\n console.log(` Using proxy: ${proxy.hostname}:${proxy.port || (isHttps ? 443 : 80)}`);\n\n return new Promise((resolve, reject) => {\n const req = proxyModule.request(proxyOpts, (res) => {\n if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n download(res.headers.location).then(resolve, reject);\n return;\n }\n if (res.statusCode !== 200) {\n reject(new Error(`Download failed: HTTP ${res.statusCode} for ${url}`));\n return;\n }\n const chunks = [];\n res.on(\"data\", (chunk) => chunks.push(chunk));\n res.on(\"end\", () => resolve(Buffer.concat(chunks)));\n res.on(\"error\", reject);\n });\n req.on(\"error\", reject);\n req.end();\n });\n}\n\nfunction extractTarGz(buffer, dest) {\n const tmpFile = join(dest, \"cc-code.tar.gz\");\n writeFileSync(tmpFile, buffer);\n execSync(`tar -xzf \"${tmpFile}\" -C \"${dest}\"`, { stdio: \"ignore\" });\n unlinkSync(tmpFile);\n}\n\nfunction extractZip(buffer, dest) {\n const AdmZip = require(\"adm-zip\");\n const zip = new AdmZip(buffer);\n zip.extractAllTo(dest, true);\n}\n\nasync function main() {\n const key = getPlatformKey();\n const platform = PLATFORMS[key];\n const fileName = `cc-code-${platform.suffix}.${platform.ext}`;\n const url = `${BASE_URL}/${fileName}`;\n const binDir = join(__dirname, \"bin\");\n\n if (!existsSync(binDir)) {\n mkdirSync(binDir, { recursive: true });\n }\n\n console.log(`Downloading cc-code ${VERSION} for ${platform.os}-${platform.arch}...`);\n console.log(` URL: ${url}`);\n\n const buffer = await download(url);\n\n if (platform.ext === \"tar.gz\") {\n extractTarGz(buffer, binDir);\n } else {\n extractZip(buffer, binDir);\n }\n\n const extractedName = platform.os === \"win32\"\n ? `cc-code-${platform.suffix}.exe`\n : `cc-code-${platform.suffix}`;\n const finalName = platform.os === \"win32\" ? \"cc-code.exe\" : \"cc-code-bin\";\n const extractedPath = join(binDir, extractedName);\n const finalPath = join(binDir, finalName);\n\n if (existsSync(extractedPath)) {\n if (existsSync(finalPath)) unlinkSync(finalPath);\n renameSync(extractedPath, finalPath);\n }\n\n if (platform.os !== \"win32\") {\n chmodSync(finalPath, 0o755);\n const wrapperPath = join(__dirname, \"bin\", \"cc-code\");\n if (existsSync(wrapperPath)) chmodSync(wrapperPath, 0o755);\n } else {\n // Generate Windows batch wrapper so npm's cc-code.cmd/ps1 can invoke it\n const binDirPath = join(__dirname, \"bin\");\n writeFileSync(join(binDirPath, \"cc-code.cmd\"), `@echo off\\r\\n\"%~dp0cc-code.exe\" %*\\r\\n`);\n writeFileSync(join(binDirPath, \"cc-code.ps1\"), `$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent\\r\\n& \"$basedir\\\\cc-code.exe\" @args\\r\\nexit $LASTEXITCODE\\r\\n`);\n }\n\n console.log(`cc-code ${VERSION} installed successfully.`);\n}\n\nmain().catch((err) => {\n console.error(\"Failed to install cc-code:\", err.message);\n process.exit(1);\n});\n"}, "e8730bf8acd1e08cab20afe0debd7fc04c3e4a80": {"t": 1785766673.2159743, "n": 5752, "h": "797c497f573dee84", "c": "#!/usr/bin/env node\n\nconst { createWriteStream, mkdirSync, chmodSync, existsSync, renameSync, unlinkSync, writeFileSync } = require(\"fs\");\nconst { join } = require(\"path\");\nconst { execSync } = require(\"child_process\");\n\nconst VERSION = require(\"./package.json\").version;\nconst REPO = \"cc-claws/cc-code\";\nconst BASE_URL = `https://github.com/${REPO}/releases/download/npm-v${VERSION}`;\n\nconst PLATFORMS = {\n \"linux-x64\": { os: \"linux\", arch: \"x64\", suffix: \"linux-x86_64\", ext: \"tar.gz\" },\n \"linux-arm64\": { os: \"linux\", arch: \"arm64\", suffix: \"linux-aarch64\", ext: \"tar.gz\" },\n \"darwin-x64\": { os: \"darwin\", arch: \"x64\", suffix: \"macos-x86_64\", ext: \"tar.gz\" },\n \"darwin-arm64\": { os: \"darwin\", arch: \"arm64\", suffix: \"macos-aarch64\", ext: \"tar.gz\" },\n \"win32-x64\": { os: \"win32\", arch: \"x64\", suffix: \"windows-x86_64\", ext: \"zip\" },\n};\n\nfunction getPlatformKey() {\n const key = `${process.platform}-${process.arch}`;\n if (!PLATFORMS[key]) {\n throw new Error(`Unsupported platform: ${key}. Supported: ${Object.keys(PLATFORMS).join(\", \")}`);\n }\n return key;\n}\n\nfunction getProxyUrl() {\n // Check common proxy environment variables\n const proxy = process.env.HTTPS_PROXY || process.env.https_proxy\n || process.env.HTTP_PROXY || process.env.http_proxy\n || process.env.ALL_PROXY || process.env.all_proxy;\n return proxy || null;\n}\n\nfunction download(url) {\n const proxyUrl = getProxyUrl();\n\n if (proxyUrl) {\n return downloadViaProxy(url, proxyUrl);\n }\n return downloadDirect(url);\n}\n\nfunction downloadDirect(url) {\n const { get } = require(\"https\");\n return new Promise((resolve, reject) => {\n get(url, (res) => {\n if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n downloadDirect(res.headers.location).then(resolve, reject);\n return;\n }\n if (res.statusCode !== 200) {\n reject(new Error(`Download failed: HTTP ${res.statusCode} for ${url}`));\n return;\n }\n const chunks = [];\n res.on(\"data\", (chunk) => chunks.push(chunk));\n res.on(\"end\", () => resolve(Buffer.concat(chunks)));\n res.on(\"error\", reject);\n }).on(\"error\", reject);\n });\n}\n\nfunction downloadViaProxy(url, proxyUrl) {\n const { URL } = require(\"url\");\n const target = new URL(url);\n const proxy = new URL(proxyUrl);\n\n const isHttps = proxy.protocol === \"https:\" || proxy.protocol === \"HTTPS:\";\n const proxyModule = isHttps ? require(\"https\") : require(\"http\");\n\n const proxyOpts = {\n hostname: proxy.hostname,\n port: proxy.port || (isHttps ? 443 : 80),\n path: url,\n method: \"GET\",\n headers: { \"Host\": target.hostname, \"User-Agent\": \"peri-installer\" },\n };\n\n // Support proxy auth\n if (proxy.username) {\n const auth = decodeURIComponent(`${proxy.username}:${proxy.password || \"\"}`);\n proxyOpts.headers[\"Proxy-Authorization\"] = `Basic ${Buffer.from(auth).toString(\"base64\")}`;\n }\n\n console.log(` Using proxy: ${proxy.hostname}:${proxy.port || (isHttps ? 443 : 80)}`);\n\n return new Promise((resolve, reject) => {\n const req = proxyModule.request(proxyOpts, (res) => {\n if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n download(res.headers.location).then(resolve, reject);\n return;\n }\n if (res.statusCode !== 200) {\n reject(new Error(`Download failed: HTTP ${res.statusCode} for ${url}`));\n return;\n }\n const chunks = [];\n res.on(\"data\", (chunk) => chunks.push(chunk));\n res.on(\"end\", () => resolve(Buffer.concat(chunks)));\n res.on(\"error\", reject);\n });\n req.on(\"error\", reject);\n req.end();\n });\n}\n\nfunction extractTarGz(buffer, dest) {\n const tmpFile = join(dest, \"peri.tar.gz\");\n writeFileSync(tmpFile, buffer);\n execSync(`tar -xzf \"${tmpFile}\" -C \"${dest}\"`, { stdio: \"ignore\" });\n unlinkSync(tmpFile);\n}\n\nfunction extractZip(buffer, dest) {\n const AdmZip = require(\"adm-zip\");\n const zip = new AdmZip(buffer);\n zip.extractAllTo(dest, true);\n}\n\nasync function main() {\n const key = getPlatformKey();\n const platform = PLATFORMS[key];\n const fileName = `peri-${platform.suffix}.${platform.ext}`;\n const url = `${BASE_URL}/${fileName}`;\n const binDir = join(__dirname, \"bin\");\n\n if (!existsSync(binDir)) {\n mkdirSync(binDir, { recursive: true });\n }\n\n console.log(`Downloading peri ${VERSION} for ${platform.os}-${platform.arch}...`);\n console.log(` URL: ${url}`);\n\n const buffer = await download(url);\n\n if (platform.ext === \"tar.gz\") {\n extractTarGz(buffer, binDir);\n } else {\n extractZip(buffer, binDir);\n }\n\n const extractedName = platform.os === \"win32\"\n ? `peri-${platform.suffix}.exe`\n : `peri-${platform.suffix}`;\n const finalName = platform.os === \"win32\" ? \"peri.exe\" : \"peri-bin\";\n const extractedPath = join(binDir, extractedName);\n const finalPath = join(binDir, finalName);\n\n if (existsSync(extractedPath)) {\n if (existsSync(finalPath)) unlinkSync(finalPath);\n renameSync(extractedPath, finalPath);\n }\n\n if (platform.os !== \"win32\") {\n chmodSync(finalPath, 0o755);\n const wrapperPath = join(__dirname, \"bin\", \"peri\");\n if (existsSync(wrapperPath)) chmodSync(wrapperPath, 0o755);\n } else {\n // Generate Windows batch wrapper so npm's peri.cmd/peri.ps1 can invoke it\n const binDirPath = join(__dirname, \"bin\");\n writeFileSync(join(binDirPath, \"peri.cmd\"), `@echo off\\r\\n\"%~dp0peri.exe\" %*\\r\\n`);\n writeFileSync(join(binDirPath, \"peri.ps1\"), `$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent\\r\\n& \"$basedir\\\\peri.exe\" @args\\r\\nexit $LASTEXITCODE\\r\\n`);\n }\n\n console.log(`peri ${VERSION} installed successfully.`);\n}\n\nmain().catch((err) => {\n console.error(\"Failed to install peri:\", err.message);\n process.exit(1);\n});\n"}, "a8c9d2ac74a125411770e9a1a85fed70e65d5b54": {"t": 1785766674.8644018, "n": 5755, "h": "9c055b89a8e4c5c7", "c": "#!/usr/bin/env node\n\nconst { createWriteStream, mkdirSync, chmodSync, existsSync, renameSync, unlinkSync, writeFileSync } = require(\"fs\");\nconst { join } = require(\"path\");\nconst { execSync } = require(\"child_process\");\n\nconst VERSION = require(\"./package.json\").version;\nconst REPO = \"wismyzhizi2018/peri\";\nconst BASE_URL = `https://github.com/${REPO}/releases/download/npm-v${VERSION}`;\n\nconst PLATFORMS = {\n \"linux-x64\": { os: \"linux\", arch: \"x64\", suffix: \"linux-x86_64\", ext: \"tar.gz\" },\n \"linux-arm64\": { os: \"linux\", arch: \"arm64\", suffix: \"linux-aarch64\", ext: \"tar.gz\" },\n \"darwin-x64\": { os: \"darwin\", arch: \"x64\", suffix: \"macos-x86_64\", ext: \"tar.gz\" },\n \"darwin-arm64\": { os: \"darwin\", arch: \"arm64\", suffix: \"macos-aarch64\", ext: \"tar.gz\" },\n \"win32-x64\": { os: \"win32\", arch: \"x64\", suffix: \"windows-x86_64\", ext: \"zip\" },\n};\n\nfunction getPlatformKey() {\n const key = `${process.platform}-${process.arch}`;\n if (!PLATFORMS[key]) {\n throw new Error(`Unsupported platform: ${key}. Supported: ${Object.keys(PLATFORMS).join(\", \")}`);\n }\n return key;\n}\n\nfunction getProxyUrl() {\n // Check common proxy environment variables\n const proxy = process.env.HTTPS_PROXY || process.env.https_proxy\n || process.env.HTTP_PROXY || process.env.http_proxy\n || process.env.ALL_PROXY || process.env.all_proxy;\n return proxy || null;\n}\n\nfunction download(url) {\n const proxyUrl = getProxyUrl();\n\n if (proxyUrl) {\n return downloadViaProxy(url, proxyUrl);\n }\n return downloadDirect(url);\n}\n\nfunction downloadDirect(url) {\n const { get } = require(\"https\");\n return new Promise((resolve, reject) => {\n get(url, (res) => {\n if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n downloadDirect(res.headers.location).then(resolve, reject);\n return;\n }\n if (res.statusCode !== 200) {\n reject(new Error(`Download failed: HTTP ${res.statusCode} for ${url}`));\n return;\n }\n const chunks = [];\n res.on(\"data\", (chunk) => chunks.push(chunk));\n res.on(\"end\", () => resolve(Buffer.concat(chunks)));\n res.on(\"error\", reject);\n }).on(\"error\", reject);\n });\n}\n\nfunction downloadViaProxy(url, proxyUrl) {\n const { URL } = require(\"url\");\n const target = new URL(url);\n const proxy = new URL(proxyUrl);\n\n const isHttps = proxy.protocol === \"https:\" || proxy.protocol === \"HTTPS:\";\n const proxyModule = isHttps ? require(\"https\") : require(\"http\");\n\n const proxyOpts = {\n hostname: proxy.hostname,\n port: proxy.port || (isHttps ? 443 : 80),\n path: url,\n method: \"GET\",\n headers: { \"Host\": target.hostname, \"User-Agent\": \"peri-installer\" },\n };\n\n // Support proxy auth\n if (proxy.username) {\n const auth = decodeURIComponent(`${proxy.username}:${proxy.password || \"\"}`);\n proxyOpts.headers[\"Proxy-Authorization\"] = `Basic ${Buffer.from(auth).toString(\"base64\")}`;\n }\n\n console.log(` Using proxy: ${proxy.hostname}:${proxy.port || (isHttps ? 443 : 80)}`);\n\n return new Promise((resolve, reject) => {\n const req = proxyModule.request(proxyOpts, (res) => {\n if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n download(res.headers.location).then(resolve, reject);\n return;\n }\n if (res.statusCode !== 200) {\n reject(new Error(`Download failed: HTTP ${res.statusCode} for ${url}`));\n return;\n }\n const chunks = [];\n res.on(\"data\", (chunk) => chunks.push(chunk));\n res.on(\"end\", () => resolve(Buffer.concat(chunks)));\n res.on(\"error\", reject);\n });\n req.on(\"error\", reject);\n req.end();\n });\n}\n\nfunction extractTarGz(buffer, dest) {\n const tmpFile = join(dest, \"peri.tar.gz\");\n writeFileSync(tmpFile, buffer);\n execSync(`tar -xzf \"${tmpFile}\" -C \"${dest}\"`, { stdio: \"ignore\" });\n unlinkSync(tmpFile);\n}\n\nfunction extractZip(buffer, dest) {\n const AdmZip = require(\"adm-zip\");\n const zip = new AdmZip(buffer);\n zip.extractAllTo(dest, true);\n}\n\nasync function main() {\n const key = getPlatformKey();\n const platform = PLATFORMS[key];\n const fileName = `peri-${platform.suffix}.${platform.ext}`;\n const url = `${BASE_URL}/${fileName}`;\n const binDir = join(__dirname, \"bin\");\n\n if (!existsSync(binDir)) {\n mkdirSync(binDir, { recursive: true });\n }\n\n console.log(`Downloading peri ${VERSION} for ${platform.os}-${platform.arch}...`);\n console.log(` URL: ${url}`);\n\n const buffer = await download(url);\n\n if (platform.ext === \"tar.gz\") {\n extractTarGz(buffer, binDir);\n } else {\n extractZip(buffer, binDir);\n }\n\n const extractedName = platform.os === \"win32\"\n ? `peri-${platform.suffix}.exe`\n : `peri-${platform.suffix}`;\n const finalName = platform.os === \"win32\" ? \"peri.exe\" : \"peri-bin\";\n const extractedPath = join(binDir, extractedName);\n const finalPath = join(binDir, finalName);\n\n if (existsSync(extractedPath)) {\n if (existsSync(finalPath)) unlinkSync(finalPath);\n renameSync(extractedPath, finalPath);\n }\n\n if (platform.os !== \"win32\") {\n chmodSync(finalPath, 0o755);\n const wrapperPath = join(__dirname, \"bin\", \"peri\");\n if (existsSync(wrapperPath)) chmodSync(wrapperPath, 0o755);\n } else {\n // Generate Windows batch wrapper so npm's peri.cmd/peri.ps1 can invoke it\n const binDirPath = join(__dirname, \"bin\");\n writeFileSync(join(binDirPath, \"peri.cmd\"), `@echo off\\r\\n\"%~dp0peri.exe\" %*\\r\\n`);\n writeFileSync(join(binDirPath, \"peri.ps1\"), `$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent\\r\\n& \"$basedir\\\\peri.exe\" @args\\r\\nexit $LASTEXITCODE\\r\\n`);\n }\n\n console.log(`peri ${VERSION} installed successfully.`);\n}\n\nmain().catch((err) => {\n console.error(\"Failed to install peri:\", err.message);\n process.exit(1);\n});\n"}}, "docs/superpowers/plans/2026-05-16-setup-language-step.md": {"3bbca7ae6170f704278b0b0307eae8f37dacfc77": {"t": 1785747175.5612204, "n": 15459, "h": "54d50030e8e5f546", "c": "# Setup Wizard 语言选择步骤 Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** 在 Setup 向导的 Choose 和 Form 步骤之间增加 Language 步骤,用户选择 `en` 或 `zh-CN`,结果写入 `AppConfig.language`。\n\n**Architecture:** 三步扩充为四步:Choose → Language → Form → Done。Language 步骤用方向键导航两个语言选项(English / 中文),Enter 确认后进入 Form,Esc 返回 Choose。`SetupWizardPanel` 新增 `language` 和 `language_cursor` 字段,`save_setup_to()` 写入 `cfg.config.language`。\n\n**Tech Stack:** Rust + ratatui + tui-textarea + Fluent i18n\n\n---\n\n### Task 1: FTL 翻译键\n\n**Files:**\n- Modify: `peri-tui/locales/en/main.ftl` (追加 ~5 行)\n- Modify: `peri-tui/locales/zh-CN/main.ftl` (追加 ~5 行)\n\n新增 Language 步骤需要的翻译键。语言选项名称(English / 中文)硬编码显示,不经过翻译。\n\n- [ ] **Step 1: 在英文 FTL 末尾追加翻译键**\n\n在 `peri-tui/locales/en/main.ftl` 末尾追加:\n\n```ftl\n# Setup Language step\nsetup-language-title = ── Peri Setup ── Language\nsetup-language-prompt = Choose your interface language:\nsetup-language-press-enter = Press Enter to confirm\n```\n\n- [ ] **Step 2: 在中文 FTL 末尾追加翻译键**\n\n在 `peri-tui/locales/zh-CN/main.ftl` 末尾追加:\n\n```ftl\n# Setup Language step\nsetup-language-title = ── Peri 设置 ── 语言\nsetup-language-prompt = 选择界面语言:\nsetup-language-press-enter = 按 Enter 确认\n```\n\n- [ ] **Step 3: 验证 FTL 文件解析无错误**\n\n```bash\ncargo build -p peri-tui 2>&1 | head -5\n```\n\nExpected: 编译成功,无 FTL parse error。\n\n---\n\n### Task 2: SetupWizardPanel 状态扩展\n\n**Files:**\n- Modify: `peri-tui/src/app/setup_wizard.rs`\n\n为 `SetupStep` 枚举添加 `Language` 变体,为 `SetupWizardPanel` 添加 `language`/`language_cursor` 字段,定义语言选项常量。\n\n- [ ] **Step 1: 在 `SetupStep` 枚举中插入 `Language` 变体**\n\n修改 `peri-tui/src/app/setup_wizard.rs` 第 3-10 行,在 `Choose` 和 `Form` 之间插入 `Language`:\n\n```rust\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum SetupStep {\n /// 选择来源\n Choose,\n /// 选择语言\n Language,\n /// 合并表单:多 Provider + API Key + Model Aliases\n Form,\n /// 确认完成\n Done,\n}\n```\n\n- [ ] **Step 2: 添加语言选项常量**\n\n在 `SetupSource::ALL` 定义之后(第 22 行附近),插入:\n\n```rust\n/// 支持的语言选项:(code, display_name)\npub const LANGUAGE_OPTIONS: [(&str, &str); 2] = [(\"en\", \"English\"), (\"zh-CN\", \"中文\")];\n```\n\n- [ ] **Step 3: 在 `SetupWizardPanel` 中添加 `language` 和 `language_cursor` 字段**\n\n修改 `SetupWizardPanel` 结构体(第 221-238 行),添加两个新字段:\n\n```rust\npub struct SetupWizardPanel {\n pub step: SetupStep,\n /// Step 1: 来源选择\n pub source: SetupSource,\n pub choose_cursor: usize,\n /// Step 2: 语言选择\n pub language: String,\n pub language_cursor: usize,\n /// Step 3: 多 provider 列表\n pub providers: Vec<MigratedProvider>,\n // ... 其余字段不变\n```\n\n- [ ] **Step 4: 在 `new()` 中初始化新字段**\n\n修改 `SetupWizardPanel::new()`(第 247-259 行),在 `choose_cursor: 0,` 之后插入:\n\n```rust\nlanguage: \"en\".to_string(),\nlanguage_cursor: 0,\n```\n\n- [ ] **Step 5: 构建验证**\n\n```bash\ncargo build -p peri-tui 2>&1 | tail -20\n```\n\nExpected: 大部分编译通过,仅渲染分发缺少 `SetupStep::Language` 分支导致编译错误(预期行为,Task 3 处理)。\n\n---\n\n### Task 3: Language 步骤按键处理\n\n**Files:**\n- Modify: `peri-tui/src/app/setup_wizard.rs`\n\n实现 `handle_step_language` 函数,并在 `handle_setup_wizard_key` 分发和步骤间跳转逻辑中插入 Language 步骤。\n\n- [ ] **Step 1: 在 `handle_setup_wizard_key` 中添加 Language 分发**\n\n修改 `handle_setup_wizard_key` 函数(第 481-490 行),在 `SetupStep::Choose` 和 `SetupStep::Form` 之间插入:\n\n```rust\nSetupStep::Language => handle_step_language(wizard, input),\n```\n\n- [ ] **Step 2: 修改 `handle_step_choose` Enter 逻辑跳转到 Language**\n\n修改第 509-536 行的 Enter/Space 处理,将跳转到 `Form` 改为跳转到 `Language`:\n\n```rust\n} | tui_textarea::Input {\n key: Key::Char(' '),\n ..\n} => {\n if wizard.source == SetupSource::MigrateClaudeCode {\n if !wizard.migrate_from_claude_code() {\n wizard.source = SetupSource::CustomApi;\n wizard.choose_cursor = 0;\n return Some(SetupWizardAction::Redraw);\n }\n } else {\n wizard.providers = vec![MigratedProvider::new(ProviderType::Anthropic)];\n wizard.active_provider = 0;\n }\n wizard.step = SetupStep::Language;\n wizard.language_cursor = 0;\n Some(SetupWizardAction::Redraw)\n}\n```\n\n- [ ] **Step 3: 修改 `handle_browse` 的 Esc 跳转到 Language**\n\n修改第 604-608 行,将 Esc 跳转目标从 `Choose` 改为 `Language`:\n\n```rust\ntui_textarea::Input { key: Key::Esc, .. } => {\n wizard.step = SetupStep::Language;\n Some(SetupWizardAction::Redraw)\n}\n```\n\n- [ ] **Step 4: 实现 `handle_step_language` 函数**\n\n在 `handle_step_choose` 函数之后(第 537 行之后),插入新函数:\n\n```rust\nfn handle_step_language(\n wizard: &mut SetupWizardPanel,\n input: tui_textarea::Input,\n) -> Option<SetupWizardAction> {\n use tui_textarea::Key;\n match input {\n tui_textarea::Input { key: Key::Up, .. } => {\n wizard.language_cursor =\n (wizard.language_cursor + LANGUAGE_OPTIONS.len() - 1) % LANGUAGE_OPTIONS.len();\n Some(SetupWizardAction::Redraw)\n }\n tui_textarea::Input { key: Key::Down, .. } => {\n wizard.language_cursor = (wizard.language_cursor + 1) % LANGUAGE_OPTIONS.len();\n Some(SetupWizardAction::Redraw)\n }\n tui_textarea::Input {\n key: Key::Enter, ..\n }\n | tui_textarea::Input {\n key: Key::Char(' '),\n ..\n } => {\n wizard.language = LANGUAGE_OPTIONS[wizard.language_cursor].0.to_string();\n wizard.step = SetupStep::Form;\n wizard.form_mode = FormMode::Browse;\n wizard.browse_cursor = 0;\n wizard.form_focus = FormField::ProviderType;\n Some(SetupWizardAction::Redraw)\n }\n tui_textarea::Input { key: Key::Esc, .. } => {\n wizard.step = SetupStep::Choose;\n Some(SetupWizardAction::Redraw)\n }\n _ => None,\n }\n}\n```\n\n- [ ] **Step 5: 编译验证(渲染分发仍缺)**\n\n```bash\ncargo build -p peri-tui 2>&1 | grep \"error\" | head -10\n```\n\nExpected: 仅有 `render_setup_wizard` 缺少 `SetupStep::Language` 分支的错误(Task 4 修复)。\n\n---\n\n### Task 4: Language 步骤 UI 渲染\n\n**Files:**\n- Modify: `peri-tui/src/ui/main_ui/popups/setup_wizard.rs`\n\n添加 `render_step_language` 函数,在 `render_setup_wizard` 分发中添加 Language 分支。\n\n- [ ] **Step 1: 在 `render_setup_wizard` 中添加 Language 分发**\n\n修改第 20-24 行的 match:\n\n```rust\nmatch wizard.step {\n SetupStep::Choose => render_step_choose(f, wizard, lc, area),\n SetupStep::Language => render_step_language(f, wizard, lc, area),\n SetupStep::Form => render_step_form(f, wizard, lc, area),\n SetupStep::Done => render_step_done(f, wizard, lc, area),\n}\n```\n\n- [ ] **Step 2: 实现 `render_step_language` 函数**\n\n在 `render_step_choose` 函数之后(第 86 行之前),插入新函数:\n\n```rust\nfn render_step_language(\n f: &mut Frame,\n wizard: &SetupWizardPanel,\n lc: &crate::i18n::LcRegistry,\n area: Rect,\n) {\n let inner = BorderedPanel::new(Span::styled(\n lc.tr(\"setup-language-title\"),\n Style::default()\n .fg(theme::ACCENT)\n .add_modifier(Modifier::BOLD),\n ))\n .border_style(Style::default().fg(theme::ACCENT))\n .render(f, area);\n\n let mut lines: Vec<Line> = vec![\n Line::from(\"\"),\n Line::from(Span::styled(\n lc.tr(\"setup-language-prompt\"),\n Style::default().fg(theme::MUTED),\n )),\n Line::from(\"\"),\n ];\n\n for (i, (_code, name)) in LANGUAGE_OPTIONS.iter().enumerate() {\n let is_cursor = i == wizard.language_cursor;\n let cursor_char = if is_cursor { \"❯\" } else { \" \" };\n let cursor_style = Style::default().fg(theme::THINKING);\n let name_style = if is_cursor {\n Style::default()\n .fg(theme::THINKING)\n .add_modifier(Modifier::BOLD)\n } else {\n Style::default().fg(theme::TEXT)\n };\n lines.push(Line::from(vec![\n Span::styled(format!(\"{} \", cursor_char), cursor_style),\n Span::styled(*name, name_style),\n ]));\n }\n\n lines.push(Line::from(\"\"));\n lines.push(make_hint_line(vec![\n (\"Enter\".to_string(), lc.tr(\"setup-key-confirm\")),\n (\"↑/↓\".to_string(), lc.tr(\"setup-key-select\")),\n (\"Esc\".to_string(), lc.tr(\"setup-key-back\")),\n ]));\n\n f.render_widget(Paragraph::new(Text::from(lines)), inner);\n}\n```\n\nNote: 需要确认 `LANGUAGE_OPTIONS` 的 import。检查文件顶部的 imports,当前已有 `use crate::app::setup_wizard::{FormField, FormMode, SetupSource, SetupStep, SetupWizardPanel};`,需要添加 `LANGUAGE_OPTIONS`。\n\n- [ ] **Step 3: 添加 `LANGUAGE_OPTIONS` 的 import**\n\n修改第 11 行的 import:\n\n```rust\nuse crate::app::setup_wizard::{FormField, FormMode, LANGUAGE_OPTIONS, SetupSource, SetupStep, SetupWizardPanel};\n```\n\n- [ ] **Step 4: 编译验证**\n\n```bash\ncargo build -p peri-tui 2>&1 | tail -5\n```\n\nExpected: 编译成功。\n\n---\n\n### Task 5: save_setup_to 写入 language 字段\n\n**Files:**\n- Modify: `peri-tui/src/app/setup_wizard.rs`\n\n- [ ] **Step 1: 在 `save_setup_to` 中写入 `language`**\n\n在 `save_setup_to` 函数中(`cfg.config.active_provider_id = first_id;` 之后,第 778 行附近),添加:\n\n```rust\ncfg.config.language = Some(wizard.language.clone());\n```\n\n最终 `save_setup_to` 的相关部分变为:\n\n```rust\nif !first_id.is_empty() {\n cfg.config.active_alias = \"opus\".to_string();\n cfg.config.active_provider_id = first_id;\n}\n\ncfg.config.language = Some(wizard.language.clone());\n\nlet content = serde_json::to_string_pretty(&cfg)?;\n```\n\n- [ ] **Step 2: 编译验证**\n\n```bash\ncargo build -p peri-tui 2>&1 | tail -5\n```\n\nExpected: 编译成功。\n\n---\n\n### Task 6: 单元测试\n\n**Files:**\n- Modify: `peri-tui/src/app/setup_wizard_test.rs`\n\n- [ ] **Step 1: 添加 Language 步骤导航测试**\n\n在 Choose 步骤测试之后(第 216 行后)、Form 步骤测试之前,插入:\n\n```rust\n// ── Step: Language ──\n\n#[test]\nfn test_language_arrow_navigates() {\n let mut wizard = SetupWizardPanel::new();\n wizard.step = SetupStep::Language;\n assert_eq!(wizard.language_cursor, 0);\n let _ = handle_setup_wizard_key(&mut wizard, make_key(Key::Down));\n assert_eq!(wizard.language_cursor, 1);\n let _ = handle_setup_wizard_key(&mut wizard, make_key(Key::Down));\n assert_eq!(wizard.language_cursor, 0); // wraps around\n let _ = handle_setup_wizard_key(&mut wizard, make_key(Key::Up));\n assert_eq!(wizard.language_cursor, 1); // wraps around\n}\n\n#[test]\nfn test_language_enter_selects_and_advances_to_form() {\n let mut wizard = SetupWizardPanel::new();\n wizard.step = SetupStep::Language;\n wizard.language_cursor = 1; // zh-CN\n let _ = handle_setup_wizard_key(&mut wizard, make_key(Key::Enter));\n assert_eq!(wizard.language, \"zh-CN\");\n assert_eq!(wizard.step, SetupStep::Form);\n assert_eq!(wizard.form_mode, FormMode::Browse);\n}\n\n#[test]\nfn test_language_space_selects_and_advances() {\n let mut wizard = SetupWizardPanel::new();\n wizard.step = SetupStep::Language;\n let _ = handle_setup_wizard_key(&mut wizard, make_char(' '));\n assert_eq!(wizard.language, \"en\");\n assert_eq!(wizard.step, SetupStep::Form);\n}\n\n#[test]\nfn test_language_esc_back_to_choose() {\n let mut wizard = SetupWizardPanel::new();\n wizard.step = SetupStep::Language;\n let _ = handle_setup_wizard_key(&mut wizard, make_key(Key::Esc));\n assert_eq!(wizard.step, SetupStep::Choose);\n}\n\n#[test]\nfn test_language_default_is_en() {\n let wizard = SetupWizardPanel::new();\n assert_eq!(wizard.language, \"en\");\n}\n```\n\n- [ ] **Step 2: 更新 Choose 步骤 Enter 测试**\n\n修改 `test_choose_enter_custom_advances_to_form` (第 203-209 行):\n\n```rust\n#[test]\nfn test_choose_enter_custom_advances_to_language() {\n let mut wizard = SetupWizardPanel::new();\n let _ = handle_setup_wizard_key(&mut wizard, make_key(Key::Enter));\n assert_eq!(wizard.step, SetupStep::Language);\n}\n```\n\n- [ ] **Step 3: 更新 Browse Esc 测试**\n\n修改 `test_browse_esc_back_to_choose` (第 272-278 行):\n\n```rust\n#[test]\nfn test_browse_esc_back_to_language() {\n let mut wizard = SetupWizardPanel::new();\n wizard.step = SetupStep::Form;\n wizard.form_mode = FormMode::Browse;\n let _ = handle_setup_wizard_key(&mut wizard, make_key(Key::Esc));\n assert_eq!(wizard.step, SetupStep::Language);\n}\n```\n\n- [ ] **Step 4: 添加 `save_setup_to` 写入 language 测试**\n\n在 `test_save_setup_skips_unselected` 之后(第 371 行后),添加:\n\n```rust\n#[test]\nfn test_save_setup_writes_language() {\n let mut wizard = SetupWizardPanel::new();\n wizard.providers[0].api_key = \"sk-test\".to_string();\n wizard.language = \"zh-CN\".to_string();\n let temp_dir = std::env::temp_dir().join(format!(\"zen-lang-setup-{}\", uuid::Uuid::now_v7()));\n let config_path = temp_dir.join(\"settings.json\");\n let cfg = save_setup_to(&wizard, &config_path).expect(\"save should succeed\");\n assert_eq!(cfg.config.language.as_deref(), Some(\"zh-CN\"));\n let _ = std::fs::remove_dir_all(&temp_dir);\n}\n```\n\n- [ ] **Step 5: 更新 `test_setup_wizard_new_defaults`**\n\n修改 `test_setup_wizard_new_defaults` (第 24-32 行),添加语言默认值断言:\n\n```rust\n#[test]\nfn test_setup_wizard_new_defaults() {\n let wizard = SetupWizardPanel::new();\n assert_eq!(wizard.step, SetupStep::Choose);\n assert_eq!(wizard.source, SetupSource::CustomApi);\n assert_eq!(wizard.language, \"en\");\n assert_eq!(wizard.language_cursor, 0);\n assert_eq!(wizard.providers.len(), 1);\n assert_eq!(wizard.providers[0].provider_type, ProviderType::Anthropic);\n assert!(wizard.providers[0].api_key.is_empty());\n assert!(wizard.providers[0].selected);\n}\n```\n\n- [ ] **Step 6: 运行全部 setup wizard 测试**\n\n```bash\ncargo test -p peri-tui --lib -- setup_wizard_test 2>&1\n```\n\nExpected: 所有测试通过。\n\n---\n\n### Task 7: 集成测试更新\n\n**Files:**\n- Modify: `peri-tui/src/ui/headless_test.rs`\n\nSetup wizard 的 headless 集成测试可能也需要适配新的步骤顺序。\n\n- [ ] **Step 1: 搜索 headless 测试中 setup wizard 相关测试**\n\n```bash\ngrep -n \"test_s.*setup\\|test_s.*wizard\\|test_s.*form\\|test_s.*choose\" peri-tui/src/ui/headless_test.rs\n```\n\n- [ ] **Step 2: 阅读相关测试并更新步骤预期**\n\n检查 `peri-tui/src/ui/headless_test.rs` 中所有直接调用 `handle_setup_wizard_key` 并按步骤状态做断言的测试。如果某测试从 Choose 直接进入 Form(跳过 Language),需要在测试中插入一次 Enter(从 Choose 进 Language)再 Enter(从 Language 进 Form)。\n\n- [ ] **Step 3: 更新 headless_test.rs 的 import**\n\n搜索 `set_wizard` 相关的 import 语句,确保 `LANGUAGE_OPTIONS` 被导入(如需要)。\n\n- [ ] **Step 4: 运行 headless 测试**\n\n```bash\ncargo test -p peri-tui --test '*' -- setup 2>&1 | tail -30\n```\n\nExpected: 所有测试通过。\n\n---\n\n### Task 8: 完整编译 + 测试验证\n\n- [ ] **Step 1: 完整编译**\n\n```bash\ncargo build -p peri-tui 2>&1\n```\n\n- [ ] **Step 2: 完整测试**\n\n```bash\ncargo test -p peri-tui 2>&1\n```\n\nExpected: 编译无错误,所有测试通过。\n\n- [ ] **Step 3: Clippy**\n\n```bash\ncargo clippy -p peri-tui -- -D warnings 2>&1\n```\n\nExpected: 无 clippy 警告。\n\n- [ ] **Step 4: Commit**\n\n```bash\ngit add peri-tui/src/app/setup_wizard.rs peri-tui/src/app/setup_wizard_test.rs \\\n peri-tui/src/ui/main_ui/popups/setup_wizard.rs peri-tui/locales/en/main.ftl \\\n peri-tui/locales/zh-CN/main.ftl peri-tui/src/ui/headless_test.rs\ngit commit -m \"feat(setup): add language selection step between Choose and Form\n\nCloses spec/issues/2026-05-16-i18n-language-not-in-setup.md\n\nCo-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>\"\n```\n\n---\n\n## Self-Review\n\n### 1. Spec coverage\n- ✅ 在 Choose 和 Form 之间增加独立的 Language 步骤\n- ✅ 用户可选择 English (en) 或 中文 (zh-CN)\n- ✅ 选择结果写入 AppConfig.language\n- ✅ save_setup_to 写入 language 字段\n- ✅ 涉及文件:setup_wizard.rs, setup_wizard.rs (popups), types.rs (已存在 language 字段,无需修改)\n\n### 2. Placeholder scan\n- 无占位符\n\n### 3. Type consistency\n- `SetupStep::Language` 在所有 match 分支中一致(handle_setup_wizard_key, render_setup_wizard)\n- `LANGUAGE_OPTIONS` 数组 `[(\"en\", \"English\"), (\"zh-CN\", \"中文\")]` 索引 0/1 与 `language_cursor` 对应\n- `wizard.language` 取值 `\"en\"` 或 `\"zh-CN\"`,与 `AppConfig.language: Option<String>` 兼容\n- 测试中 `make_char(' ')` 和 `make_key(Key::Enter)` 在 Language 步骤的行为一致\n"}, "a8c9d2ac74a125411770e9a1a85fed70e65d5b54": {"t": 1785766667.1863415, "n": 15459, "h": "54d50030e8e5f546", "c": "# Setup Wizard 语言选择步骤 Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** 在 Setup 向导的 Choose 和 Form 步骤之间增加 Language 步骤,用户选择 `en` 或 `zh-CN`,结果写入 `AppConfig.language`。\n\n**Architecture:** 三步扩充为四步:Choose → Language → Form → Done。Language 步骤用方向键导航两个语言选项(English / 中文),Enter 确认后进入 Form,Esc 返回 Choose。`SetupWizardPanel` 新增 `language` 和 `language_cursor` 字段,`save_setup_to()` 写入 `cfg.config.language`。\n\n**Tech Stack:** Rust + ratatui + tui-textarea + Fluent i18n\n\n---\n\n### Task 1: FTL 翻译键\n\n**Files:**\n- Modify: `peri-tui/locales/en/main.ftl` (追加 ~5 行)\n- Modify: `peri-tui/locales/zh-CN/main.ftl` (追加 ~5 行)\n\n新增 Language 步骤需要的翻译键。语言选项名称(English / 中文)硬编码显示,不经过翻译。\n\n- [ ] **Step 1: 在英文 FTL 末尾追加翻译键**\n\n在 `peri-tui/locales/en/main.ftl` 末尾追加:\n\n```ftl\n# Setup Language step\nsetup-language-title = ── Peri Setup ── Language\nsetup-language-prompt = Choose your interface language:\nsetup-language-press-enter = Press Enter to confirm\n```\n\n- [ ] **Step 2: 在中文 FTL 末尾追加翻译键**\n\n在 `peri-tui/locales/zh-CN/main.ftl` 末尾追加:\n\n```ftl\n# Setup Language step\nsetup-language-title = ── Peri 设置 ── 语言\nsetup-language-prompt = 选择界面语言:\nsetup-language-press-enter = 按 Enter 确认\n```\n\n- [ ] **Step 3: 验证 FTL 文件解析无错误**\n\n```bash\ncargo build -p peri-tui 2>&1 | head -5\n```\n\nExpected: 编译成功,无 FTL parse error。\n\n---\n\n### Task 2: SetupWizardPanel 状态扩展\n\n**Files:**\n- Modify: `peri-tui/src/app/setup_wizard.rs`\n\n为 `SetupStep` 枚举添加 `Language` 变体,为 `SetupWizardPanel` 添加 `language`/`language_cursor` 字段,定义语言选项常量。\n\n- [ ] **Step 1: 在 `SetupStep` 枚举中插入 `Language` 变体**\n\n修改 `peri-tui/src/app/setup_wizard.rs` 第 3-10 行,在 `Choose` 和 `Form` 之间插入 `Language`:\n\n```rust\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum SetupStep {\n /// 选择来源\n Choose,\n /// 选择语言\n Language,\n /// 合并表单:多 Provider + API Key + Model Aliases\n Form,\n /// 确认完成\n Done,\n}\n```\n\n- [ ] **Step 2: 添加语言选项常量**\n\n在 `SetupSource::ALL` 定义之后(第 22 行附近),插入:\n\n```rust\n/// 支持的语言选项:(code, display_name)\npub const LANGUAGE_OPTIONS: [(&str, &str); 2] = [(\"en\", \"English\"), (\"zh-CN\", \"中文\")];\n```\n\n- [ ] **Step 3: 在 `SetupWizardPanel` 中添加 `language` 和 `language_cursor` 字段**\n\n修改 `SetupWizardPanel` 结构体(第 221-238 行),添加两个新字段:\n\n```rust\npub struct SetupWizardPanel {\n pub step: SetupStep,\n /// Step 1: 来源选择\n pub source: SetupSource,\n pub choose_cursor: usize,\n /// Step 2: 语言选择\n pub language: String,\n pub language_cursor: usize,\n /// Step 3: 多 provider 列表\n pub providers: Vec<MigratedProvider>,\n // ... 其余字段不变\n```\n\n- [ ] **Step 4: 在 `new()` 中初始化新字段**\n\n修改 `SetupWizardPanel::new()`(第 247-259 行),在 `choose_cursor: 0,` 之后插入:\n\n```rust\nlanguage: \"en\".to_string(),\nlanguage_cursor: 0,\n```\n\n- [ ] **Step 5: 构建验证**\n\n```bash\ncargo build -p peri-tui 2>&1 | tail -20\n```\n\nExpected: 大部分编译通过,仅渲染分发缺少 `SetupStep::Language` 分支导致编译错误(预期行为,Task 3 处理)。\n\n---\n\n### Task 3: Language 步骤按键处理\n\n**Files:**\n- Modify: `peri-tui/src/app/setup_wizard.rs`\n\n实现 `handle_step_language` 函数,并在 `handle_setup_wizard_key` 分发和步骤间跳转逻辑中插入 Language 步骤。\n\n- [ ] **Step 1: 在 `handle_setup_wizard_key` 中添加 Language 分发**\n\n修改 `handle_setup_wizard_key` 函数(第 481-490 行),在 `SetupStep::Choose` 和 `SetupStep::Form` 之间插入:\n\n```rust\nSetupStep::Language => handle_step_language(wizard, input),\n```\n\n- [ ] **Step 2: 修改 `handle_step_choose` Enter 逻辑跳转到 Language**\n\n修改第 509-536 行的 Enter/Space 处理,将跳转到 `Form` 改为跳转到 `Language`:\n\n```rust\n} | tui_textarea::Input {\n key: Key::Char(' '),\n ..\n} => {\n if wizard.source == SetupSource::MigrateClaudeCode {\n if !wizard.migrate_from_claude_code() {\n wizard.source = SetupSource::CustomApi;\n wizard.choose_cursor = 0;\n return Some(SetupWizardAction::Redraw);\n }\n } else {\n wizard.providers = vec![MigratedProvider::new(ProviderType::Anthropic)];\n wizard.active_provider = 0;\n }\n wizard.step = SetupStep::Language;\n wizard.language_cursor = 0;\n Some(SetupWizardAction::Redraw)\n}\n```\n\n- [ ] **Step 3: 修改 `handle_browse` 的 Esc 跳转到 Language**\n\n修改第 604-608 行,将 Esc 跳转目标从 `Choose` 改为 `Language`:\n\n```rust\ntui_textarea::Input { key: Key::Esc, .. } => {\n wizard.step = SetupStep::Language;\n Some(SetupWizardAction::Redraw)\n}\n```\n\n- [ ] **Step 4: 实现 `handle_step_language` 函数**\n\n在 `handle_step_choose` 函数之后(第 537 行之后),插入新函数:\n\n```rust\nfn handle_step_language(\n wizard: &mut SetupWizardPanel,\n input: tui_textarea::Input,\n) -> Option<SetupWizardAction> {\n use tui_textarea::Key;\n match input {\n tui_textarea::Input { key: Key::Up, .. } => {\n wizard.language_cursor =\n (wizard.language_cursor + LANGUAGE_OPTIONS.len() - 1) % LANGUAGE_OPTIONS.len();\n Some(SetupWizardAction::Redraw)\n }\n tui_textarea::Input { key: Key::Down, .. } => {\n wizard.language_cursor = (wizard.language_cursor + 1) % LANGUAGE_OPTIONS.len();\n Some(SetupWizardAction::Redraw)\n }\n tui_textarea::Input {\n key: Key::Enter, ..\n }\n | tui_textarea::Input {\n key: Key::Char(' '),\n ..\n } => {\n wizard.language = LANGUAGE_OPTIONS[wizard.language_cursor].0.to_string();\n wizard.step = SetupStep::Form;\n wizard.form_mode = FormMode::Browse;\n wizard.browse_cursor = 0;\n wizard.form_focus = FormField::ProviderType;\n Some(SetupWizardAction::Redraw)\n }\n tui_textarea::Input { key: Key::Esc, .. } => {\n wizard.step = SetupStep::Choose;\n Some(SetupWizardAction::Redraw)\n }\n _ => None,\n }\n}\n```\n\n- [ ] **Step 5: 编译验证(渲染分发仍缺)**\n\n```bash\ncargo build -p peri-tui 2>&1 | grep \"error\" | head -10\n```\n\nExpected: 仅有 `render_setup_wizard` 缺少 `SetupStep::Language` 分支的错误(Task 4 修复)。\n\n---\n\n### Task 4: Language 步骤 UI 渲染\n\n**Files:**\n- Modify: `peri-tui/src/ui/main_ui/popups/setup_wizard.rs`\n\n添加 `render_step_language` 函数,在 `render_setup_wizard` 分发中添加 Language 分支。\n\n- [ ] **Step 1: 在 `render_setup_wizard` 中添加 Language 分发**\n\n修改第 20-24 行的 match:\n\n```rust\nmatch wizard.step {\n SetupStep::Choose => render_step_choose(f, wizard, lc, area),\n SetupStep::Language => render_step_language(f, wizard, lc, area),\n SetupStep::Form => render_step_form(f, wizard, lc, area),\n SetupStep::Done => render_step_done(f, wizard, lc, area),\n}\n```\n\n- [ ] **Step 2: 实现 `render_step_language` 函数**\n\n在 `render_step_choose` 函数之后(第 86 行之前),插入新函数:\n\n```rust\nfn render_step_language(\n f: &mut Frame,\n wizard: &SetupWizardPanel,\n lc: &crate::i18n::LcRegistry,\n area: Rect,\n) {\n let inner = BorderedPanel::new(Span::styled(\n lc.tr(\"setup-language-title\"),\n Style::default()\n .fg(theme::ACCENT)\n .add_modifier(Modifier::BOLD),\n ))\n .border_style(Style::default().fg(theme::ACCENT))\n .render(f, area);\n\n let mut lines: Vec<Line> = vec![\n Line::from(\"\"),\n Line::from(Span::styled(\n lc.tr(\"setup-language-prompt\"),\n Style::default().fg(theme::MUTED),\n )),\n Line::from(\"\"),\n ];\n\n for (i, (_code, name)) in LANGUAGE_OPTIONS.iter().enumerate() {\n let is_cursor = i == wizard.language_cursor;\n let cursor_char = if is_cursor { \"❯\" } else { \" \" };\n let cursor_style = Style::default().fg(theme::THINKING);\n let name_style = if is_cursor {\n Style::default()\n .fg(theme::THINKING)\n .add_modifier(Modifier::BOLD)\n } else {\n Style::default().fg(theme::TEXT)\n };\n lines.push(Line::from(vec![\n Span::styled(format!(\"{} \", cursor_char), cursor_style),\n Span::styled(*name, name_style),\n ]));\n }\n\n lines.push(Line::from(\"\"));\n lines.push(make_hint_line(vec![\n (\"Enter\".to_string(), lc.tr(\"setup-key-confirm\")),\n (\"↑/↓\".to_string(), lc.tr(\"setup-key-select\")),\n (\"Esc\".to_string(), lc.tr(\"setup-key-back\")),\n ]));\n\n f.render_widget(Paragraph::new(Text::from(lines)), inner);\n}\n```\n\nNote: 需要确认 `LANGUAGE_OPTIONS` 的 import。检查文件顶部的 imports,当前已有 `use crate::app::setup_wizard::{FormField, FormMode, SetupSource, SetupStep, SetupWizardPanel};`,需要添加 `LANGUAGE_OPTIONS`。\n\n- [ ] **Step 3: 添加 `LANGUAGE_OPTIONS` 的 import**\n\n修改第 11 行的 import:\n\n```rust\nuse crate::app::setup_wizard::{FormField, FormMode, LANGUAGE_OPTIONS, SetupSource, SetupStep, SetupWizardPanel};\n```\n\n- [ ] **Step 4: 编译验证**\n\n```bash\ncargo build -p peri-tui 2>&1 | tail -5\n```\n\nExpected: 编译成功。\n\n---\n\n### Task 5: save_setup_to 写入 language 字段\n\n**Files:**\n- Modify: `peri-tui/src/app/setup_wizard.rs`\n\n- [ ] **Step 1: 在 `save_setup_to` 中写入 `language`**\n\n在 `save_setup_to` 函数中(`cfg.config.active_provider_id = first_id;` 之后,第 778 行附近),添加:\n\n```rust\ncfg.config.language = Some(wizard.language.clone());\n```\n\n最终 `save_setup_to` 的相关部分变为:\n\n```rust\nif !first_id.is_empty() {\n cfg.config.active_alias = \"opus\".to_string();\n cfg.config.active_provider_id = first_id;\n}\n\ncfg.config.language = Some(wizard.language.clone());\n\nlet content = serde_json::to_string_pretty(&cfg)?;\n```\n\n- [ ] **Step 2: 编译验证**\n\n```bash\ncargo build -p peri-tui 2>&1 | tail -5\n```\n\nExpected: 编译成功。\n\n---\n\n### Task 6: 单元测试\n\n**Files:**\n- Modify: `peri-tui/src/app/setup_wizard_test.rs`\n\n- [ ] **Step 1: 添加 Language 步骤导航测试**\n\n在 Choose 步骤测试之后(第 216 行后)、Form 步骤测试之前,插入:\n\n```rust\n// ── Step: Language ──\n\n#[test]\nfn test_language_arrow_navigates() {\n let mut wizard = SetupWizardPanel::new();\n wizard.step = SetupStep::Language;\n assert_eq!(wizard.language_cursor, 0);\n let _ = handle_setup_wizard_key(&mut wizard, make_key(Key::Down));\n assert_eq!(wizard.language_cursor, 1);\n let _ = handle_setup_wizard_key(&mut wizard, make_key(Key::Down));\n assert_eq!(wizard.language_cursor, 0); // wraps around\n let _ = handle_setup_wizard_key(&mut wizard, make_key(Key::Up));\n assert_eq!(wizard.language_cursor, 1); // wraps around\n}\n\n#[test]\nfn test_language_enter_selects_and_advances_to_form() {\n let mut wizard = SetupWizardPanel::new();\n wizard.step = SetupStep::Language;\n wizard.language_cursor = 1; // zh-CN\n let _ = handle_setup_wizard_key(&mut wizard, make_key(Key::Enter));\n assert_eq!(wizard.language, \"zh-CN\");\n assert_eq!(wizard.step, SetupStep::Form);\n assert_eq!(wizard.form_mode, FormMode::Browse);\n}\n\n#[test]\nfn test_language_space_selects_and_advances() {\n let mut wizard = SetupWizardPanel::new();\n wizard.step = SetupStep::Language;\n let _ = handle_setup_wizard_key(&mut wizard, make_char(' '));\n assert_eq!(wizard.language, \"en\");\n assert_eq!(wizard.step, SetupStep::Form);\n}\n\n#[test]\nfn test_language_esc_back_to_choose() {\n let mut wizard = SetupWizardPanel::new();\n wizard.step = SetupStep::Language;\n let _ = handle_setup_wizard_key(&mut wizard, make_key(Key::Esc));\n assert_eq!(wizard.step, SetupStep::Choose);\n}\n\n#[test]\nfn test_language_default_is_en() {\n let wizard = SetupWizardPanel::new();\n assert_eq!(wizard.language, \"en\");\n}\n```\n\n- [ ] **Step 2: 更新 Choose 步骤 Enter 测试**\n\n修改 `test_choose_enter_custom_advances_to_form` (第 203-209 行):\n\n```rust\n#[test]\nfn test_choose_enter_custom_advances_to_language() {\n let mut wizard = SetupWizardPanel::new();\n let _ = handle_setup_wizard_key(&mut wizard, make_key(Key::Enter));\n assert_eq!(wizard.step, SetupStep::Language);\n}\n```\n\n- [ ] **Step 3: 更新 Browse Esc 测试**\n\n修改 `test_browse_esc_back_to_choose` (第 272-278 行):\n\n```rust\n#[test]\nfn test_browse_esc_back_to_language() {\n let mut wizard = SetupWizardPanel::new();\n wizard.step = SetupStep::Form;\n wizard.form_mode = FormMode::Browse;\n let _ = handle_setup_wizard_key(&mut wizard, make_key(Key::Esc));\n assert_eq!(wizard.step, SetupStep::Language);\n}\n```\n\n- [ ] **Step 4: 添加 `save_setup_to` 写入 language 测试**\n\n在 `test_save_setup_skips_unselected` 之后(第 371 行后),添加:\n\n```rust\n#[test]\nfn test_save_setup_writes_language() {\n let mut wizard = SetupWizardPanel::new();\n wizard.providers[0].api_key = \"sk-test\".to_string();\n wizard.language = \"zh-CN\".to_string();\n let temp_dir = std::env::temp_dir().join(format!(\"zen-lang-setup-{}\", uuid::Uuid::now_v7()));\n let config_path = temp_dir.join(\"settings.json\");\n let cfg = save_setup_to(&wizard, &config_path).expect(\"save should succeed\");\n assert_eq!(cfg.config.language.as_deref(), Some(\"zh-CN\"));\n let _ = std::fs::remove_dir_all(&temp_dir);\n}\n```\n\n- [ ] **Step 5: 更新 `test_setup_wizard_new_defaults`**\n\n修改 `test_setup_wizard_new_defaults` (第 24-32 行),添加语言默认值断言:\n\n```rust\n#[test]\nfn test_setup_wizard_new_defaults() {\n let wizard = SetupWizardPanel::new();\n assert_eq!(wizard.step, SetupStep::Choose);\n assert_eq!(wizard.source, SetupSource::CustomApi);\n assert_eq!(wizard.language, \"en\");\n assert_eq!(wizard.language_cursor, 0);\n assert_eq!(wizard.providers.len(), 1);\n assert_eq!(wizard.providers[0].provider_type, ProviderType::Anthropic);\n assert!(wizard.providers[0].api_key.is_empty());\n assert!(wizard.providers[0].selected);\n}\n```\n\n- [ ] **Step 6: 运行全部 setup wizard 测试**\n\n```bash\ncargo test -p peri-tui --lib -- setup_wizard_test 2>&1\n```\n\nExpected: 所有测试通过。\n\n---\n\n### Task 7: 集成测试更新\n\n**Files:**\n- Modify: `peri-tui/src/ui/headless_test.rs`\n\nSetup wizard 的 headless 集成测试可能也需要适配新的步骤顺序。\n\n- [ ] **Step 1: 搜索 headless 测试中 setup wizard 相关测试**\n\n```bash\ngrep -n \"test_s.*setup\\|test_s.*wizard\\|test_s.*form\\|test_s.*choose\" peri-tui/src/ui/headless_test.rs\n```\n\n- [ ] **Step 2: 阅读相关测试并更新步骤预期**\n\n检查 `peri-tui/src/ui/headless_test.rs` 中所有直接调用 `handle_setup_wizard_key` 并按步骤状态做断言的测试。如果某测试从 Choose 直接进入 Form(跳过 Language),需要在测试中插入一次 Enter(从 Choose 进 Language)再 Enter(从 Language 进 Form)。\n\n- [ ] **Step 3: 更新 headless_test.rs 的 import**\n\n搜索 `set_wizard` 相关的 import 语句,确保 `LANGUAGE_OPTIONS` 被导入(如需要)。\n\n- [ ] **Step 4: 运行 headless 测试**\n\n```bash\ncargo test -p peri-tui --test '*' -- setup 2>&1 | tail -30\n```\n\nExpected: 所有测试通过。\n\n---\n\n### Task 8: 完整编译 + 测试验证\n\n- [ ] **Step 1: 完整编译**\n\n```bash\ncargo build -p peri-tui 2>&1\n```\n\n- [ ] **Step 2: 完整测试**\n\n```bash\ncargo test -p peri-tui 2>&1\n```\n\nExpected: 编译无错误,所有测试通过。\n\n- [ ] **Step 3: Clippy**\n\n```bash\ncargo clippy -p peri-tui -- -D warnings 2>&1\n```\n\nExpected: 无 clippy 警告。\n\n- [ ] **Step 4: Commit**\n\n```bash\ngit add peri-tui/src/app/setup_wizard.rs peri-tui/src/app/setup_wizard_test.rs \\\n peri-tui/src/ui/main_ui/popups/setup_wizard.rs peri-tui/locales/en/main.ftl \\\n peri-tui/locales/zh-CN/main.ftl peri-tui/src/ui/headless_test.rs\ngit commit -m \"feat(setup): add language selection step between Choose and Form\n\nCloses spec/issues/2026-05-16-i18n-language-not-in-setup.md\n\nCo-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>\"\n```\n\n---\n\n## Self-Review\n\n### 1. Spec coverage\n- ✅ 在 Choose 和 Form 之间增加独立的 Language 步骤\n- ✅ 用户可选择 English (en) 或 中文 (zh-CN)\n- ✅ 选择结果写入 AppConfig.language\n- ✅ save_setup_to 写入 language 字段\n- ✅ 涉及文件:setup_wizard.rs, setup_wizard.rs (popups), types.rs (已存在 language 字段,无需修改)\n\n### 2. Placeholder scan\n- 无占位符\n\n### 3. Type consistency\n- `SetupStep::Language` 在所有 match 分支中一致(handle_setup_wizard_key, render_setup_wizard)\n- `LANGUAGE_OPTIONS` 数组 `[(\"en\", \"English\"), (\"zh-CN\", \"中文\")]` 索引 0/1 与 `language_cursor` 对应\n- `wizard.language` 取值 `\"en\"` 或 `\"zh-CN\"`,与 `AppConfig.language: Option<String>` 兼容\n- 测试中 `make_char(' ')` 和 `make_key(Key::Enter)` 在 Language 步骤的行为一致\n"}}}} |