Files

1 line
86 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}