From a4c577278902da8a61fba38224bd4d853da04605 Mon Sep 17 00:00:00 2001 From: fukyor <3273228775@qq.com> Date: Thu, 22 Jan 2026 16:38:47 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++ package-lock.json | 6 ++- providers.example.json | 5 ++- src/commands/command-executor.ts | 65 +++++++++++++++++++++++++++++++- src/types/index.ts | 6 +++ src/ui/cli-interface.ts | 58 ++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 3d7748b..51e8c0a 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,6 @@ dev/ # Test files test-*.json *.test.json + +# .claude +.claude/** diff --git a/package-lock.json b/package-lock.json index a3b05b4..a03d7a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "switch-claude-cli", - "version": "1.4.1", + "version": "1.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "switch-claude-cli", - "version": "1.4.1", + "version": "1.4.5", "license": "MIT", "dependencies": { "inquirer": "^9.0.0", @@ -14,6 +14,8 @@ "update-notifier": "^7.3.1" }, "bin": { + "ccc": "dist/index.js", + "scl": "dist/index.js", "switch-claude": "dist/index.js" }, "devDependencies": { diff --git a/providers.example.json b/providers.example.json index e75a268..9ae9eb1 100644 --- a/providers.example.json +++ b/providers.example.json @@ -3,7 +3,10 @@ "name": "Provider1", "baseUrl": "https://api.example1.com", "key": "sk-your-api-key-here-replace-with-real-key", - "default": false + "default": false, + "defaultHaikuModel": "claude-3-5-haiku-20241022", + "defaultSonnetModel": "claude-3-5-sonnet-20241022", + "defaultOpusModel": "claude-3-5-opus-20241022" }, { "name": "Provider2", diff --git a/src/commands/command-executor.ts b/src/commands/command-executor.ts index 0457609..ea4b164 100644 --- a/src/commands/command-executor.ts +++ b/src/commands/command-executor.ts @@ -496,6 +496,17 @@ export class CommandExecutor { process.env.ANTHROPIC_BASE_URL = provider.baseUrl; process.env.ANTHROPIC_AUTH_TOKEN = provider.key; + // 设置模型环境变量 + if (provider.defaultHaikuModel) { + process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = provider.defaultHaikuModel; + } + if (provider.defaultSonnetModel) { + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = provider.defaultSonnetModel; + } + if (provider.defaultOpusModel) { + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = provider.defaultOpusModel; + } + console.log(`\n✅ 已切换到: ${provider.name} (${provider.baseUrl})`); console.log(`\n🔧 环境变量已设置:`); console.log(` ANTHROPIC_BASE_URL=${provider.baseUrl}`); @@ -508,6 +519,16 @@ export class CommandExecutor { console.log(` HTTPS_PROXY=${normalizedProxy}`); } + if (provider.defaultHaikuModel) { + console.log(` ANTHROPIC_DEFAULT_HAIKU_MODEL=${provider.defaultHaikuModel}`); + } + if (provider.defaultSonnetModel) { + console.log(` ANTHROPIC_DEFAULT_SONNET_MODEL=${provider.defaultSonnetModel}`); + } + if (provider.defaultOpusModel) { + console.log(` ANTHROPIC_DEFAULT_OPUS_MODEL=${provider.defaultOpusModel}`); + } + const responseTime = provider.testResult?.responseTime ?? null; StatsManager.recordProviderUse(provider.name, true, responseTime); @@ -521,6 +542,15 @@ export class CommandExecutor { } console.log(` $env:ANTHROPIC_BASE_URL="${provider.baseUrl}"`); console.log(` $env:ANTHROPIC_AUTH_TOKEN="${provider.key}"`); + if (provider.defaultHaikuModel) { + console.log(` $env:ANTHROPIC_DEFAULT_HAIKU_MODEL="${provider.defaultHaikuModel}"`); + } + if (provider.defaultSonnetModel) { + console.log(` $env:ANTHROPIC_DEFAULT_SONNET_MODEL="${provider.defaultSonnetModel}"`); + } + if (provider.defaultOpusModel) { + console.log(` $env:ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"`); + } console.log(` claude`); return { success: true, message: '', exitCode: 0 }; } @@ -569,13 +599,35 @@ export class CommandExecutor { if (platform === 'windows') { // 在 Windows 上使用 cmd 执行(尽量避免对 PowerShell 的依赖) command = userShell; // 通常为 cmd.exe - const winCmd = `set "ANTHROPIC_BASE_URL=${provider.baseUrl}" && set "ANTHROPIC_AUTH_TOKEN=${provider.key}" && claude`; + // 构建包含模型环境变量的命令 + let modelEnv = ''; + if (provider.defaultHaikuModel) { + modelEnv += `set "ANTHROPIC_DEFAULT_HAIKU_MODEL=${provider.defaultHaikuModel}" && `; + } + if (provider.defaultSonnetModel) { + modelEnv += `set "ANTHROPIC_DEFAULT_SONNET_MODEL=${provider.defaultSonnetModel}" && `; + } + if (provider.defaultOpusModel) { + modelEnv += `set "ANTHROPIC_DEFAULT_OPUS_MODEL=${provider.defaultOpusModel}" && `; + } + const winCmd = `${modelEnv}set "ANTHROPIC_BASE_URL=${provider.baseUrl}" && set "ANTHROPIC_AUTH_TOKEN=${provider.key}" && claude`; args = ['/c', winCmd]; useShell = false; } else { command = userShell; // -l 登录 shell(读取 zprofile/profile),-i 交互式(读取 zshrc/bashrc),-c 执行命令 - const exportCmd = `export ANTHROPIC_BASE_URL="${provider.baseUrl}"; export ANTHROPIC_AUTH_TOKEN="${provider.key}"; claude`; + // 构建包含模型环境变量的命令 + let modelExport = ''; + if (provider.defaultHaikuModel) { + modelExport += `export ANTHROPIC_DEFAULT_HAIKU_MODEL="${provider.defaultHaikuModel}"; `; + } + if (provider.defaultSonnetModel) { + modelExport += `export ANTHROPIC_DEFAULT_SONNET_MODEL="${provider.defaultSonnetModel}"; `; + } + if (provider.defaultOpusModel) { + modelExport += `export ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"; `; + } + const exportCmd = `${modelExport}export ANTHROPIC_BASE_URL="${provider.baseUrl}"; export ANTHROPIC_AUTH_TOKEN="${provider.key}"; claude`; args = ['-l', '-i', '-c', exportCmd]; useShell = false; } @@ -604,6 +656,15 @@ export class CommandExecutor { console.log(` 3. 或者手动设置环境变量后运行 claude:`); console.log(` $env:ANTHROPIC_BASE_URL="${provider.baseUrl}"`); console.log(` $env:ANTHROPIC_AUTH_TOKEN="${provider.key}"`); + if (provider.defaultHaikuModel) { + console.log(` $env:ANTHROPIC_DEFAULT_HAIKU_MODEL="${provider.defaultHaikuModel}"`); + } + if (provider.defaultSonnetModel) { + console.log(` $env:ANTHROPIC_DEFAULT_SONNET_MODEL="${provider.defaultSonnetModel}"`); + } + if (provider.defaultOpusModel) { + console.log(` $env:ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"`); + } console.log(` claude`); console.log(`\n🔍 当前 PATH 包含的目录:`); const paths = (process.env.PATH || '').split(process.platform === 'win32' ? ';' : ':'); diff --git a/src/types/index.ts b/src/types/index.ts index 7fcec88..86ff08d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,6 +13,12 @@ export interface Provider { default?: boolean; /** HTTP 代理配置 (例如: http://127.0.0.1:7897) */ proxy?: string; + /** 默认 Haiku 模型 */ + defaultHaikuModel?: string; + /** 默认 Sonnet 模型 */ + defaultSonnetModel?: string; + /** 默认 Opus 模型 */ + defaultOpusModel?: string; } export interface TestResult { diff --git a/src/ui/cli-interface.ts b/src/ui/cli-interface.ts index 6c198de..8dbc0c4 100644 --- a/src/ui/cli-interface.ts +++ b/src/ui/cli-interface.ts @@ -105,6 +105,24 @@ export class CliInterface { message: '是否设置为默认 Provider?', default: existingProviders.length === 0, }, + { + type: 'input', + name: 'defaultHaikuModel', + message: '默认 Haiku 模型 (可选,留空使用默认):', + default: '', + }, + { + type: 'input', + name: 'defaultSonnetModel', + message: '默认 Sonnet 模型 (可选,留空使用默认):', + default: '', + }, + { + type: 'input', + name: 'defaultOpusModel', + message: '默认 Opus 模型 (可选,留空使用默认):', + default: '', + }, ]); const provider: Provider = { @@ -119,6 +137,17 @@ export class CliInterface { provider.proxy = answers.proxy.trim(); } + // 只有当用户输入了模型名称时才添加对应字段 + if (answers.defaultHaikuModel && answers.defaultHaikuModel.trim()) { + provider.defaultHaikuModel = answers.defaultHaikuModel.trim(); + } + if (answers.defaultSonnetModel && answers.defaultSonnetModel.trim()) { + provider.defaultSonnetModel = answers.defaultSonnetModel.trim(); + } + if (answers.defaultOpusModel && answers.defaultOpusModel.trim()) { + provider.defaultOpusModel = answers.defaultOpusModel.trim(); + } + return provider; } catch (error) { if (error instanceof Error && error.message.includes('cancelled')) { @@ -202,6 +231,24 @@ export class CliInterface { message: '是否设置为默认 Provider?', default: provider.default || false, }, + { + type: 'input', + name: 'defaultHaikuModel', + message: '默认 Haiku 模型 (可选,留空使用默认):', + default: provider.defaultHaikuModel || '', + }, + { + type: 'input', + name: 'defaultSonnetModel', + message: '默认 Sonnet 模型 (可选,留空使用默认):', + default: provider.defaultSonnetModel || '', + }, + { + type: 'input', + name: 'defaultOpusModel', + message: '默认 Opus 模型 (可选,留空使用默认):', + default: provider.defaultOpusModel || '', + }, ]); const updatedProvider: Provider = { @@ -216,6 +263,17 @@ export class CliInterface { updatedProvider.proxy = answers.proxy.trim(); } + // 只有当用户输入了模型名称时才添加对应字段 + if (answers.defaultHaikuModel && answers.defaultHaikuModel.trim()) { + updatedProvider.defaultHaikuModel = answers.defaultHaikuModel.trim(); + } + if (answers.defaultSonnetModel && answers.defaultSonnetModel.trim()) { + updatedProvider.defaultSonnetModel = answers.defaultSonnetModel.trim(); + } + if (answers.defaultOpusModel && answers.defaultOpusModel.trim()) { + updatedProvider.defaultOpusModel = answers.defaultOpusModel.trim(); + } + return updatedProvider; } catch (error) { if (error instanceof Error && error.message.includes('cancelled')) { From 732949cd79aa673d3fba8326c3831ba0eaa59afd Mon Sep 17 00:00:00 2001 From: fukyor <3273228775@qq.com> Date: Sat, 24 Jan 2026 14:53:43 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E5=AD=97=E6=AE=B5=EF=BC=8C=E4=BF=9D?= =?UTF-8?q?=E8=AF=81=E9=99=A4=E4=BA=86=E4=BA=A4=E4=BA=92=E5=BC=8F=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E7=9A=84=E5=BF=85=E8=A6=81=E5=AD=97=E6=AE=B5=E5=A4=96?= =?UTF-8?q?=EF=BC=8C=E8=BF=98=E8=83=BD=E5=A4=9F=E8=AF=BB=E5=8F=96=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=87=AA=E5=AE=9A=E4=B9=89=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/command-executor.ts | 43 ++++++++++++++++++++- src/utils/provider-utils.ts | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 src/utils/provider-utils.ts diff --git a/src/commands/command-executor.ts b/src/commands/command-executor.ts index ea4b164..5b327ae 100644 --- a/src/commands/command-executor.ts +++ b/src/commands/command-executor.ts @@ -10,6 +10,7 @@ import { FileUtils } from '../utils/file-utils.js'; import { ValidationUtils } from '../utils/validation.js'; import { normalizeProxyUrl } from '../utils/proxy-utils.js'; import { StatsManager } from '../core/stats-manager.js'; +import { ProviderUtils } from '../utils/provider-utils.js'; import updateNotifier from 'update-notifier'; import { spawn } from 'child_process'; import path from 'node:path'; @@ -507,6 +508,10 @@ export class CommandExecutor { process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = provider.defaultOpusModel; } + // 提取并应用自定义字段为环境变量 + const customFields = ProviderUtils.extractCustomFields(provider); + const customFieldsCount = ProviderUtils.applyCustomFieldsToEnv(customFields); + console.log(`\n✅ 已切换到: ${provider.name} (${provider.baseUrl})`); console.log(`\n🔧 环境变量已设置:`); console.log(` ANTHROPIC_BASE_URL=${provider.baseUrl}`); @@ -529,6 +534,14 @@ export class CommandExecutor { console.log(` ANTHROPIC_DEFAULT_OPUS_MODEL=${provider.defaultOpusModel}`); } + // 显示自定义字段 + if (customFieldsCount > 0) { + console.log(`\n 自定义字段:`); + for (const [key, value] of Object.entries(customFields)) { + console.log(` ${key}=${value}`); + } + } + const responseTime = provider.testResult?.responseTime ?? null; StatsManager.recordProviderUse(provider.name, true, responseTime); @@ -551,6 +564,12 @@ export class CommandExecutor { if (provider.defaultOpusModel) { console.log(` $env:ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"`); } + // 显示自定义字段的 PowerShell 命令 + if (customFieldsCount > 0) { + for (const [key, value] of Object.entries(customFields)) { + console.log(` $env:${key}="${value}"`); + } + } console.log(` claude`); return { success: true, message: '', exitCode: 0 }; } @@ -610,7 +629,14 @@ export class CommandExecutor { if (provider.defaultOpusModel) { modelEnv += `set "ANTHROPIC_DEFAULT_OPUS_MODEL=${provider.defaultOpusModel}" && `; } - const winCmd = `${modelEnv}set "ANTHROPIC_BASE_URL=${provider.baseUrl}" && set "ANTHROPIC_AUTH_TOKEN=${provider.key}" && claude`; + // 构建包含自定义字段的命令 + let customEnv = ''; + if (customFieldsCount > 0) { + for (const [key, value] of Object.entries(customFields)) { + customEnv += `set "${key}=${value}" && `; + } + } + const winCmd = `${customEnv}${modelEnv}set "ANTHROPIC_BASE_URL=${provider.baseUrl}" && set "ANTHROPIC_AUTH_TOKEN=${provider.key}" && claude`; args = ['/c', winCmd]; useShell = false; } else { @@ -627,7 +653,14 @@ export class CommandExecutor { if (provider.defaultOpusModel) { modelExport += `export ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"; `; } - const exportCmd = `${modelExport}export ANTHROPIC_BASE_URL="${provider.baseUrl}"; export ANTHROPIC_AUTH_TOKEN="${provider.key}"; claude`; + // 构建包含自定义字段的命令 + let customExport = ''; + if (customFieldsCount > 0) { + for (const [key, value] of Object.entries(customFields)) { + customExport += `export ${key}="${value}"; `; + } + } + const exportCmd = `${customExport}${modelExport}export ANTHROPIC_BASE_URL="${provider.baseUrl}"; export ANTHROPIC_AUTH_TOKEN="${provider.key}"; claude`; args = ['-l', '-i', '-c', exportCmd]; useShell = false; } @@ -665,6 +698,12 @@ export class CommandExecutor { if (provider.defaultOpusModel) { console.log(` $env:ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"`); } + // 显示自定义字段的 PowerShell 命令 + if (customFieldsCount > 0) { + for (const [key, value] of Object.entries(customFields)) { + console.log(` $env:${key}="${value}"`); + } + } console.log(` claude`); console.log(`\n🔍 当前 PATH 包含的目录:`); const paths = (process.env.PATH || '').split(process.platform === 'win32' ? ';' : ':'); diff --git a/src/utils/provider-utils.ts b/src/utils/provider-utils.ts new file mode 100644 index 0000000..e35e7c7 --- /dev/null +++ b/src/utils/provider-utils.ts @@ -0,0 +1,65 @@ +import type { Provider } from '../types/index.js'; + +/** + * Provider 工具类 + * 提供 Provider 自定义字段的提取和环境变量设置功能 + */ +export class ProviderUtils { + /** 标准字段列表,这些字段不会被处理为自定义字段 */ + private static readonly STANDARD_FIELDS = [ + 'name', + 'baseUrl', + 'key', + 'default', + 'proxy', + 'defaultHaikuModel', + 'defaultSonnetModel', + 'defaultOpusModel', + ]; + + /** + * 提取 Provider 中的自定义字段 + * 返回键值对,支持字符串和数字类型(数字会被转换为字符串) + * 忽略 boolean、null、undefined、对象、数组等其他类型 + * + * @param provider Provider 对象 + * @returns 自定义字段键值对 + */ + static extractCustomFields(provider: Provider): Record { + const customFields: Record = {}; + + for (const [key, value] of Object.entries(provider)) { + // 跳过标准字段 + if (this.STANDARD_FIELDS.includes(key)) { + continue; + } + + // 支持字符串类型和数字类型 + if (typeof value === 'string' && value.length > 0) { + customFields[key] = value; + } else if (typeof value === 'number') { + customFields[key] = String(value); + } + } + + return customFields; + } + + /** + * 将自定义字段设置为环境变量 + * + * @param customFields 自定义字段键值对 + * @returns 已设置的环境变量数量 + */ + static applyCustomFieldsToEnv(customFields: Record): number { + let count = 0; + + for (const [key, value] of Object.entries(customFields)) { + // 自定义字段名直接用作环境变量名,不做任何转换 + process.env[key] = value; + count++; + } + + return count; + } +} From c7422e7ce18a42bcfbe83323b3928ca89cccb72a Mon Sep 17 00:00:00 2001 From: fukyor <3273228775@qq.com> Date: Tue, 10 Mar 2026 16:22:14 +0800 Subject: [PATCH 3/4] codex --- .gitignore | 1 + AGENTS.md | 25 ++ CLAUDE.md | 25 ++ plan.md | 49 ++++ providers.example.json | 5 +- src/cli/cli-parser.ts | 23 +- src/commands/command-executor.ts | 376 +++++++++++++++++++------------ src/core/config-manager.ts | 11 +- src/types/index.ts | 5 +- src/ui/cli-interface.ts | 78 ++++--- src/ui/output-formatter.ts | 28 ++- src/utils/platform-utils.ts | 108 +++++++++ 12 files changed, 538 insertions(+), 196 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 plan.md diff --git a/.gitignore b/.gitignore index 51e8c0a..24b0c23 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,4 @@ test-*.json # .claude .claude/** +.gitnexus diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7b085e1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ + +# GitNexus MCP + +This project is indexed by GitNexus as **switch-claude-cli** (264 symbols, 637 relationships, 21 execution flows). + +## Always Start Here + +1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness +2. **Match your task to a skill below** and **read that skill file** +3. **Follow the skill's workflow and checklist** + +> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. + +## Skills + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7b085e1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,25 @@ + +# GitNexus MCP + +This project is indexed by GitNexus as **switch-claude-cli** (264 symbols, 637 relationships, 21 execution flows). + +## Always Start Here + +1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness +2. **Match your task to a skill below** and **read that skill file** +3. **Follow the skill's workflow and checklist** + +> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. + +## Skills + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + \ No newline at end of file diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..a57daaa --- /dev/null +++ b/plan.md @@ -0,0 +1,49 @@ +# 支持 codex 启动功能方案 + +通过增加 `--codex` (或 `-x`) 参数启动 Codex,并根据原有架构调整环境变量映射与可执行文件调用逻辑。 +同时移除现有的 API 自动检测验证。 + +## 用户审核 + +这是一次平滑的能力增强。主要变更在于执行时通过判断 `options.codex`,决定使用 `claude` 的变量和命令还是 `codex` 的变量和命令,这使得工具能够更加通用。 + +## Proposed Changes + +### 1. [src/types/index.ts](file:///e:/ProgramFiles/nvm/v22.16.0/node_modules/switch-claude-cli/src/types/index.ts) + +在 [CliOptions](file:///e:/ProgramFiles/nvm/v22.16.0/node_modules/switch-claude-cli/src/types/index.ts#46-72) 中增加 `codex?: boolean;`。 + +### 2. [src/cli/cli-parser.ts](file:///e:/ProgramFiles/nvm/v22.16.0/node_modules/switch-claude-cli/src/cli/cli-parser.ts) + +- 识别 `-x` 及 `--codex` 标志,当传入时设置 `options.codex = true;`。 +- 修改相应的使用说明 [getUsage()](file:///e:/ProgramFiles/nvm/v22.16.0/node_modules/switch-claude-cli/src/cli/cli-parser.ts#269-285) 以提示此参数:`switch-claude --codex`。 + +### 3. [src/utils/platform-utils.ts](file:///e:/ProgramFiles/nvm/v22.16.0/node_modules/switch-claude-cli/src/utils/platform-utils.ts) + +- 增加 `findCodexCommand()` 和 `getCommonCodexPaths()` 方法,查找逻辑完全参考目前的 [findClaudeCommand](file:///e:/ProgramFiles/nvm/v22.16.0/node_modules/switch-claude-cli/src/utils/platform-utils.ts#43-121)。其中涉及的文件名包括 `codex.exe`, `codex.cmd` 等。 + +### 4. 数据结构增强 ([src/types/index.ts](file:///e:/ProgramFiles/nvm/v22.16.0/node_modules/switch-claude-cli/src/types/index.ts)) + +- 在 [Provider](file:///e:/ProgramFiles/nvm/v22.16.0/node_modules/switch-claude-cli/src/types/index.ts#5-23) 接口中,增加用于标识能否作为 Codex 提供商及相关属性(例如:`isCodex?: boolean`、`defaultCodexModel?: string` 等)。这样我们可以使用同样的 [providers.json](file:///c:/Users/Administrator/.switch-claude/providers.json) 文件来管理所有服务。 + +### 5. [src/commands/command-executor.ts](file:///e:/ProgramFiles/nvm/v22.16.0/node_modules/switch-claude-cli/src/commands/command-executor.ts)与交互 + +- **配置持久化与新增隔离**:当通过 `--codex --add` 交互式新增提供商时,自动设置 `isCodex: true` 以便与普通的 Claude 配置区分开并保存至 [providers.json](file:///c:/Users/Administrator/.switch-claude/providers.json) 之中。 +- **移除自动检测**:修改 [executeMainFlow](file:///e:/ProgramFiles/nvm/v22.16.0/node_modules/switch-claude-cli/src/commands/command-executor.ts#235-438) 逻辑,去除原来的并发检测逻辑,默认以跳过检测的方式(类似原先的 `--no-check`)进行提供商选择和启动。 +- **配置过滤加载**:加载完整 [providers.json](file:///c:/Users/Administrator/.switch-claude/providers.json) 之后,在进入列表打印或提供商选择逻辑前: + - 如果命令行带着 `--codex`,则仅保留并在终端打印配置中 `isCodex === true` 的提供商。 + - 如果未带参数(原始 Claude 模式),则仅保留并在终端打印没有 `isCodex` 或 `isCodex` 为 false 的正常 Claude 候选提供商列表。 +- 修改或重构 [launchClaude](file:///e:/ProgramFiles/nvm/v22.16.0/node_modules/switch-claude-cli/src/commands/command-executor.ts#479-745) 方法为更加多态的方法(例如 `launchApp(provider, envOnly, options)`)。 +- 对于环境变量的配置: + - **Claude** 保持不变。 + - **Codex**:注入代理变量。将 `provider.baseUrl` 映射到 `OPENAI_BASE_URL`;将 `provider.key` 映射到 `OPENAI_API_KEY`。并且原样附加提取出的自定义字段。不注入 `ANTHROPIC_DEFAULT_*_MODEL` 等变量。 +- 对于启动进程与降级策略: + - 判断应调用的目标路径为 `claudePath` 还是 `codexPath`。 + - 在无法直接找到路径时构建的 fallback shell 字符串(涉及 [set](file:///e:/ProgramFiles/nvm/v22.16.0/node_modules/switch-claude-cli/src/commands/command-executor.ts#1188-1195) / [export](file:///e:/ProgramFiles/nvm/v22.16.0/node_modules/switch-claude-cli/src/core/config-manager.ts#97-117) 命令)也分岔对应两套环境处理逻辑,最后执行的指令为 `claude` 或者 `codex`。 + +## Verification Plan + +### 局部功能测试 + +修改完成后,可以对 CLI 运行如下参数: +`npx tsx src/index.ts -e --codex 1` 或 `npx tsx src/index.ts -x`,然后检查它最后输出的环境变量清单和准备启动的命令,验证是否从 Claude 平滑切换到了 Codex 的变量 (`OPENAI_BASE_URL`/`OPENAI_API_KEY`)以及最后执行调用的 `codex`。 \ No newline at end of file diff --git a/providers.example.json b/providers.example.json index 9ae9eb1..c7536fc 100644 --- a/providers.example.json +++ b/providers.example.json @@ -12,7 +12,8 @@ "name": "Provider2", "baseUrl": "https://api.example2.com", "key": "cr_your-api-key-here-replace-with-real-key", - "default": false + "default": false, + "isCodex": true }, { "name": "Provider3", @@ -20,4 +21,4 @@ "key": "custom_your-api-key-here-replace-with-real-key", "default": false } -] +] \ No newline at end of file diff --git a/src/cli/cli-parser.ts b/src/cli/cli-parser.ts index 5a86caa..3897d4a 100644 --- a/src/cli/cli-parser.ts +++ b/src/cli/cli-parser.ts @@ -55,8 +55,14 @@ export class CliParser { options.envOnly = true; break; - case '--no-check': - options.noCheck = true; + case '-c': + case '--check': + options.check = true; + break; + + case '-x': + case '--codex': + options.codex = true; break; case '--add': @@ -274,10 +280,15 @@ export class CliParser { 用法: switch-claude [选项] [provider编号] 常用命令: - switch-claude # 交互式选择 provider - switch-claude 1 # 直接选择编号为 1 的 provider - switch-claude --add # 添加新的 provider - switch-claude --list # 列出所有 providers + switch-claude # 交互式选择 Claude provider + switch-claude 1 # 直接选择编号为 1 的 Claude provider + switch-claude --codex # 交互式选择 Codex provider + switch-claude --codex 1 # 直接选择编号为 1 的 Codex provider + switch-claude --add # 添加新的 Claude provider + switch-claude --codex --add # 添加新的 Codex provider + switch-claude --list # 列出所有 Claude providers + switch-claude -c # 交互式选择前强制检测可用性 + switch-claude --codex --list # 列出所有 Codex providers 使用 --help 查看完整帮助信息 `; diff --git a/src/commands/command-executor.ts b/src/commands/command-executor.ts index 5b327ae..c824c53 100644 --- a/src/commands/command-executor.ts +++ b/src/commands/command-executor.ts @@ -79,6 +79,18 @@ export class CommandExecutor { const providers = loadResult.result; + // 根据 --codex 参数过滤 Provider + const filteredProviders = options.codex + ? providers.filter((p) => p.isCodex === true) + : providers.filter((p) => !p.isCodex); + + if (filteredProviders.length === 0) { + const mode = options.codex ? 'Codex' : 'Claude'; + return this.createErrorResult( + `没有配置任何 ${mode} Provider。请使用 ${options.codex ? '--codex ' : ''}--add 添加。` + ); + } + // 处理不需要显示provider列表的命令 if (options.export) { return this.executeExportCommand(providers, options.exportPath); @@ -112,8 +124,9 @@ export class CommandExecutor { !options.clearDefault); if (shouldShowList) { - console.log('📋 配置的 Provider 列表:\n'); - providers.forEach((p, i) => { + const mode = options.codex ? 'Codex' : 'Claude'; + console.log(`📋 配置的 ${mode} Provider 列表:\n`); + filteredProviders.forEach((p, i) => { console.log(`[${i + 1}] ${p.name} (${p.baseUrl})${p.default ? ' ⭐默认' : ''}`); }); } @@ -124,27 +137,27 @@ export class CommandExecutor { } if (options.add) { - return this.executeAddCommand(providers); + return this.executeAddCommand(providers, options.codex || false); } if (options.edit && options.providerIndex) { - return this.executeEditCommand(providers, options.providerIndex); + return this.executeEditCommand(filteredProviders, options.providerIndex); } if (options.remove && options.providerIndex) { - return this.executeRemoveCommand(providers, options.providerIndex); + return this.executeRemoveCommand(providers, options.providerIndex, filteredProviders); } if (options.setDefault && options.providerIndex) { - return this.executeSetDefaultCommand(providers, options.providerIndex); + return this.executeSetDefaultCommand(filteredProviders, options.providerIndex); } if (options.clearDefault) { - return this.executeClearDefaultCommand(providers); + return this.executeClearDefaultCommand(filteredProviders); } // 主要功能:批量检测并选择Provider - return await this.executeMainFlow(providers, providerIndex, options); + return await this.executeMainFlow(filteredProviders, providerIndex, options); } catch (error) { return this.createErrorResult(error instanceof Error ? error.message : String(error)); } @@ -242,8 +255,8 @@ export class CommandExecutor { ): Promise { // 注意:Provider列表已经在调用此方法之前显示了 - // 如果指定了 providerIndex 或 --no-check,直接使用不检测 - if ((providerIndex !== undefined || options.noCheck) && !options.refresh) { + // 如果指定了 providerIndex,或没有开启 --check 参数,且不刷新,则直接使用不检测 + if ((providerIndex !== undefined || !options.check) && !options.refresh) { return this.executeDirectSelection(providers, providerIndex, options); } @@ -432,8 +445,8 @@ export class CommandExecutor { } } - // 7. 启动Claude - return this.launchClaude(selected, options.envOnly); + // 7. 启动应用 + return this.launchApp(selected, options.envOnly, options.codex || false); } /** @@ -472,16 +485,17 @@ export class CommandExecutor { } } - // 启动 Claude - return this.launchClaude(selected, options.envOnly); + // 启动应用 + return this.launchApp(selected, options.envOnly, options.codex || false); } /** - * 启动 Claude + * 启动应用(Claude Code 或 Codex) */ - private async launchClaude( + private async launchApp( provider: Provider & { testResult?: TestResult }, - envOnly: boolean = false + envOnly: boolean = false, + isCodex: boolean = false ): Promise { // 设置代理环境变量(如果配置了) if (provider.proxy) { @@ -493,30 +507,65 @@ export class CommandExecutor { process.env.https_proxy = normalizedProxy; } - // 设置环境变量 - 使用原版的环境变量名称 - process.env.ANTHROPIC_BASE_URL = provider.baseUrl; - process.env.ANTHROPIC_AUTH_TOKEN = provider.key; + // 根据模式设置不同的环境变量 + if (isCodex) { + // Codex 模式:使用 OpenAI 环境变量 + process.env.OPENAI_BASE_URL = provider.baseUrl; + process.env.OPENAI_API_KEY = provider.key; + + // 清除可能存在的 Anthropic 环境变量(避免冲突) + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL; + delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL; + delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL; + } else { + // Claude 模式:使用 Anthropic 环境变量 + process.env.ANTHROPIC_BASE_URL = provider.baseUrl; + process.env.ANTHROPIC_AUTH_TOKEN = provider.key; - // 设置模型环境变量 - if (provider.defaultHaikuModel) { - process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = provider.defaultHaikuModel; - } - if (provider.defaultSonnetModel) { - process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = provider.defaultSonnetModel; - } - if (provider.defaultOpusModel) { - process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = provider.defaultOpusModel; + // 设置模型环境变量 + if (provider.defaultHaikuModel) { + process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = provider.defaultHaikuModel; + } + if (provider.defaultSonnetModel) { + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = provider.defaultSonnetModel; + } + if (provider.defaultOpusModel) { + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = provider.defaultOpusModel; + } + + // 清除可能存在的 OpenAI 环境变量(避免冲突) + delete process.env.OPENAI_BASE_URL; + delete process.env.OPENAI_API_KEY; } // 提取并应用自定义字段为环境变量 const customFields = ProviderUtils.extractCustomFields(provider); const customFieldsCount = ProviderUtils.applyCustomFieldsToEnv(customFields); + const appName = isCodex ? 'Codex' : 'Claude Code'; console.log(`\n✅ 已切换到: ${provider.name} (${provider.baseUrl})`); console.log(`\n🔧 环境变量已设置:`); - console.log(` ANTHROPIC_BASE_URL=${provider.baseUrl}`); - console.log(` ANTHROPIC_AUTH_TOKEN=${provider.key.slice(0, 12)}...`); - + + if (isCodex) { + console.log(` OPENAI_BASE_URL=${provider.baseUrl}`); + console.log(` OPENAI_API_KEY=${provider.key.slice(0, 12)}...`); + } else { + console.log(` ANTHROPIC_BASE_URL=${provider.baseUrl}`); + console.log(` ANTHROPIC_AUTH_TOKEN=${provider.key.slice(0, 12)}...`); + + if (provider.defaultHaikuModel) { + console.log(` ANTHROPIC_DEFAULT_HAIKU_MODEL=${provider.defaultHaikuModel}`); + } + if (provider.defaultSonnetModel) { + console.log(` ANTHROPIC_DEFAULT_SONNET_MODEL=${provider.defaultSonnetModel}`); + } + if (provider.defaultOpusModel) { + console.log(` ANTHROPIC_DEFAULT_OPUS_MODEL=${provider.defaultOpusModel}`); + } + } + if (provider.proxy) { // 显示标准化后的代理地址 const normalizedProxy = normalizeProxyUrl(provider.proxy); @@ -524,16 +573,6 @@ export class CommandExecutor { console.log(` HTTPS_PROXY=${normalizedProxy}`); } - if (provider.defaultHaikuModel) { - console.log(` ANTHROPIC_DEFAULT_HAIKU_MODEL=${provider.defaultHaikuModel}`); - } - if (provider.defaultSonnetModel) { - console.log(` ANTHROPIC_DEFAULT_SONNET_MODEL=${provider.defaultSonnetModel}`); - } - if (provider.defaultOpusModel) { - console.log(` ANTHROPIC_DEFAULT_OPUS_MODEL=${provider.defaultOpusModel}`); - } - // 显示自定义字段 if (customFieldsCount > 0) { console.log(`\n 自定义字段:`); @@ -545,124 +584,157 @@ export class CommandExecutor { const responseTime = provider.testResult?.responseTime ?? null; StatsManager.recordProviderUse(provider.name, true, responseTime); + const commandName = isCodex ? 'codex' : 'claude'; + if (envOnly) { - console.log(`\n📋 环境变量设置完成!你可以手动运行 claude 命令`); + console.log(`\n📋 环境变量设置完成!你可以手动运行 ${commandName} 命令`); console.log(`\n💡 在当前会话中,你也可以使用这些命令:`); if (provider.proxy) { const normalizedProxy = normalizeProxyUrl(provider.proxy); console.log(` $env:HTTP_PROXY="${normalizedProxy}"`); console.log(` $env:HTTPS_PROXY="${normalizedProxy}"`); } - console.log(` $env:ANTHROPIC_BASE_URL="${provider.baseUrl}"`); - console.log(` $env:ANTHROPIC_AUTH_TOKEN="${provider.key}"`); - if (provider.defaultHaikuModel) { - console.log(` $env:ANTHROPIC_DEFAULT_HAIKU_MODEL="${provider.defaultHaikuModel}"`); - } - if (provider.defaultSonnetModel) { - console.log(` $env:ANTHROPIC_DEFAULT_SONNET_MODEL="${provider.defaultSonnetModel}"`); - } - if (provider.defaultOpusModel) { - console.log(` $env:ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"`); + + if (isCodex) { + console.log(` $env:OPENAI_BASE_URL="${provider.baseUrl}"`); + console.log(` $env:OPENAI_API_KEY="${provider.key}"`); + } else { + console.log(` $env:ANTHROPIC_BASE_URL="${provider.baseUrl}"`); + console.log(` $env:ANTHROPIC_AUTH_TOKEN="${provider.key}"`); + if (provider.defaultHaikuModel) { + console.log(` $env:ANTHROPIC_DEFAULT_HAIKU_MODEL="${provider.defaultHaikuModel}"`); + } + if (provider.defaultSonnetModel) { + console.log(` $env:ANTHROPIC_DEFAULT_SONNET_MODEL="${provider.defaultSonnetModel}"`); + } + if (provider.defaultOpusModel) { + console.log(` $env:ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"`); + } } + // 显示自定义字段的 PowerShell 命令 if (customFieldsCount > 0) { for (const [key, value] of Object.entries(customFields)) { console.log(` $env:${key}="${value}"`); } } - console.log(` claude`); + console.log(` ${commandName}`); return { success: true, message: '', exitCode: 0 }; } - // 尝试启动 claude - console.log(`\n🚀 正在启动 Claude Code...`); + // 尝试启动应用 + console.log(`\n🚀 正在启动 ${appName}...`); // 优先查找绝对路径,其次回退到用户登录 shell 执行 - const foundPath = await PlatformUtils.findClaudeCommand(); + const foundPath = isCodex + ? await PlatformUtils.findCodexCommand() + : await PlatformUtils.findClaudeCommand(); const isAbs = foundPath ? PlatformUtils.getPlatform() === 'windows' ? true : path.isAbsolute(foundPath) : false; - const claudePath = isAbs ? foundPath : null; - if (claudePath) { - console.log(`🔍 使用 claude 命令路径: ${claudePath}`); + const appPath = isAbs ? foundPath : null; + if (appPath) { + console.log(`🔍 使用 ${commandName} 命令路径: ${appPath}`); } else { - console.log('🔍 使用 claude 命令路径: 未解析到二进制,尝试通过登录 shell 执行'); + console.log(`🔍 使用 ${commandName} 命令路径: 未解析到二进制,尝试通过登录 shell 执行`); } try { // 根据是否找到绝对路径,决定启动方式 - let command = claudePath || ''; + let command = appPath || ''; let args: string[] = []; let useShell = false; - if (claudePath) { + if (appPath) { const platform = PlatformUtils.getPlatform(); if (platform === 'windows') { - const ext = path.extname(claudePath).toLowerCase(); + const ext = path.extname(appPath).toLowerCase(); if (ext === '.cmd' || ext === '.bat') { command = 'cmd.exe'; - args = ['/c', 'claude']; + args = ['/c', commandName]; } else if (ext === '.ps1') { command = 'powershell.exe'; - args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudePath]; + args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', appPath]; } } else { - command = claudePath; + command = appPath; } } else { - // 回退:通过用户的默认 shell 以“登录 + 交互”方式执行,确保加载别名/函数 + // 回退:通过用户的默认 shell 以”登录 + 交互”方式执行,确保加载别名/函数 const userShell = PlatformUtils.getUserShell(); const platform = PlatformUtils.getPlatform(); if (platform === 'windows') { // 在 Windows 上使用 cmd 执行(尽量避免对 PowerShell 的依赖) command = userShell; // 通常为 cmd.exe - // 构建包含模型环境变量的命令 - let modelEnv = ''; - if (provider.defaultHaikuModel) { - modelEnv += `set "ANTHROPIC_DEFAULT_HAIKU_MODEL=${provider.defaultHaikuModel}" && `; - } - if (provider.defaultSonnetModel) { - modelEnv += `set "ANTHROPIC_DEFAULT_SONNET_MODEL=${provider.defaultSonnetModel}" && `; - } - if (provider.defaultOpusModel) { - modelEnv += `set "ANTHROPIC_DEFAULT_OPUS_MODEL=${provider.defaultOpusModel}" && `; - } - // 构建包含自定义字段的命令 - let customEnv = ''; - if (customFieldsCount > 0) { - for (const [key, value] of Object.entries(customFields)) { - customEnv += `set "${key}=${value}" && `; + + if (isCodex) { + // Codex 模式:使用 OpenAI 环境变量 + let customEnv = ''; + if (customFieldsCount > 0) { + for (const [key, value] of Object.entries(customFields)) { + customEnv += `set “${key}=${value}” && `; + } + } + const winCmd = `${customEnv}set “OPENAI_BASE_URL=${provider.baseUrl}” && set “OPENAI_API_KEY=${provider.key}” && ${commandName}`; + args = ['/c', winCmd]; + } else { + // Claude 模式:使用 Anthropic 环境变量 + let modelEnv = ''; + if (provider.defaultHaikuModel) { + modelEnv += `set “ANTHROPIC_DEFAULT_HAIKU_MODEL=${provider.defaultHaikuModel}” && `; + } + if (provider.defaultSonnetModel) { + modelEnv += `set “ANTHROPIC_DEFAULT_SONNET_MODEL=${provider.defaultSonnetModel}” && `; + } + if (provider.defaultOpusModel) { + modelEnv += `set “ANTHROPIC_DEFAULT_OPUS_MODEL=${provider.defaultOpusModel}” && `; + } + let customEnv = ''; + if (customFieldsCount > 0) { + for (const [key, value] of Object.entries(customFields)) { + customEnv += `set “${key}=${value}” && `; + } } + const winCmd = `${customEnv}${modelEnv}set “ANTHROPIC_BASE_URL=${provider.baseUrl}” && set “ANTHROPIC_AUTH_TOKEN=${provider.key}” && ${commandName}`; + args = ['/c', winCmd]; } - const winCmd = `${customEnv}${modelEnv}set "ANTHROPIC_BASE_URL=${provider.baseUrl}" && set "ANTHROPIC_AUTH_TOKEN=${provider.key}" && claude`; - args = ['/c', winCmd]; - useShell = false; } else { command = userShell; // -l 登录 shell(读取 zprofile/profile),-i 交互式(读取 zshrc/bashrc),-c 执行命令 - // 构建包含模型环境变量的命令 - let modelExport = ''; - if (provider.defaultHaikuModel) { - modelExport += `export ANTHROPIC_DEFAULT_HAIKU_MODEL="${provider.defaultHaikuModel}"; `; - } - if (provider.defaultSonnetModel) { - modelExport += `export ANTHROPIC_DEFAULT_SONNET_MODEL="${provider.defaultSonnetModel}"; `; - } - if (provider.defaultOpusModel) { - modelExport += `export ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"; `; - } - // 构建包含自定义字段的命令 - let customExport = ''; - if (customFieldsCount > 0) { - for (const [key, value] of Object.entries(customFields)) { - customExport += `export ${key}="${value}"; `; + + if (isCodex) { + // Codex 模式:使用 OpenAI 环境变量 + let customExport = ''; + if (customFieldsCount > 0) { + for (const [key, value] of Object.entries(customFields)) { + customExport += `export ${key}=”${value}”; `; + } + } + const exportCmd = `${customExport}export OPENAI_BASE_URL=”${provider.baseUrl}”; export OPENAI_API_KEY=”${provider.key}”; ${commandName}`; + args = ['-l', '-i', '-c', exportCmd]; + } else { + // Claude 模式:使用 Anthropic 环境变量 + let modelExport = ''; + if (provider.defaultHaikuModel) { + modelExport += `export ANTHROPIC_DEFAULT_HAIKU_MODEL=”${provider.defaultHaikuModel}”; `; + } + if (provider.defaultSonnetModel) { + modelExport += `export ANTHROPIC_DEFAULT_SONNET_MODEL=”${provider.defaultSonnetModel}”; `; + } + if (provider.defaultOpusModel) { + modelExport += `export ANTHROPIC_DEFAULT_OPUS_MODEL=”${provider.defaultOpusModel}”; `; } + let customExport = ''; + if (customFieldsCount > 0) { + for (const [key, value] of Object.entries(customFields)) { + customExport += `export ${key}=”${value}”; `; + } + } + const exportCmd = `${customExport}${modelExport}export ANTHROPIC_BASE_URL=”${provider.baseUrl}”; export ANTHROPIC_AUTH_TOKEN=”${provider.key}”; ${commandName}`; + args = ['-l', '-i', '-c', exportCmd]; } - const exportCmd = `${customExport}${modelExport}export ANTHROPIC_BASE_URL="${provider.baseUrl}"; export ANTHROPIC_AUTH_TOKEN="${provider.key}"; claude`; - args = ['-l', '-i', '-c', exportCmd]; - useShell = false; } console.log(`🔁 通过登录 shell 启动: ${command} ${args.join(' ')}`); @@ -672,73 +744,80 @@ export class CommandExecutor { delete childEnv.NODE_OPTIONS; delete (childEnv as Record).VSCODE_INSPECTOR_OPTIONS; - // 为 Claude Code 设置正确的 stdin 配置以支持交互 - const claude = spawn(command || 'claude', args, { + // 为应用设置正确的 stdin 配置以支持交互 + const childProcess = spawn(command || commandName, args, { stdio: ['inherit', 'inherit', 'inherit'], // 继承 stdin, stdout, stderr env: childEnv, shell: useShell, }); - claude.on('error', (error: unknown) => { + childProcess.on('error', (error: unknown) => { const err = error as { code?: string; message?: string }; if (err && err.code === 'ENOENT') { - console.error(`\n❌ 找不到 'claude' 命令!`); + console.error(`\n❌ 找不到 '${commandName}' 命令!`); console.log(`\n💡 解决方案:`); - console.log(` 1. 确保 Claude Code 已正确安装`); - console.log(` 2. 检查 claude 命令是否在 PATH 环境变量中`); - console.log(` 3. 或者手动设置环境变量后运行 claude:`); - console.log(` $env:ANTHROPIC_BASE_URL="${provider.baseUrl}"`); - console.log(` $env:ANTHROPIC_AUTH_TOKEN="${provider.key}"`); - if (provider.defaultHaikuModel) { - console.log(` $env:ANTHROPIC_DEFAULT_HAIKU_MODEL="${provider.defaultHaikuModel}"`); - } - if (provider.defaultSonnetModel) { - console.log(` $env:ANTHROPIC_DEFAULT_SONNET_MODEL="${provider.defaultSonnetModel}"`); - } - if (provider.defaultOpusModel) { - console.log(` $env:ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"`); + console.log(` 1. 确保 ${appName} 已正确安装`); + console.log(` 2. 检查 ${commandName} 命令是否在 PATH 环境变量中`); + console.log(` 3. 或者手动设置环境变量后运行 ${commandName}:`); + + if (isCodex) { + console.log(` $env:OPENAI_BASE_URL="${provider.baseUrl}"`); + console.log(` $env:OPENAI_API_KEY="${provider.key}"`); + } else { + console.log(` $env:ANTHROPIC_BASE_URL="${provider.baseUrl}"`); + console.log(` $env:ANTHROPIC_AUTH_TOKEN="${provider.key}"`); + if (provider.defaultHaikuModel) { + console.log(` $env:ANTHROPIC_DEFAULT_HAIKU_MODEL="${provider.defaultHaikuModel}"`); + } + if (provider.defaultSonnetModel) { + console.log(` $env:ANTHROPIC_DEFAULT_SONNET_MODEL="${provider.defaultSonnetModel}"`); + } + if (provider.defaultOpusModel) { + console.log(` $env:ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"`); + } } + // 显示自定义字段的 PowerShell 命令 if (customFieldsCount > 0) { for (const [key, value] of Object.entries(customFields)) { console.log(` $env:${key}="${value}"`); } } - console.log(` claude`); + console.log(` ${commandName}`); console.log(`\n🔍 当前 PATH 包含的目录:`); const paths = (process.env.PATH || '').split(process.platform === 'win32' ? ';' : ':'); paths.slice(0, 5).forEach((p) => console.log(` - ${p}`)); if (paths.length > 5) { console.log(` ... 还有 ${paths.length - 5} 个目录`); } - if (!claudePath) { - console.log('\n🔁 备用方案:你也可以运行 "switch-claude -e <编号>"'); - console.log(' 然后在你的终端手动输入 "claude" 启动。'); + if (!appPath) { + console.log(`\n🔁 备用方案:你也可以运行 "switch-claude ${isCodex ? '--codex ' : ''}-e <编号>"`); + console.log(` 然后在你的终端手动输入 "${commandName}" 启动。`); } } else { const msg = err && err.message ? err.message : String(error); - console.error(`\n❌ 启动 claude 时出错: ${msg}`); + console.error(`\n❌ 启动 ${commandName} 时出错: ${msg}`); } process.exit(1); }); - claude.on('exit', (code) => { + childProcess.on('exit', (code) => { if (code !== 0 && code !== null) { - console.log(`\n⚠️ Claude Code 退出,退出码: ${code}`); + console.log(`\n⚠️ ${appName} 退出,退出码: ${code}`); } process.exit(code || 0); }); - // 不返回结果,让程序继续运行等待Claude进程结束 - console.log('✅ Claude 已启动'); + // 不返回结果,让程序继续运行等待进程结束 + console.log(`✅ ${appName} 已启动`); - // 返回一个永不resolve的Promise,让程序等待Claude进程结束 + // 返回一个永不resolve的Promise,让程序等待进程结束 return new Promise(() => { - // 这个Promise永远不会resolve,程序会一直等待直到Claude进程退出并调用process.exit() + // 这个Promise永远不会resolve,程序会一直等待直到进程退出并调用process.exit() }); } catch (error) { return this.createErrorResult( - `启动 Claude 失败: ${error instanceof Error ? error.message : String(error)}` + `启动 ${commandName} 失败: ${error instanceof Error ? error.message : String(error)}` ); } } @@ -755,8 +834,8 @@ export class CommandExecutor { /** * 执行添加命令 */ - private async executeAddCommand(providers: Provider[]): Promise { - const newProvider = await CliInterface.addProvider(providers); + private async executeAddCommand(providers: Provider[], isCodex: boolean = false): Promise { + const newProvider = await CliInterface.addProvider(providers, isCodex); if (!newProvider) { return this.createErrorResult('添加操作已取消', 0); } @@ -824,33 +903,38 @@ export class CommandExecutor { * 执行删除命令 */ private async executeRemoveCommand( - providers: Provider[], - indexStr: string + allProviders: Provider[], + indexStr: string, + filteredProviders: Provider[] ): Promise { - // 不允许删除最后一个 Provider,避免保存时报“配置文件为空”且信息重复 - if (providers.length <= 1) { + // 不允许删除最后一个 Provider,避免保存时报”配置文件为空”且信息重复 + if (allProviders.length <= 1) { return this.createErrorResult( '无法删除:至少需要一个 provider(请先添加新的 provider 后再删除)' ); } - const validation = ValidationUtils.validateProviderIndex(indexStr, providers.length); + const validation = ValidationUtils.validateProviderIndex(indexStr, filteredProviders.length); if (!validation.valid) { return this.createErrorResult(validation.error || '无效索引'); } const index = validation.value!; - const provider = providers[index]!; + const provider = filteredProviders[index]!; const confirmed = await CliInterface.confirmRemoveProvider(provider); if (!confirmed) { return this.createErrorResult('删除操作已取消', 0); } - providers.splice(index, 1); + // 从所有 providers 中删除 + const allIndex = allProviders.findIndex(p => p.name === provider.name); + if (allIndex !== -1) { + allProviders.splice(allIndex, 1); + } const saveResult = await this.handleAsyncOperation( - () => this.configManager.saveProviders(providers), + () => this.configManager.saveProviders(allProviders), '保存配置失败' ); @@ -858,7 +942,7 @@ export class CommandExecutor { return this.createErrorResult(saveResult.error || '保存失败'); } - return this.createSuccessResult(`Provider "${provider.name}" 删除成功`); + return this.createSuccessResult(`Provider “${provider.name}” 删除成功`); } /** diff --git a/src/core/config-manager.ts b/src/core/config-manager.ts index 355eff1..3b64558 100644 --- a/src/core/config-manager.ts +++ b/src/core/config-manager.ts @@ -30,7 +30,16 @@ export class ConfigManager { try { const content = fs.readFileSync(this.configPath, 'utf-8'); - const providers = JSON.parse(content) as Provider[]; + let providers = JSON.parse(content) as Provider[]; + + // 自动清理字符串字段的尾随空格,防止解析错误 + providers = providers.map(p => ({ + ...p, + name: typeof p.name === 'string' ? p.name.trim() : p.name, + baseUrl: typeof p.baseUrl === 'string' ? p.baseUrl.trim() : p.baseUrl, + key: typeof p.key === 'string' ? p.key.trim() : p.key, + })); + // 验证配置 const errors = ValidationUtils.validateProviders(providers); diff --git a/src/types/index.ts b/src/types/index.ts index 86ff08d..2e343b3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,6 +19,8 @@ export interface Provider { defaultSonnetModel?: string; /** 默认 Opus 模型 */ defaultOpusModel?: string; + /** 是否为 Codex Provider(默认为 false,即 Claude Provider) */ + isCodex?: boolean; } export interface TestResult { @@ -64,7 +66,8 @@ export interface CliOptions { stats?: boolean; exportStats?: boolean; resetStats?: boolean; - noCheck?: boolean; + check?: boolean; + codex?: boolean; providerIndex?: string; exportPath?: string; importPath?: string; diff --git a/src/ui/cli-interface.ts b/src/ui/cli-interface.ts index 8dbc0c4..f83ca21 100644 --- a/src/ui/cli-interface.ts +++ b/src/ui/cli-interface.ts @@ -40,8 +40,9 @@ export class CliInterface { /** * 交互式添加Provider */ - static async addProvider(existingProviders: Provider[]): Promise { - console.log('\n🚀 添加新的 Provider\n'); + static async addProvider(existingProviders: Provider[], isCodex: boolean = false): Promise { + const mode = isCodex ? 'Codex' : 'Claude'; + console.log(`\n🚀 添加新的 ${mode} Provider\n`); const existingNames = existingProviders.map((p) => p.name); @@ -52,7 +53,7 @@ export class CliInterface { } try { - const answers = await inquirer.prompt([ + const questions: any[] = [ { type: 'input', name: 'name', @@ -105,25 +106,33 @@ export class CliInterface { message: '是否设置为默认 Provider?', default: existingProviders.length === 0, }, - { - type: 'input', - name: 'defaultHaikuModel', - message: '默认 Haiku 模型 (可选,留空使用默认):', - default: '', - }, - { - type: 'input', - name: 'defaultSonnetModel', - message: '默认 Sonnet 模型 (可选,留空使用默认):', - default: '', - }, - { - type: 'input', - name: 'defaultOpusModel', - message: '默认 Opus 模型 (可选,留空使用默认):', - default: '', - }, - ]); + ]; + + // 只有 Claude Provider 才需要模型配置 + if (!isCodex) { + questions.push( + { + type: 'input', + name: 'defaultHaikuModel', + message: '默认 Haiku 模型 (可选,留空使用默认):', + default: '', + }, + { + type: 'input', + name: 'defaultSonnetModel', + message: '默认 Sonnet 模型 (可选,留空使用默认):', + default: '', + }, + { + type: 'input', + name: 'defaultOpusModel', + message: '默认 Opus 模型 (可选,留空使用默认):', + default: '', + } + ); + } + + const answers = await inquirer.prompt(questions); const provider: Provider = { name: answers.name.trim(), @@ -132,20 +141,27 @@ export class CliInterface { default: answers.setAsDefault, }; + // 设置 isCodex 标志 + if (isCodex) { + provider.isCodex = true; + } + // 只有当用户输入了代理地址时才添加 proxy 字段 if (answers.proxy && answers.proxy.trim()) { provider.proxy = answers.proxy.trim(); } - // 只有当用户输入了模型名称时才添加对应字段 - if (answers.defaultHaikuModel && answers.defaultHaikuModel.trim()) { - provider.defaultHaikuModel = answers.defaultHaikuModel.trim(); - } - if (answers.defaultSonnetModel && answers.defaultSonnetModel.trim()) { - provider.defaultSonnetModel = answers.defaultSonnetModel.trim(); - } - if (answers.defaultOpusModel && answers.defaultOpusModel.trim()) { - provider.defaultOpusModel = answers.defaultOpusModel.trim(); + // 只有当用户输入了模型名称时才添加对应字段(仅 Claude) + if (!isCodex) { + if (answers.defaultHaikuModel && answers.defaultHaikuModel.trim()) { + provider.defaultHaikuModel = answers.defaultHaikuModel.trim(); + } + if (answers.defaultSonnetModel && answers.defaultSonnetModel.trim()) { + provider.defaultSonnetModel = answers.defaultSonnetModel.trim(); + } + if (answers.defaultOpusModel && answers.defaultOpusModel.trim()) { + provider.defaultOpusModel = answers.defaultOpusModel.trim(); + } } return provider; diff --git a/src/ui/output-formatter.ts b/src/ui/output-formatter.ts index 39562d9..2c24c88 100644 --- a/src/ui/output-formatter.ts +++ b/src/ui/output-formatter.ts @@ -86,11 +86,12 @@ export class OutputFormatter { 选项: -h, --help 显示帮助信息 -V, --version 显示版本信息并检查更新 + -x, --codex 使用 Codex 模式(默认为 Claude 模式) -r, --refresh 强制刷新缓存,重新检测所有 provider -v, --verbose 显示详细的调试信息 - -l, --list 只列出 providers 不启动 claude - -e, --env-only 只设置环境变量,不启动 claude - --no-check 跳过 API 可用性检测,直接使用 provider + -l, --list 只列出 providers 不启动应用 + -e, --env-only 只设置环境变量,不启动应用 + -c, --check 强制检测 API 可用性(默认跳过检测,直接使用 provider) --add 添加新的 provider --edit <编号> 编辑指定编号的 provider --remove <编号> 删除指定编号的 provider @@ -110,18 +111,27 @@ export class OutputFormatter { 编号 直接选择指定编号的 provider(自动跳过 API 检测) 示例: - switch-claude # 交互式选择 - switch-claude 1 # 直接选择编号为 1 的 provider(跳过检测) + # Claude 模式(默认) + switch-claude # 交互式选择 Claude provider + switch-claude 1 # 直接选择编号为 1 的 Claude provider + switch-claude --list # 列出所有 Claude providers + switch-claude --add # 添加新的 Claude provider + + # Codex 模式 + switch-claude --codex # 交互式选择 Codex provider + switch-claude --codex 1 # 直接选择编号为 1 的 Codex provider + switch-claude --codex --list # 列出所有 Codex providers + switch-claude --codex --add # 添加新的 Codex provider + + # 其他示例 switch-claude --refresh # 强制刷新缓存后选择 switch-claude -v 2 # 详细模式选择编号为 2 的 provider - switch-claude --no-check # 跳过检测,使用默认或交互选择 - switch-claude --list # 只列出所有 providers - switch-claude --add # 添加新的 provider + switch-claude --check # 强制检测 API 可用性 switch-claude --edit 2 # 编辑编号为 2 的 provider switch-claude --remove 2 # 删除编号为 2 的 provider switch-claude --set-default 1 # 设置编号为 1 的 provider 为默认 switch-claude --clear-default # 清除默认设置 - switch-claude -e 1 # 只设置环境变量,不启动 claude + switch-claude -e 1 # 只设置环境变量,不启动应用 switch-claude --export # 导出配置到带时间戳的文件 switch-claude --export my-config.json # 导出到指定文件 switch-claude --import backup.json # 导入配置(替换) diff --git a/src/utils/platform-utils.ts b/src/utils/platform-utils.ts index 9eb886d..061266f 100644 --- a/src/utils/platform-utils.ts +++ b/src/utils/platform-utils.ts @@ -119,6 +119,114 @@ export class PlatformUtils { return null; } + /** + * 查找 Codex 命令的路径 + */ + static async findCodexCommand(): Promise { + const platform = this.getPlatform(); + const { execSync } = await import('child_process'); + + // 1) 系统 PATH 中的可执行文件 + try { + const cmd = platform === 'windows' ? 'where codex' : 'which codex'; + const out = execSync(cmd, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 3000, + }).trim(); + if (out && out.startsWith('/') && out.includes('/')) return out; + } catch { + // ignore + } + + // 2) 用户默认 shell 的登录+交互环境 + if (platform !== 'windows') { + try { + const userShell = this.getUserShell(); + const probe = `${userShell} -l -i -c "alias codex || type -a codex || whence -a codex || which codex || command -v codex"`; + const out = execSync(probe, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 4000, + }).trim(); + + const lines = out.split('\n').map((l) => l.trim()).filter(Boolean); + for (const line of lines) { + const pathMatch = line.match(/(\/[^\s'"`]+)/); + if (pathMatch && pathMatch[1]) { + return pathMatch[1]; + } + + const aliasMatch = line.match(/^(?:alias\s+)?codex=(.*)$/); + if (aliasMatch) { + const rhsRaw = aliasMatch[1]!.trim(); + const firstToken = rhsRaw.split(/\s+/)[0] || ''; + const candidate = firstToken.replace(/^['"`]/, '').replace(/['"`]$/, ''); + if (candidate.startsWith('/')) { + return candidate; + } + } + } + } catch { + // ignore + } + } + + // 3) 常见安装路径 + const commonPaths = this.getCommonCodexPaths(); + const { existsSync } = await import('fs'); + + for (const path of commonPaths) { + if (existsSync(path)) { + return path; + } + } + + // 4) 环境变量 + const codexPath = process.env.CODEX_PATH; + if (codexPath && existsSync(codexPath)) { + return codexPath; + } + + return null; + } + + /** + * 获取常见的 Codex 安装路径 + */ + private static getCommonCodexPaths(): string[] { + const platform = this.getPlatform(); + + switch (platform) { + case 'windows': + return [ + 'C:\\Program Files\\Codex\\codex.exe', + 'C:\\Program Files (x86)\\Codex\\codex.exe', + `${process.env.USERPROFILE}\\AppData\\Local\\Codex\\codex.exe`, + `${process.env.USERPROFILE}\\AppData\\Roaming\\npm\\codex.cmd`, + 'codex.exe', + ]; + case 'macos': + return [ + '/Applications/Codex.app/Contents/MacOS/codex', + '/usr/local/bin/codex', + '/opt/homebrew/bin/codex', + `${process.env.HOME}/.local/bin/codex`, + '/usr/bin/codex', + ]; + case 'linux': + return [ + '/usr/local/bin/codex', + '/usr/bin/codex', + `${process.env.HOME}/.local/bin/codex`, + '/snap/bin/codex', + '/opt/codex/bin/codex', + ]; + default: + return ['/usr/local/bin/codex', '/usr/bin/codex']; + } + } + /** * 获取常见的Claude安装路径 */ From a23e4c0cc5c17691269c2b0f7eab2271e2281cba Mon Sep 17 00:00:00 2001 From: fukyor <3273228775@qq.com> Date: Tue, 5 May 2026 22:10:02 +0800 Subject: [PATCH 4/4] 1 --- AGENTS.md | 4 +- CLAUDE.md | 4 +- README.md | 69 ++- codextest/codex-config-file-write.test.ts | 58 ++ .../codex-config-write-regression.test.ts | 46 ++ codextest/codex-launch-flow.test.ts | 82 +++ codextest/launch-command-proxy-env.test.ts | 70 +++ prompt.md | 1 + run-codex-loop.sh | 27 + src/cli/cli-parser.ts | 9 +- src/commands/command-executor.ts | 580 +++++++++++------- src/core/config-manager.ts | 3 +- src/ui/cli-interface.ts | 12 +- src/ui/output-formatter.ts | 6 +- src/utils/codex-config-utils.ts | 154 +++++ src/utils/launch-env-utils.ts | 94 +++ src/utils/platform-utils.ts | 81 ++- src/utils/proxy-utils.ts | 2 +- test/codex-config-utils.test.ts | 107 ++++ test/launch-utils.test.ts | 94 +++ test/utils.test.ts | 19 +- 21 files changed, 1234 insertions(+), 288 deletions(-) create mode 100644 codextest/codex-config-file-write.test.ts create mode 100644 codextest/codex-config-write-regression.test.ts create mode 100644 codextest/codex-launch-flow.test.ts create mode 100644 codextest/launch-command-proxy-env.test.ts create mode 100644 prompt.md create mode 100644 run-codex-loop.sh create mode 100644 src/utils/codex-config-utils.ts create mode 100644 src/utils/launch-env-utils.ts create mode 100644 test/codex-config-utils.test.ts create mode 100644 test/launch-utils.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7b085e1..f9ecd71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus MCP -This project is indexed by GitNexus as **switch-claude-cli** (264 symbols, 637 relationships, 21 execution flows). +This project is indexed by GitNexus as **switch-claude-cli** (317 symbols, 785 relationships, 25 execution flows). ## Always Start Here @@ -22,4 +22,4 @@ This project is indexed by GitNexus as **switch-claude-cli** (264 symbols, 637 r | Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | | Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - \ No newline at end of file + diff --git a/CLAUDE.md b/CLAUDE.md index 7b085e1..f9ecd71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus MCP -This project is indexed by GitNexus as **switch-claude-cli** (264 symbols, 637 relationships, 21 execution flows). +This project is indexed by GitNexus as **switch-claude-cli** (317 symbols, 785 relationships, 25 execution flows). ## Always Start Here @@ -22,4 +22,4 @@ This project is indexed by GitNexus as **switch-claude-cli** (264 symbols, 637 r | Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | | Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - \ No newline at end of file + diff --git a/README.md b/README.md index 7ea88d8..f3530a5 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,6 @@ npm install -g switch-claude-cli ``` - ### 从源码安装 ```bash @@ -117,6 +116,7 @@ nano ~/.switch-claude/providers.json ``` **代理配置说明** 🌐: + - 如果某个 API 提供方需要通过代理访问(如 VPN),可以在配置中添加 `proxy` 字段 - `proxy` 格式:`http://代理地址:端口`(例如:`http://127.0.0.1:7897`) - 未配置 `proxy` 字段的 Provider 会直接连接,不使用代理 @@ -142,6 +142,7 @@ switch-claude --list <==> scl --list <==> ccc --list ``` **别名说明**: + - `switch-claude` - 完整命令,语义清晰 - `scl` - Switch CLaude 首字母缩写 - `ccc` - Choose Claude CLI 缩写 @@ -159,13 +160,29 @@ ccc # 直接选择编号为 1 的 provider scl 1 -# 只设置环境变量,不启动 claude +# 仅 Claude 模式:只设置环境变量,不启动 Claude Code scl -e 1 # 查看版本并检查更新 scl --version ``` +### Codex 模式 + +```bash +# 交互式选择 Codex provider +switch-claude --codex + +# 直接选择 Codex provider,并写入 ~/.codex/config.toml 后启动 codex +switch-claude --codex 1 +``` + +**说明**: + +- Codex 模式不再通过环境变量注入 `baseUrl` 和 `key` +- 工具会直接更新 `~/.codex/config.toml` 中 `[model_providers.x]` 下的 `name`、`base_url` 与 `experimental_bearer_token` +- `--env-only` 仅适用于 Claude 模式,不适用于 `--codex` + ### 检测和缓存 ```bash @@ -270,6 +287,7 @@ switch-claude --reset-stats - 🔄 **数据重置**:支持清空所有统计数据 **统计数据存储**: + - 统计数据存储在 `~/.switch-claude/usage-stats.json` - 数据会自动保存,无需手动操作 - 重装或升级时统计数据会保留 @@ -283,27 +301,27 @@ switch-claude --help ## 🔧 命令行选项 -| 选项 | 简写 | 描述 | -| ---------------------- | ---- | --------------------------------------- | -| `--help` | `-h` | 显示帮助信息 | -| `--version` | `-V` | 显示版本信息并检查更新 | -| `--refresh` | `-r` | 强制刷新缓存,重新检测所有 provider | -| `--verbose` | `-v` | 显示详细的调试信息 | -| `--list` | `-l` | 只列出 providers 不启动 claude | -| `--env-only` | `-e` | 只设置环境变量,不启动 claude | -| `--add` | | 添加新的 provider | -| `--remove <编号>` | | 删除指定编号的 provider | -| `--set-default <编号>` | | 设置指定编号的 provider 为默认 | -| `--clear-default` | | 清除默认 provider(每次都需要手动选择) | -| `--check-update` | | 手动检查版本更新 | -| `--export [文件名]` | | 导出配置到文件 | -| `--import <文件名>` | | 从文件导入配置 | -| `--merge` | | 与 --import 配合使用,合并而不是替换 | -| `--backup` | | 备份当前配置到系统目录 | -| `--list-backups` | | 列出所有备份文件 | -| `--stats` | | 显示使用统计信息 | -| `--export-stats [文件名]` | | 导出统计数据到文件 | -| `--reset-stats` | | 重置所有统计数据 | +| 选项 | 简写 | 描述 | +| ------------------------- | ---- | -------------------------------------------------- | +| `--help` | `-h` | 显示帮助信息 | +| `--version` | `-V` | 显示版本信息并检查更新 | +| `--refresh` | `-r` | 强制刷新缓存,重新检测所有 provider | +| `--verbose` | `-v` | 显示详细的调试信息 | +| `--list` | `-l` | 只列出 providers 不启动应用 | +| `--env-only` | `-e` | 仅 Claude 模式:只设置环境变量,不启动 Claude Code | +| `--add` | | 添加新的 provider | +| `--remove <编号>` | | 删除指定编号的 provider | +| `--set-default <编号>` | | 设置指定编号的 provider 为默认 | +| `--clear-default` | | 清除默认 provider(每次都需要手动选择) | +| `--check-update` | | 手动检查版本更新 | +| `--export [文件名]` | | 导出配置到文件 | +| `--import <文件名>` | | 从文件导入配置 | +| `--merge` | | 与 --import 配合使用,合并而不是替换 | +| `--backup` | | 备份当前配置到系统目录 | +| `--list-backups` | | 列出所有备份文件 | +| `--stats` | | 显示使用统计信息 | +| `--export-stats [文件名]` | | 导出统计数据到文件 | +| `--reset-stats` | | 重置所有统计数据 | ## 📁 配置文件位置 @@ -389,7 +407,7 @@ switch-claude switch-claude -v --refresh ``` -2. 只设置环境变量,手动运行 claude: +2. 只设置环境变量,手动运行 Claude Code: ```bash switch-claude -e 1 @@ -421,7 +439,7 @@ claude 1. **检查安装**:确保 Claude Code 已正确安装 2. **检查 PATH**:确保 claude 命令在系统 PATH 中 -3. **使用 --env-only**: +3. **使用 --env-only(仅 Claude 模式)**: ```bash switch-claude -e 1 @@ -529,7 +547,6 @@ A: 工具会自动提醒你更新!你也可以: A: 可以。删除 `cache.json` 不会影响功能,只是下次运行会重新检测。 - --- **项目地址**: [GitHub](https://github.com/yak33/switch-claude-cli) diff --git a/codextest/codex-config-file-write.test.ts b/codextest/codex-config-file-write.test.ts new file mode 100644 index 0000000..529e582 --- /dev/null +++ b/codextest/codex-config-file-write.test.ts @@ -0,0 +1,58 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { CODEX_PROVIDER_ID, writeCodexProviderConfig } from '../src/utils/codex-config-utils.js'; + +const createdDirs: string[] = []; + +afterEach(() => { + for (const dir of createdDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('codextest: codex-config 文件写入', () => { + it('会把 name/baseUrl/key 分别写入 name/base_url/experimental_bearer_token', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switch-claude-codex-')); + createdDirs.push(tempDir); + + const configPath = path.join(tempDir, '.codex', 'config.toml'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync( + configPath, + [ + 'model_provider = "x"', + '', + '[model_providers.x]', + 'name = "legacy-name"', + 'base_url = "https://legacy.example.com/v1"', + 'experimental_bearer_token = "legacy-token"', + 'wire_api = "responses"', + ].join('\n'), + 'utf-8' + ); + + const result = writeCodexProviderConfig( + { + name: '写入后的 Provider 名称', + baseUrl: 'https://written.example.com/v1', + key: 'sk-written-token', + }, + CODEX_PROVIDER_ID, + configPath + ); + + const updated = fs.readFileSync(configPath, 'utf-8'); + + expect(result.configPath).toBe(configPath); + expect(result.providerId).toBe(CODEX_PROVIDER_ID); + expect(result.createdSection).toBe(false); + expect(updated).toContain('name = "写入后的 Provider 名称"'); + expect(updated).toContain('base_url = "https://written.example.com/v1"'); + expect(updated).toContain('experimental_bearer_token = "sk-written-token"'); + expect(updated).toContain('wire_api = "responses"'); + }); +}); diff --git a/codextest/codex-config-write-regression.test.ts b/codextest/codex-config-write-regression.test.ts new file mode 100644 index 0000000..fcea2b3 --- /dev/null +++ b/codextest/codex-config-write-regression.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { + CODEX_PROVIDER_ID as DIST_CODEX_PROVIDER_ID, + updateCodexConfigToml as updateDistCodexConfigToml, +} from '../dist/utils/codex-config-utils.js'; +import { + CODEX_PROVIDER_ID as SRC_CODEX_PROVIDER_ID, + updateCodexConfigToml as updateSrcCodexConfigToml, +} from '../src/utils/codex-config-utils.js'; + +describe('codextest: codex-config 写入回归', () => { + it.each([ + { + label: 'src', + providerId: SRC_CODEX_PROVIDER_ID, + updateCodexConfigToml: updateSrcCodexConfigToml, + }, + { + label: 'dist', + providerId: DIST_CODEX_PROVIDER_ID, + updateCodexConfigToml: updateDistCodexConfigToml, + }, + ])('$label 会把 name/baseUrl/key 分别写入正确的 Codex 字段', ({ providerId, updateCodexConfigToml }) => { + const toml = [ + 'model_provider = "x"', + '', + '[model_providers.x]', + 'name = "legacy-name"', + 'base_url = "https://legacy.example.com/v1"', + 'experimental_bearer_token = "legacy-token"', + 'wire_api = "responses"', + ].join('\n'); + + const updated = updateCodexConfigToml(toml, providerId, { + name: '目标 Provider', + baseUrl: 'https://target.example.com/v1', + key: 'sk-test-token', + }); + + expect(updated).toContain('name = "目标 Provider"'); + expect(updated).toContain('base_url = "https://target.example.com/v1"'); + expect(updated).toContain('experimental_bearer_token = "sk-test-token"'); + expect(updated).toContain('wire_api = "responses"'); + }); +}); diff --git a/codextest/codex-launch-flow.test.ts b/codextest/codex-launch-flow.test.ts new file mode 100644 index 0000000..5109fea --- /dev/null +++ b/codextest/codex-launch-flow.test.ts @@ -0,0 +1,82 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CommandExecutor } from '../src/commands/command-executor.js'; +import { StatsManager } from '../src/core/stats-manager.js'; + +const createdDirs: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + + for (const dir of createdDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('codextest: codex 启动写入链路', () => { + it('会在启动 codex 前把 name/baseUrl/key 直接写入 ~/.codex/config.toml', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switch-claude-codex-launch-')); + createdDirs.push(tempDir); + + vi.spyOn(os, 'homedir').mockReturnValue(tempDir); + vi.spyOn(StatsManager, 'recordProviderUse').mockImplementation(() => undefined); + + const configPath = path.join(tempDir, '.codex', 'config.toml'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync( + configPath, + [ + 'model_provider = "x"', + '', + '[model_providers.x]', + 'name = "legacy-name"', + 'base_url = "https://legacy.example.com/v1"', + 'experimental_bearer_token = "legacy-token"', + 'wire_api = "responses"', + ].join('\n'), + 'utf-8' + ); + + const executor = new CommandExecutor() as any; + const launchCommandSpy = vi.fn().mockResolvedValue({ success: true, exitCode: 0 }); + executor.launchCommand = launchCommandSpy; + + const result = await executor.launchCodexApp( + { + name: 'Codex 测试 Provider', + baseUrl: 'https://selected.example.com/v1', + key: 'sk-selected-token', + proxy: '127.0.0.1:7897', + isCodex: true, + }, + false + ); + + const updated = fs.readFileSync(configPath, 'utf-8'); + + expect(result).toEqual({ success: true, exitCode: 0 }); + expect(launchCommandSpy).toHaveBeenCalledTimes(1); + expect(launchCommandSpy).toHaveBeenCalledWith( + expect.objectContaining({ + appName: 'Codex', + commandName: 'codex', + launchEnvEntries: expect.arrayContaining([ + { key: 'HTTP_PROXY', value: 'http://127.0.0.1:7897' }, + { key: 'HTTPS_PROXY', value: 'http://127.0.0.1:7897' }, + { key: 'ALL_PROXY', value: 'http://127.0.0.1:7897' }, + { key: 'http_proxy', value: 'http://127.0.0.1:7897' }, + { key: 'https_proxy', value: 'http://127.0.0.1:7897' }, + { key: 'all_proxy', value: 'http://127.0.0.1:7897' }, + ]), + }) + ); + expect(updated).toContain('name = "Codex 测试 Provider"'); + expect(updated).toContain('base_url = "https://selected.example.com/v1"'); + expect(updated).toContain('experimental_bearer_token = "sk-selected-token"'); + expect(updated).toContain('wire_api = "responses"'); + }); +}); diff --git a/codextest/launch-command-proxy-env.test.ts b/codextest/launch-command-proxy-env.test.ts new file mode 100644 index 0000000..08c1423 --- /dev/null +++ b/codextest/launch-command-proxy-env.test.ts @@ -0,0 +1,70 @@ +import { EventEmitter } from 'node:events'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unmock('child_process'); +}); + +describe('codextest: launchCommand 代理环境透传', () => { + it('会把 launchEnvEntries 合并到直接 spawn 的子进程环境中', async () => { + const child = new EventEmitter(); + const spawnMock = vi.fn(() => child as any); + + vi.doMock('child_process', async () => { + const actual = await vi.importActual('child_process'); + return { + ...actual, + spawn: spawnMock, + }; + }); + + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + + const { CommandExecutor } = await import('../src/commands/command-executor.js'); + const { PlatformUtils } = await import('../src/utils/platform-utils.js'); + + vi.spyOn(PlatformUtils, 'findCodexCommand').mockResolvedValue( + 'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\codex.cmd' + ); + vi.spyOn(PlatformUtils, 'getPlatform').mockReturnValue('windows'); + vi.spyOn(PlatformUtils, 'getUserShell').mockReturnValue('cmd.exe'); + + const executor = Object.create(CommandExecutor.prototype) as any; + void executor.launchCommand({ + appName: 'Codex', + commandName: 'codex', + launchEnvEntries: [ + { key: 'HTTP_PROXY', value: 'http://127.0.0.1:7897' }, + { key: 'HTTPS_PROXY', value: 'http://127.0.0.1:7897' }, + { key: 'ALL_PROXY', value: 'http://127.0.0.1:7897' }, + { key: 'http_proxy', value: 'http://127.0.0.1:7897' }, + ], + printManualFallback: vi.fn(), + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const [command, args, options] = spawnMock.mock.calls[0] as [ + string, + string[], + { env: Record; shell: boolean } + ]; + + expect(command).toBe('cmd.exe'); + expect(args).toEqual(['/c', 'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\codex.cmd']); + expect(options.shell).toBe(false); + expect(options.env.HTTP_PROXY).toBe('http://127.0.0.1:7897'); + expect(options.env.HTTPS_PROXY).toBe('http://127.0.0.1:7897'); + expect(options.env.ALL_PROXY).toBe('http://127.0.0.1:7897'); + expect(options.env.http_proxy).toBe('http://127.0.0.1:7897'); + + child.emit('exit', 0); + expect(exitSpy).toHaveBeenCalledWith(0); + }); +}); diff --git a/prompt.md b/prompt.md new file mode 100644 index 0000000..095174d --- /dev/null +++ b/prompt.md @@ -0,0 +1 @@ +我现在想通过代理完成codex和claude code的抓包。但是当前实现的代理模块好像失败了,并没有正常完成代理。仔细分析原因,我猜测是不是因为子shell可以完成代理的配置,但是js子进程无法完成代理配置 \ No newline at end of file diff --git a/run-codex-loop.sh b/run-codex-loop.sh new file mode 100644 index 0000000..9f8c7cb --- /dev/null +++ b/run-codex-loop.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +if [ $# -ne 0 ]; then + echo "用法: 将问题输入到./prompt.md" + exit 1 +fi + +if [ ! -s ./prompt.md ]; then + echo "./prompt.md 不存在或为空" + exit 1 +fi + +{ + cat ./prompt.md + printf '\n\n重要: 每次修改完代码都需要进行相应的测试,并保证所有测试通过,测试目录在 codextest。\n' +} | codex -a never exec --sandbox danger-full-access - + +EXIT_CODE=$? + +while [ "$EXIT_CODE" -ne 0 ]; do + echo "任务未完成或中断,退出码: $EXIT_CODE,正在 resume --last ..." + codex -a never exec --sandbox danger-full-access resume --last "继续执行未完成的任务,直到最终结束。重要: 每次修改 +完代码都需要进行相应的测试,并保证所有测试通过,测试目录在 codextest" + EXIT_CODE=$? +done + +echo "Codex 任务循环结束" \ No newline at end of file diff --git a/src/cli/cli-parser.ts b/src/cli/cli-parser.ts index 3897d4a..42a6d80 100644 --- a/src/cli/cli-parser.ts +++ b/src/cli/cli-parser.ts @@ -229,6 +229,13 @@ export class CliParser { }; } + if (options.codex && options.envOnly) { + return { + valid: false, + error: 'Codex 模式不支持 --env-only;会直接写入 ~/.codex/config.toml 后启动 codex', + }; + } + return { valid: true }; } @@ -283,7 +290,7 @@ export class CliParser { switch-claude # 交互式选择 Claude provider switch-claude 1 # 直接选择编号为 1 的 Claude provider switch-claude --codex # 交互式选择 Codex provider - switch-claude --codex 1 # 直接选择编号为 1 的 Codex provider + switch-claude --codex 1 # 写入 ~/.codex/config.toml 后启动 Codex switch-claude --add # 添加新的 Claude provider switch-claude --codex --add # 添加新的 Codex provider switch-claude --list # 列出所有 Claude providers diff --git a/src/commands/command-executor.ts b/src/commands/command-executor.ts index c824c53..817785c 100644 --- a/src/commands/command-executor.ts +++ b/src/commands/command-executor.ts @@ -5,15 +5,51 @@ import { CliInterface } from '../ui/cli-interface.js'; import { OutputFormatter } from '../ui/output-formatter.js'; import { ProgressIndicator } from '../ui/progress-indicator.js'; import type { Provider, CliOptions, CommandResult, TestResult } from '../types/index.js'; -import { PlatformUtils } from '../utils/platform-utils.js'; +import { PlatformUtils, isWindowsNativeCommandPath } from '../utils/platform-utils.js'; import { FileUtils } from '../utils/file-utils.js'; import { ValidationUtils } from '../utils/validation.js'; import { normalizeProxyUrl } from '../utils/proxy-utils.js'; +import { + buildCustomEnvEntries, + buildClaudeEnvEntries, + buildPosixEnvCommand, + buildProxyEnvEntries, + buildWindowsEnvCommand, + type EnvEntry, +} from '../utils/launch-env-utils.js'; +import { CODEX_PROVIDER_ID, writeCodexProviderConfig } from '../utils/codex-config-utils.js'; import { StatsManager } from '../core/stats-manager.js'; import { ProviderUtils } from '../utils/provider-utils.js'; import updateNotifier from 'update-notifier'; import { spawn } from 'child_process'; import path from 'node:path'; + +interface LaunchConfig { + command: string; + args: string[]; + viaLoginShell: boolean; +} + +interface AppLaunchOptions { + appName: string; + commandName: 'claude' | 'codex'; + launchEnvEntries: EnvEntry[]; + printManualFallback: (showShellTip: boolean) => void; +} + +function shouldRetryWithShell(error: { code?: string; message?: string }): boolean { + const code = (error.code || '').toUpperCase(); + if (code === 'ENOENT' || code === 'EACCES' || code === 'UNKNOWN' || code === 'EINVAL') { + return true; + } + + const message = (error.message || '').toLowerCase(); + return ( + message.includes('not a valid win32 application') || + message.includes('unknown system error') || + message.includes('command not found') + ); +} /** * 命令执行器 * 负责处理所有CLI命令 @@ -463,7 +499,9 @@ export class CommandExecutor { // 用户指定了编号,直接使用 const index = parseInt(providerIndex, 10) - 1; if (isNaN(index) || index < 0 || index >= providers.length) { - return this.createErrorResult(`编号 ${providerIndex} 无效,有效范围: 1-${providers.length}`); + return this.createErrorResult( + `编号 ${providerIndex} 无效,有效范围: 1-${providers.length}` + ); } selected = providers[index]!; console.log(`\n👉 直接选择: ${selected.name} (${selected.baseUrl}) - 跳过检测`); @@ -497,73 +535,57 @@ export class CommandExecutor { envOnly: boolean = false, isCodex: boolean = false ): Promise { - // 设置代理环境变量(如果配置了) - if (provider.proxy) { - // 标准化代理 URL(自动添加 http:// 前缀) - const normalizedProxy = normalizeProxyUrl(provider.proxy); - process.env.HTTP_PROXY = normalizedProxy; - process.env.HTTPS_PROXY = normalizedProxy; - process.env.http_proxy = normalizedProxy; - process.env.https_proxy = normalizedProxy; + if (isCodex) { + return this.launchCodexApp(provider, envOnly); } - // 根据模式设置不同的环境变量 - if (isCodex) { - // Codex 模式:使用 OpenAI 环境变量 - process.env.OPENAI_BASE_URL = provider.baseUrl; - process.env.OPENAI_API_KEY = provider.key; - - // 清除可能存在的 Anthropic 环境变量(避免冲突) - delete process.env.ANTHROPIC_BASE_URL; - delete process.env.ANTHROPIC_AUTH_TOKEN; - delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL; - delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL; - delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL; - } else { - // Claude 模式:使用 Anthropic 环境变量 - process.env.ANTHROPIC_BASE_URL = provider.baseUrl; - process.env.ANTHROPIC_AUTH_TOKEN = provider.key; + return this.launchClaudeApp(provider, envOnly); + } - // 设置模型环境变量 - if (provider.defaultHaikuModel) { - process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = provider.defaultHaikuModel; - } - if (provider.defaultSonnetModel) { - process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = provider.defaultSonnetModel; - } - if (provider.defaultOpusModel) { - process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = provider.defaultOpusModel; - } + /** + * 启动 Claude Code + */ + private async launchClaudeApp( + provider: Provider & { testResult?: TestResult }, + envOnly: boolean = false + ): Promise { + const customFields = ProviderUtils.extractCustomFields(provider); + const proxyEnvEntries = buildProxyEnvEntries(provider.proxy); + const modeEnvEntries = buildClaudeEnvEntries(provider); + const customEnvEntries = buildCustomEnvEntries(customFields); + const launchEnvEntries = [...proxyEnvEntries, ...modeEnvEntries, ...customEnvEntries]; - // 清除可能存在的 OpenAI 环境变量(避免冲突) - delete process.env.OPENAI_BASE_URL; - delete process.env.OPENAI_API_KEY; + // 设置代理环境变量(如果配置了) + for (const { key, value } of proxyEnvEntries) { + process.env[key] = value; + } + + // 清除可能存在的 OpenAI 环境变量,避免 Claude 启动时误用 + delete process.env.OPENAI_BASE_URL; + delete process.env.OPENAI_API_KEY; + + for (const { key, value } of modeEnvEntries) { + process.env[key] = value; } // 提取并应用自定义字段为环境变量 - const customFields = ProviderUtils.extractCustomFields(provider); const customFieldsCount = ProviderUtils.applyCustomFieldsToEnv(customFields); - const appName = isCodex ? 'Codex' : 'Claude Code'; + const appName = 'Claude Code'; console.log(`\n✅ 已切换到: ${provider.name} (${provider.baseUrl})`); console.log(`\n🔧 环境变量已设置:`); - if (isCodex) { - console.log(` OPENAI_BASE_URL=${provider.baseUrl}`); - console.log(` OPENAI_API_KEY=${provider.key.slice(0, 12)}...`); - } else { - console.log(` ANTHROPIC_BASE_URL=${provider.baseUrl}`); - console.log(` ANTHROPIC_AUTH_TOKEN=${provider.key.slice(0, 12)}...`); + console.log(` ANTHROPIC_BASE_URL=${provider.baseUrl}`); + console.log(` ANTHROPIC_AUTH_TOKEN=${provider.key.slice(0, 12)}...`); - if (provider.defaultHaikuModel) { - console.log(` ANTHROPIC_DEFAULT_HAIKU_MODEL=${provider.defaultHaikuModel}`); - } - if (provider.defaultSonnetModel) { - console.log(` ANTHROPIC_DEFAULT_SONNET_MODEL=${provider.defaultSonnetModel}`); - } - if (provider.defaultOpusModel) { - console.log(` ANTHROPIC_DEFAULT_OPUS_MODEL=${provider.defaultOpusModel}`); - } + if (provider.defaultHaikuModel) { + console.log(` ANTHROPIC_DEFAULT_HAIKU_MODEL=${provider.defaultHaikuModel}`); + } + if (provider.defaultSonnetModel) { + console.log(` ANTHROPIC_DEFAULT_SONNET_MODEL=${provider.defaultSonnetModel}`); + } + if (provider.defaultOpusModel) { + console.log(` ANTHROPIC_DEFAULT_OPUS_MODEL=${provider.defaultOpusModel}`); } if (provider.proxy) { @@ -584,7 +606,7 @@ export class CommandExecutor { const responseTime = provider.testResult?.responseTime ?? null; StatsManager.recordProviderUse(provider.name, true, responseTime); - const commandName = isCodex ? 'codex' : 'claude'; + const commandName = 'claude'; if (envOnly) { console.log(`\n📋 环境变量设置完成!你可以手动运行 ${commandName} 命令`); @@ -595,21 +617,16 @@ export class CommandExecutor { console.log(` $env:HTTPS_PROXY="${normalizedProxy}"`); } - if (isCodex) { - console.log(` $env:OPENAI_BASE_URL="${provider.baseUrl}"`); - console.log(` $env:OPENAI_API_KEY="${provider.key}"`); - } else { - console.log(` $env:ANTHROPIC_BASE_URL="${provider.baseUrl}"`); - console.log(` $env:ANTHROPIC_AUTH_TOKEN="${provider.key}"`); - if (provider.defaultHaikuModel) { - console.log(` $env:ANTHROPIC_DEFAULT_HAIKU_MODEL="${provider.defaultHaikuModel}"`); - } - if (provider.defaultSonnetModel) { - console.log(` $env:ANTHROPIC_DEFAULT_SONNET_MODEL="${provider.defaultSonnetModel}"`); - } - if (provider.defaultOpusModel) { - console.log(` $env:ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"`); - } + console.log(` $env:ANTHROPIC_BASE_URL="${provider.baseUrl}"`); + console.log(` $env:ANTHROPIC_AUTH_TOKEN="${provider.key}"`); + if (provider.defaultHaikuModel) { + console.log(` $env:ANTHROPIC_DEFAULT_HAIKU_MODEL="${provider.defaultHaikuModel}"`); + } + if (provider.defaultSonnetModel) { + console.log(` $env:ANTHROPIC_DEFAULT_SONNET_MODEL="${provider.defaultSonnetModel}"`); + } + if (provider.defaultOpusModel) { + console.log(` $env:ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"`); } // 显示自定义字段的 PowerShell 命令 @@ -625,195 +642,270 @@ export class CommandExecutor { // 尝试启动应用 console.log(`\n🚀 正在启动 ${appName}...`); + return this.launchCommand({ + appName, + commandName, + launchEnvEntries, + printManualFallback: (showShellTip: boolean) => { + console.log(`\n💡 解决方案:`); + console.log(` 1. 确保 ${appName} 已正确安装`); + console.log(` 2. 检查 ${commandName} 命令是否在 PATH 环境变量中`); + console.log(` 3. 或者手动设置环境变量后运行 ${commandName}:`); + + if (provider.proxy) { + const normalizedProxy = normalizeProxyUrl(provider.proxy); + console.log(` $env:HTTP_PROXY="${normalizedProxy}"`); + console.log(` $env:HTTPS_PROXY="${normalizedProxy}"`); + } + + console.log(` $env:ANTHROPIC_BASE_URL="${provider.baseUrl}"`); + console.log(` $env:ANTHROPIC_AUTH_TOKEN="${provider.key}"`); + if (provider.defaultHaikuModel) { + console.log(` $env:ANTHROPIC_DEFAULT_HAIKU_MODEL="${provider.defaultHaikuModel}"`); + } + if (provider.defaultSonnetModel) { + console.log(` $env:ANTHROPIC_DEFAULT_SONNET_MODEL="${provider.defaultSonnetModel}"`); + } + if (provider.defaultOpusModel) { + console.log(` $env:ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"`); + } + + if (customFieldsCount > 0) { + for (const [key, value] of Object.entries(customFields)) { + console.log(` $env:${key}="${value}"`); + } + } + console.log(` ${commandName}`); + this.printPathHint(); + if (showShellTip) { + console.log(`\n🔁 备用方案:你也可以运行 "switch-claude -e <编号>"`); + console.log(` 然后在你的终端手动输入 "${commandName}" 启动。`); + } + }, + }); + } + + /** + * 启动 Codex + */ + private async launchCodexApp( + provider: Provider & { testResult?: TestResult }, + envOnly: boolean = false + ): Promise { + if (envOnly) { + return this.createErrorResult( + 'Codex 模式不支持 --env-only;当前会直接写入 ~/.codex/config.toml。' + ); + } + + const proxyEnvEntries = buildProxyEnvEntries(provider.proxy); + + const writeResult = writeCodexProviderConfig({ + name: provider.name, + baseUrl: provider.baseUrl, + key: provider.key, + }); + + const responseTime = provider.testResult?.responseTime ?? null; + StatsManager.recordProviderUse(provider.name, true, responseTime); + + console.log(`\n✅ 已切换到: ${provider.name} (${provider.baseUrl})`); + console.log(`\n📝 Codex 配置已写入:`); + console.log(` 文件=${writeResult.configPath}`); + console.log(` Section=[model_providers.${writeResult.providerId}]`); + console.log(` name=${provider.name}`); + console.log(` base_url=${provider.baseUrl}`); + console.log(` experimental_bearer_token=${provider.key.slice(0, 12)}...`); + if (provider.proxy) { + const normalizedProxy = normalizeProxyUrl(provider.proxy); + console.log(` HTTP_PROXY=${normalizedProxy}`); + console.log(` HTTPS_PROXY=${normalizedProxy}`); + console.log(` ALL_PROXY=${normalizedProxy}`); + } + if (writeResult.createdSection) { + console.log(` 已自动创建 [model_providers.${writeResult.providerId}]`); + } + + console.log(`\n🚀 正在启动 Codex...`); + + return this.launchCommand({ + appName: 'Codex', + commandName: 'codex', + launchEnvEntries: proxyEnvEntries, + printManualFallback: (showShellTip: boolean) => { + console.log(`\n💡 解决方案:`); + console.log(` 1. 确保 Codex 已正确安装`); + console.log(` 2. 检查 codex 命令是否在 PATH 环境变量中`); + console.log(` 3. 确认 Codex 配置文件中的目标字段已更新:`); + console.log(` 文件: ${writeResult.configPath}`); + console.log(` Section: [model_providers.${CODEX_PROVIDER_ID}]`); + console.log(` name = "${provider.name}"`); + console.log(` base_url = "${provider.baseUrl}"`); + console.log(` experimental_bearer_token = "${provider.key}"`); + if (provider.proxy) { + const normalizedProxy = normalizeProxyUrl(provider.proxy); + console.log(` 4. 代理环境变量也会随 Codex 进程传递:`); + console.log(` HTTP_PROXY = "${normalizedProxy}"`); + console.log(` HTTPS_PROXY = "${normalizedProxy}"`); + console.log(` ALL_PROXY = "${normalizedProxy}"`); + console.log(` 以及对应的小写变量`); + } else { + console.log(` 4. 当前 Provider 未配置代理,Codex 将直连。`); + } + console.log(` 5. Codex 模式不再依赖 OPENAI_BASE_URL / OPENAI_API_KEY 环境变量`); + this.printPathHint(); + if (showShellTip) { + console.log(`\n🔁 备用方案:先单独运行 "codex" 检查命令是否可用。`); + } + }, + }); + } + + /** + * 启动外部命令 + */ + private async launchCommand({ + appName, + commandName, + launchEnvEntries, + printManualFallback, + }: AppLaunchOptions): Promise { // 优先查找绝对路径,其次回退到用户登录 shell 执行 - const foundPath = isCodex - ? await PlatformUtils.findCodexCommand() - : await PlatformUtils.findClaudeCommand(); - const isAbs = foundPath - ? PlatformUtils.getPlatform() === 'windows' - ? true - : path.isAbsolute(foundPath) - : false; + const foundPath = + commandName === 'codex' + ? await PlatformUtils.findCodexCommand() + : await PlatformUtils.findClaudeCommand(); + const platform = PlatformUtils.getPlatform(); + const userShell = PlatformUtils.getUserShell(); + const isAbs = foundPath ? path.isAbsolute(foundPath) : false; const appPath = isAbs ? foundPath : null; - if (appPath) { - console.log(`🔍 使用 ${commandName} 命令路径: ${appPath}`); - } else { - console.log(`🔍 使用 ${commandName} 命令路径: 未解析到二进制,尝试通过登录 shell 执行`); - } try { - // 根据是否找到绝对路径,决定启动方式 - let command = appPath || ''; - let args: string[] = []; - let useShell = false; - - if (appPath) { - const platform = PlatformUtils.getPlatform(); + const buildDirectLaunchConfig = (resolvedPath: string): LaunchConfig | null => { if (platform === 'windows') { - const ext = path.extname(appPath).toLowerCase(); + if (!isWindowsNativeCommandPath(resolvedPath)) { + return null; + } + + const ext = path.extname(resolvedPath).toLowerCase(); if (ext === '.cmd' || ext === '.bat') { - command = 'cmd.exe'; - args = ['/c', commandName]; - } else if (ext === '.ps1') { - command = 'powershell.exe'; - args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', appPath]; + return { + command: 'cmd.exe', + args: ['/c', resolvedPath], + viaLoginShell: false, + }; + } + if (ext === '.ps1') { + return { + command: 'powershell.exe', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', resolvedPath], + viaLoginShell: false, + }; } - } else { - command = appPath; } - } else { - // 回退:通过用户的默认 shell 以”登录 + 交互”方式执行,确保加载别名/函数 - const userShell = PlatformUtils.getUserShell(); - const platform = PlatformUtils.getPlatform(); + + return { + command: resolvedPath, + args: [], + viaLoginShell: false, + }; + }; + + const buildShellLaunchConfig = (): LaunchConfig => { if (platform === 'windows') { // 在 Windows 上使用 cmd 执行(尽量避免对 PowerShell 的依赖) - command = userShell; // 通常为 cmd.exe - - if (isCodex) { - // Codex 模式:使用 OpenAI 环境变量 - let customEnv = ''; - if (customFieldsCount > 0) { - for (const [key, value] of Object.entries(customFields)) { - customEnv += `set “${key}=${value}” && `; - } - } - const winCmd = `${customEnv}set “OPENAI_BASE_URL=${provider.baseUrl}” && set “OPENAI_API_KEY=${provider.key}” && ${commandName}`; - args = ['/c', winCmd]; - } else { - // Claude 模式:使用 Anthropic 环境变量 - let modelEnv = ''; - if (provider.defaultHaikuModel) { - modelEnv += `set “ANTHROPIC_DEFAULT_HAIKU_MODEL=${provider.defaultHaikuModel}” && `; - } - if (provider.defaultSonnetModel) { - modelEnv += `set “ANTHROPIC_DEFAULT_SONNET_MODEL=${provider.defaultSonnetModel}” && `; - } - if (provider.defaultOpusModel) { - modelEnv += `set “ANTHROPIC_DEFAULT_OPUS_MODEL=${provider.defaultOpusModel}” && `; - } - let customEnv = ''; - if (customFieldsCount > 0) { - for (const [key, value] of Object.entries(customFields)) { - customEnv += `set “${key}=${value}” && `; - } - } - const winCmd = `${customEnv}${modelEnv}set “ANTHROPIC_BASE_URL=${provider.baseUrl}” && set “ANTHROPIC_AUTH_TOKEN=${provider.key}” && ${commandName}`; - args = ['/c', winCmd]; - } - } else { - command = userShell; - // -l 登录 shell(读取 zprofile/profile),-i 交互式(读取 zshrc/bashrc),-c 执行命令 - - if (isCodex) { - // Codex 模式:使用 OpenAI 环境变量 - let customExport = ''; - if (customFieldsCount > 0) { - for (const [key, value] of Object.entries(customFields)) { - customExport += `export ${key}=”${value}”; `; - } - } - const exportCmd = `${customExport}export OPENAI_BASE_URL=”${provider.baseUrl}”; export OPENAI_API_KEY=”${provider.key}”; ${commandName}`; - args = ['-l', '-i', '-c', exportCmd]; - } else { - // Claude 模式:使用 Anthropic 环境变量 - let modelExport = ''; - if (provider.defaultHaikuModel) { - modelExport += `export ANTHROPIC_DEFAULT_HAIKU_MODEL=”${provider.defaultHaikuModel}”; `; - } - if (provider.defaultSonnetModel) { - modelExport += `export ANTHROPIC_DEFAULT_SONNET_MODEL=”${provider.defaultSonnetModel}”; `; - } - if (provider.defaultOpusModel) { - modelExport += `export ANTHROPIC_DEFAULT_OPUS_MODEL=”${provider.defaultOpusModel}”; `; - } - let customExport = ''; - if (customFieldsCount > 0) { - for (const [key, value] of Object.entries(customFields)) { - customExport += `export ${key}=”${value}”; `; - } - } - const exportCmd = `${customExport}${modelExport}export ANTHROPIC_BASE_URL=”${provider.baseUrl}”; export ANTHROPIC_AUTH_TOKEN=”${provider.key}”; ${commandName}`; - args = ['-l', '-i', '-c', exportCmd]; - } + const winCmd = buildWindowsEnvCommand(launchEnvEntries, commandName); + return { + command: userShell, + args: ['/c', winCmd], + viaLoginShell: true, + }; } - console.log(`🔁 通过登录 shell 启动: ${command} ${args.join(' ')}`); + // -l 登录 shell(读取 zprofile/profile),-i 交互式(读取 zshrc/bashrc),-c 执行命令 + const exportCmd = buildPosixEnvCommand(launchEnvEntries, commandName); + return { + command: userShell, + args: ['-l', '-i', '-c', exportCmd], + viaLoginShell: true, + }; + }; + + const directLaunchConfig = appPath ? buildDirectLaunchConfig(appPath) : null; + const shellLaunchConfig = buildShellLaunchConfig(); + const initialLaunchConfig = directLaunchConfig ?? shellLaunchConfig; + + if (appPath) { + if (directLaunchConfig) { + console.log(`🔍 使用 ${commandName} 命令路径: ${appPath}`); + } else { + console.log( + `🔍 使用 ${commandName} 命令路径: ${appPath} (非 Windows 原生命令,改为通过登录 shell 执行)` + ); + } + } else { + console.log(`🔍 使用 ${commandName} 命令路径: 未解析到二进制,尝试通过登录 shell 执行`); } const childEnv = { ...process.env }; delete childEnv.NODE_OPTIONS; delete (childEnv as Record).VSCODE_INSPECTOR_OPTIONS; + for (const { key, value } of launchEnvEntries) { + childEnv[key] = value; + } - // 为应用设置正确的 stdin 配置以支持交互 - const childProcess = spawn(command || commandName, args, { - stdio: ['inherit', 'inherit', 'inherit'], // 继承 stdin, stdout, stderr - env: childEnv, - shell: useShell, - }); + return new Promise(() => { + const startLaunch = (config: LaunchConfig, allowShellRetry: boolean) => { + if (config.viaLoginShell) { + console.log(`🔁 通过登录 shell 启动: ${config.command} ${config.args.join(' ')}`); + } - childProcess.on('error', (error: unknown) => { - const err = error as { code?: string; message?: string }; - if (err && err.code === 'ENOENT') { - console.error(`\n❌ 找不到 '${commandName}' 命令!`); - console.log(`\n💡 解决方案:`); - console.log(` 1. 确保 ${appName} 已正确安装`); - console.log(` 2. 检查 ${commandName} 命令是否在 PATH 环境变量中`); - console.log(` 3. 或者手动设置环境变量后运行 ${commandName}:`); - - if (isCodex) { - console.log(` $env:OPENAI_BASE_URL="${provider.baseUrl}"`); - console.log(` $env:OPENAI_API_KEY="${provider.key}"`); - } else { - console.log(` $env:ANTHROPIC_BASE_URL="${provider.baseUrl}"`); - console.log(` $env:ANTHROPIC_AUTH_TOKEN="${provider.key}"`); - if (provider.defaultHaikuModel) { - console.log(` $env:ANTHROPIC_DEFAULT_HAIKU_MODEL="${provider.defaultHaikuModel}"`); - } - if (provider.defaultSonnetModel) { - console.log(` $env:ANTHROPIC_DEFAULT_SONNET_MODEL="${provider.defaultSonnetModel}"`); - } - if (provider.defaultOpusModel) { - console.log(` $env:ANTHROPIC_DEFAULT_OPUS_MODEL="${provider.defaultOpusModel}"`); + const handleLaunchError = (error: unknown) => { + const err = error as { code?: string; message?: string }; + + if (allowShellRetry && !config.viaLoginShell && shouldRetryWithShell(err)) { + const msg = err.message || err.code || '未知错误'; + console.log(`\n⚠️ 直接启动 ${commandName} 失败,尝试通过登录 shell 回退: ${msg}`); + startLaunch(shellLaunchConfig, false); + return; } - } - // 显示自定义字段的 PowerShell 命令 - if (customFieldsCount > 0) { - for (const [key, value] of Object.entries(customFields)) { - console.log(` $env:${key}="${value}"`); + if (err && err.code === 'ENOENT') { + console.error(`\n❌ 找不到 '${commandName}' 命令!`); + printManualFallback(config.viaLoginShell || !directLaunchConfig); + } else { + const msg = err && err.message ? err.message : String(error); + console.error(`\n❌ 启动 ${commandName} 时出错: ${msg}`); + printManualFallback(config.viaLoginShell || !directLaunchConfig); } - } - console.log(` ${commandName}`); - console.log(`\n🔍 当前 PATH 包含的目录:`); - const paths = (process.env.PATH || '').split(process.platform === 'win32' ? ';' : ':'); - paths.slice(0, 5).forEach((p) => console.log(` - ${p}`)); - if (paths.length > 5) { - console.log(` ... 还有 ${paths.length - 5} 个目录`); - } - if (!appPath) { - console.log(`\n🔁 备用方案:你也可以运行 "switch-claude ${isCodex ? '--codex ' : ''}-e <编号>"`); - console.log(` 然后在你的终端手动输入 "${commandName}" 启动。`); - } - } else { - const msg = err && err.message ? err.message : String(error); - console.error(`\n❌ 启动 ${commandName} 时出错: ${msg}`); - } - process.exit(1); - }); + process.exit(1); + }; - childProcess.on('exit', (code) => { - if (code !== 0 && code !== null) { - console.log(`\n⚠️ ${appName} 退出,退出码: ${code}`); - } - process.exit(code || 0); - }); + try { + const childProcess = spawn(config.command, config.args, { + stdio: ['inherit', 'inherit', 'inherit'], // 继承 stdin, stdout, stderr + env: childEnv, + shell: false, + }); - // 不返回结果,让程序继续运行等待进程结束 - console.log(`✅ ${appName} 已启动`); + childProcess.once('spawn', () => { + console.log(`✅ ${appName} 已启动`); + }); - // 返回一个永不resolve的Promise,让程序等待进程结束 - return new Promise(() => { - // 这个Promise永远不会resolve,程序会一直等待直到进程退出并调用process.exit() + childProcess.once('error', handleLaunchError); + + childProcess.once('exit', (code) => { + if (code !== 0 && code !== null) { + console.log(`\n⚠️ ${appName} 退出,退出码: ${code}`); + } + process.exit(code || 0); + }); + } catch (error) { + handleLaunchError(error); + } + }; + + startLaunch(initialLaunchConfig, directLaunchConfig !== null); }); } catch (error) { return this.createErrorResult( @@ -822,6 +914,15 @@ export class CommandExecutor { } } + private printPathHint(): void { + console.log(`\n🔍 当前 PATH 包含的目录:`); + const paths = (process.env.PATH || '').split(process.platform === 'win32' ? ';' : ':'); + paths.slice(0, 5).forEach((p) => console.log(` - ${p}`)); + if (paths.length > 5) { + console.log(` ... 还有 ${paths.length - 5} 个目录`); + } + } + /** * 执行列表命令 */ @@ -834,7 +935,10 @@ export class CommandExecutor { /** * 执行添加命令 */ - private async executeAddCommand(providers: Provider[], isCodex: boolean = false): Promise { + private async executeAddCommand( + providers: Provider[], + isCodex: boolean = false + ): Promise { const newProvider = await CliInterface.addProvider(providers, isCodex); if (!newProvider) { return this.createErrorResult('添加操作已取消', 0); @@ -928,7 +1032,7 @@ export class CommandExecutor { } // 从所有 providers 中删除 - const allIndex = allProviders.findIndex(p => p.name === provider.name); + const allIndex = allProviders.findIndex((p) => p.name === provider.name); if (allIndex !== -1) { allProviders.splice(allIndex, 1); } diff --git a/src/core/config-manager.ts b/src/core/config-manager.ts index 3b64558..83f2974 100644 --- a/src/core/config-manager.ts +++ b/src/core/config-manager.ts @@ -33,14 +33,13 @@ export class ConfigManager { let providers = JSON.parse(content) as Provider[]; // 自动清理字符串字段的尾随空格,防止解析错误 - providers = providers.map(p => ({ + providers = providers.map((p) => ({ ...p, name: typeof p.name === 'string' ? p.name.trim() : p.name, baseUrl: typeof p.baseUrl === 'string' ? p.baseUrl.trim() : p.baseUrl, key: typeof p.key === 'string' ? p.key.trim() : p.key, })); - // 验证配置 const errors = ValidationUtils.validateProviders(providers); if (errors.length > 0) { diff --git a/src/ui/cli-interface.ts b/src/ui/cli-interface.ts index f83ca21..fccf56e 100644 --- a/src/ui/cli-interface.ts +++ b/src/ui/cli-interface.ts @@ -1,7 +1,6 @@ import inquirer from 'inquirer'; import type { Provider } from '../types/index.js'; import { ValidationUtils } from '../utils/validation.js'; -import { FileUtils } from '../utils/file-utils.js'; import { getProxyFromEnv, isValidProxy } from '../utils/proxy-utils.js'; export class CliInterface { @@ -40,7 +39,10 @@ export class CliInterface { /** * 交互式添加Provider */ - static async addProvider(existingProviders: Provider[], isCodex: boolean = false): Promise { + static async addProvider( + existingProviders: Provider[], + isCodex: boolean = false + ): Promise { const mode = isCodex ? 'Codex' : 'Claude'; console.log(`\n🚀 添加新的 ${mode} Provider\n`); @@ -416,13 +418,13 @@ export class CliInterface { 用法: switch-claude [选项] [编号] -选项: + 选项: -h, --help 显示帮助信息 -V, --version 显示版本信息并检查更新 -r, --refresh 强制刷新缓存,重新检测所有 provider -v, --verbose 显示详细的调试信息 -l, --list 只列出 providers 不启动 claude - -e, --env-only 只设置环境变量,不启动 claude + -e, --env-only 仅 Claude 模式:只设置环境变量,不启动 Claude Code --add 添加新的 provider --remove <编号> 删除指定编号的 provider --set-default <编号> 设置指定编号的 provider 为默认 @@ -446,7 +448,7 @@ export class CliInterface { switch-claude --remove 2 # 删除编号为 2 的 provider switch-claude --set-default 1 # 设置编号为 1 的 provider 为默认 switch-claude --clear-default # 清除默认设置 - switch-claude -e 1 # 只设置环境变量,不启动 claude`); + switch-claude -e 1 # 仅 Claude:只设置环境变量,不启动 Claude Code`); } /** diff --git a/src/ui/output-formatter.ts b/src/ui/output-formatter.ts index 2c24c88..b9d4630 100644 --- a/src/ui/output-formatter.ts +++ b/src/ui/output-formatter.ts @@ -90,7 +90,7 @@ export class OutputFormatter { -r, --refresh 强制刷新缓存,重新检测所有 provider -v, --verbose 显示详细的调试信息 -l, --list 只列出 providers 不启动应用 - -e, --env-only 只设置环境变量,不启动应用 + -e, --env-only 仅 Claude 模式:只设置环境变量,不启动 Claude Code -c, --check 强制检测 API 可用性(默认跳过检测,直接使用 provider) --add 添加新的 provider --edit <编号> 编辑指定编号的 provider @@ -119,7 +119,7 @@ export class OutputFormatter { # Codex 模式 switch-claude --codex # 交互式选择 Codex provider - switch-claude --codex 1 # 直接选择编号为 1 的 Codex provider + switch-claude --codex 1 # 写入 ~/.codex/config.toml 后启动 Codex switch-claude --codex --list # 列出所有 Codex providers switch-claude --codex --add # 添加新的 Codex provider @@ -131,7 +131,7 @@ export class OutputFormatter { switch-claude --remove 2 # 删除编号为 2 的 provider switch-claude --set-default 1 # 设置编号为 1 的 provider 为默认 switch-claude --clear-default # 清除默认设置 - switch-claude -e 1 # 只设置环境变量,不启动应用 + switch-claude -e 1 # 仅 Claude:只设置环境变量,不启动 Claude Code switch-claude --export # 导出配置到带时间戳的文件 switch-claude --export my-config.json # 导出到指定文件 switch-claude --import backup.json # 导入配置(替换) diff --git a/src/utils/codex-config-utils.ts b/src/utils/codex-config-utils.ts new file mode 100644 index 0000000..7353882 --- /dev/null +++ b/src/utils/codex-config-utils.ts @@ -0,0 +1,154 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +export const CODEX_PROVIDER_ID = 'x'; + +export interface CodexConfigValues { + name: string; + baseUrl: string; + key: string; +} + +export interface CodexConfigWriteResult { + configPath: string; + providerId: string; + createdSection: boolean; +} + +const NAME_KEY = 'name'; +const BASE_URL_KEY = 'base_url'; +const BEARER_TOKEN_KEY = 'experimental_bearer_token'; + +/** + * 获取用户级 Codex 配置文件路径 + */ +export function getCodexConfigPath(): string { + return path.join(os.homedir(), '.codex', 'config.toml'); +} + +/** + * 将指定 provider 的 name/base_url/experimental_bearer_token 写入 Codex 配置文本 + */ +export function updateCodexConfigToml( + tomlText: string, + providerId: string, + values: CodexConfigValues +): string { + const newline = tomlText.includes('\r\n') ? '\r\n' : '\n'; + const lines = tomlText === '' ? [] : tomlText.split(/\r?\n/); + const sectionHeader = `[model_providers.${providerId}]`; + const sectionStart = lines.findIndex((line) => line.trim() === sectionHeader); + + const nameLine = `${NAME_KEY} = ${toTomlString(values.name)}`; + const baseUrlLine = `${BASE_URL_KEY} = ${toTomlString(values.baseUrl)}`; + const bearerTokenLine = `${BEARER_TOKEN_KEY} = ${toTomlString(values.key)}`; + + if (sectionStart === -1) { + const nextLines = [...lines]; + if (nextLines.length > 0 && nextLines[nextLines.length - 1] !== '') { + nextLines.push(''); + } + nextLines.push(sectionHeader, nameLine, baseUrlLine, bearerTokenLine); + return nextLines.join(newline); + } + + let sectionEnd = lines.length; + for (let i = sectionStart + 1; i < lines.length; i++) { + if (isTomlSectionHeader(lines[i]!)) { + sectionEnd = i; + break; + } + } + + const sectionLines = lines.slice(sectionStart + 1, sectionEnd); + const nameIndex = findKeyLineIndex(sectionLines, NAME_KEY); + const baseUrlIndex = findKeyLineIndex(sectionLines, BASE_URL_KEY); + const bearerTokenIndex = findKeyLineIndex(sectionLines, BEARER_TOKEN_KEY); + + if (nameIndex !== -1) { + sectionLines[nameIndex] = nameLine; + } + if (baseUrlIndex !== -1) { + sectionLines[baseUrlIndex] = baseUrlLine; + } + if (bearerTokenIndex !== -1) { + sectionLines[bearerTokenIndex] = bearerTokenLine; + } + + if (nameIndex === -1 && baseUrlIndex === -1 && bearerTokenIndex === -1) { + sectionLines.splice(0, 0, nameLine, baseUrlLine, bearerTokenLine); + } else if (nameIndex === -1) { + const nextBaseUrlIndex = findKeyLineIndex(sectionLines, BASE_URL_KEY); + sectionLines.splice(nextBaseUrlIndex === -1 ? 0 : nextBaseUrlIndex, 0, nameLine); + } + + if (findKeyLineIndex(sectionLines, BASE_URL_KEY) === -1) { + const nextNameIndex = findKeyLineIndex(sectionLines, NAME_KEY); + sectionLines.splice(nextNameIndex + 1, 0, baseUrlLine); + } + + if (findKeyLineIndex(sectionLines, BEARER_TOKEN_KEY) === -1) { + const nextBaseUrlIndex = findKeyLineIndex(sectionLines, BASE_URL_KEY); + sectionLines.splice(nextBaseUrlIndex + 1, 0, bearerTokenLine); + } + + return [...lines.slice(0, sectionStart + 1), ...sectionLines, ...lines.slice(sectionEnd)].join( + newline + ); +} + +/** + * 读取并写回用户级 Codex 配置文件 + */ +export function writeCodexProviderConfig( + values: CodexConfigValues, + providerId: string = CODEX_PROVIDER_ID, + configPath: string = getCodexConfigPath() +): CodexConfigWriteResult { + const configDir = path.dirname(configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + const currentToml = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf-8') : ''; + const createdSection = !hasProviderSection(currentToml, providerId); + const nextToml = updateCodexConfigToml(currentToml, providerId, values); + + fs.writeFileSync(configPath, nextToml, 'utf-8'); + + return { + configPath, + providerId, + createdSection, + }; +} + +function hasProviderSection(tomlText: string, providerId: string): boolean { + const sectionHeader = `[model_providers.${providerId}]`; + return tomlText.split(/\r?\n/).some((line) => line.trim() === sectionHeader); +} + +function findKeyLineIndex(lines: string[], key: string): number { + const pattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`); + return lines.findIndex((line) => pattern.test(line)); +} + +function isTomlSectionHeader(line: string): boolean { + return /^\s*\[.*\]\s*$/.test(line); +} + +function toTomlString(value: string): string { + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + .replace(/\t/g, '\\t'); + + return `"${escaped}"`; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/utils/launch-env-utils.ts b/src/utils/launch-env-utils.ts new file mode 100644 index 0000000..5fdf7f4 --- /dev/null +++ b/src/utils/launch-env-utils.ts @@ -0,0 +1,94 @@ +import type { Provider } from '../types/index.js'; +import { normalizeProxyUrl } from './proxy-utils.js'; + +export interface EnvEntry { + key: string; + value: string; +} + +/** + * 构建代理相关环境变量 + */ +export function buildProxyEnvEntries(proxy?: string): EnvEntry[] { + if (!proxy) { + return []; + } + + const normalizedProxy = normalizeProxyUrl(proxy); + + return [ + { key: 'HTTP_PROXY', value: normalizedProxy }, + { key: 'HTTPS_PROXY', value: normalizedProxy }, + { key: 'ALL_PROXY', value: normalizedProxy }, + { key: 'http_proxy', value: normalizedProxy }, + { key: 'https_proxy', value: normalizedProxy }, + { key: 'all_proxy', value: normalizedProxy }, + ]; +} + +/** + * 构建 Claude 模式自身需要的环境变量 + */ +export function buildClaudeEnvEntries(provider: Provider): EnvEntry[] { + const entries: EnvEntry[] = [ + { key: 'ANTHROPIC_BASE_URL', value: provider.baseUrl }, + { key: 'ANTHROPIC_AUTH_TOKEN', value: provider.key }, + ]; + + if (provider.defaultHaikuModel) { + entries.push({ key: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', value: provider.defaultHaikuModel }); + } + if (provider.defaultSonnetModel) { + entries.push({ key: 'ANTHROPIC_DEFAULT_SONNET_MODEL', value: provider.defaultSonnetModel }); + } + if (provider.defaultOpusModel) { + entries.push({ key: 'ANTHROPIC_DEFAULT_OPUS_MODEL', value: provider.defaultOpusModel }); + } + + return entries; +} + +/** + * 将自定义字段转换为环境变量条目 + */ +export function buildCustomEnvEntries(customFields: Record): EnvEntry[] { + return Object.entries(customFields).map(([key, value]) => ({ key, value })); +} + +/** + * 为 cmd.exe 构建环境变量设置命令 + */ +export function buildWindowsEnvCommand(envEntries: EnvEntry[], commandName: string): string { + if (envEntries.length === 0) { + return commandName; + } + + const envPrefix = envEntries + .map(({ key, value }) => `set "${key}=${escapeWindowsEnvValue(value)}"`) + .join(' && '); + + return `${envPrefix} && ${commandName}`; +} + +/** + * 为 POSIX shell 构建环境变量设置命令 + */ +export function buildPosixEnvCommand(envEntries: EnvEntry[], commandName: string): string { + if (envEntries.length === 0) { + return commandName; + } + + const envPrefix = envEntries + .map(({ key, value }) => `export ${key}='${escapePosixEnvValue(value)}'`) + .join('; '); + + return `${envPrefix}; ${commandName}`; +} + +function escapeWindowsEnvValue(value: string): string { + return value.replace(/"/g, '^"').replace(/%/g, '%%'); +} + +function escapePosixEnvValue(value: string): string { + return value.replace(/'/g, `'"'"'`); +} diff --git a/src/utils/platform-utils.ts b/src/utils/platform-utils.ts index 061266f..0f18db2 100644 --- a/src/utils/platform-utils.ts +++ b/src/utils/platform-utils.ts @@ -55,7 +55,8 @@ export class PlatformUtils { stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, }).trim(); - if (out && out.startsWith('/') && out.includes('/')) return out; + const resolvedPath = extractExecutablePath(out, platform); + if (resolvedPath) return resolvedPath; } catch { // ignore } @@ -134,7 +135,8 @@ export class PlatformUtils { stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, }).trim(); - if (out && out.startsWith('/') && out.includes('/')) return out; + const resolvedPath = extractExecutablePath(out, platform); + if (resolvedPath) return resolvedPath; } catch { // ignore } @@ -150,7 +152,10 @@ export class PlatformUtils { timeout: 4000, }).trim(); - const lines = out.split('\n').map((l) => l.trim()).filter(Boolean); + const lines = out + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); for (const line of lines) { const pathMatch = line.match(/(\/[^\s'"`]+)/); if (pathMatch && pathMatch[1]) { @@ -416,3 +421,73 @@ export class PlatformUtils { } } } + +/** + * 从 which/where 输出中提取可执行文件路径 + */ +export function extractExecutablePath( + output: string, + platform: 'windows' | 'macos' | 'linux' | 'unknown' +): string | null { + const candidates = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + if (platform === 'windows') { + const rankedCandidates = candidates + .map((candidate, index) => ({ candidate, index })) + .filter(({ candidate }) => /^[a-zA-Z]:[\\/]/.test(candidate) || candidate.startsWith('\\\\')) + .sort((left, right) => { + const priorityDiff = + getWindowsCommandPriority(left.candidate) - getWindowsCommandPriority(right.candidate); + if (priorityDiff !== 0) { + return priorityDiff; + } + + return left.index - right.index; + }); + + return rankedCandidates[0]?.candidate ?? null; + } + + for (const candidate of candidates) { + if (candidate.startsWith('/')) { + return candidate; + } + } + + return null; +} + +/** + * 判断 Windows 路径是否适合直接作为原生命令启动 + */ +export function isWindowsNativeCommandPath(commandPath: string): boolean { + const normalizedPath = commandPath.trim().toLowerCase(); + return ( + normalizedPath.endsWith('.cmd') || + normalizedPath.endsWith('.bat') || + normalizedPath.endsWith('.exe') || + normalizedPath.endsWith('.ps1') + ); +} + +function getWindowsCommandPriority(commandPath: string): number { + const normalizedPath = commandPath.trim().toLowerCase(); + + if (normalizedPath.endsWith('.cmd')) { + return 0; + } + if (normalizedPath.endsWith('.bat')) { + return 1; + } + if (normalizedPath.endsWith('.exe')) { + return 2; + } + if (normalizedPath.endsWith('.ps1')) { + return 3; + } + + return 99; +} diff --git a/src/utils/proxy-utils.ts b/src/utils/proxy-utils.ts index e602d57..3ab272e 100644 --- a/src/utils/proxy-utils.ts +++ b/src/utils/proxy-utils.ts @@ -1,7 +1,7 @@ /** * 代理工具类 * 用于检测和处理系统代理配置 - * + * * @author ZHANGCHAO */ diff --git a/test/codex-config-utils.test.ts b/test/codex-config-utils.test.ts new file mode 100644 index 0000000..ef9ccda --- /dev/null +++ b/test/codex-config-utils.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import { CODEX_PROVIDER_ID, updateCodexConfigToml } from '../src/utils/codex-config-utils.js'; + +describe('codex-config-utils', () => { + it('会更新现有 [model_providers.x] 中的 name、base_url 和 experimental_bearer_token', () => { + const toml = [ + 'model_provider = "x"', + '', + '[model_providers.x]', + 'name = "xcodex"', + 'base_url = "https://old.example.com/v1"', + 'experimental_bearer_token = "old-token"', + 'wire_api = "responses"', + '', + '[features]', + 'powershell_utf8 = true', + ].join('\n'); + + const updated = updateCodexConfigToml(toml, CODEX_PROVIDER_ID, { + name: 'new-provider-name', + baseUrl: 'https://new.example.com/v1', + key: 'new-token', + }); + + expect(updated).toContain('name = "new-provider-name"'); + expect(updated).toContain('base_url = "https://new.example.com/v1"'); + expect(updated).toContain('experimental_bearer_token = "new-token"'); + expect(updated).toContain('wire_api = "responses"'); + expect(updated).toContain('[features]'); + }); + + it('会保留原始 CRLF 换行风格', () => { + const toml = [ + 'model_provider = "x"', + '', + '[model_providers.x]', + 'name = "xcodex"', + 'base_url = "https://old.example.com/v1"', + 'experimental_bearer_token = "old-token"', + ].join('\r\n'); + + const updated = updateCodexConfigToml(toml, CODEX_PROVIDER_ID, { + name: 'new-provider-name', + baseUrl: 'https://new.example.com/v1', + key: 'new-token', + }); + + expect(updated).toContain('\r\n[model_providers.x]\r\n'); + expect(updated).toContain('name = "new-provider-name"\r\n'); + expect(updated).toContain('base_url = "https://new.example.com/v1"'); + expect(updated).toContain('experimental_bearer_token = "new-token"'); + expect(updated).not.toContain('\n[model_providers.x]\n'); + }); + + it('会在缺少 [model_providers.x] 时自动追加最小 section', () => { + const toml = ['model_provider = "x"', '', '[features]', 'powershell_utf8 = true'].join('\n'); + + const updated = updateCodexConfigToml(toml, CODEX_PROVIDER_ID, { + name: 'created-provider-name', + baseUrl: 'https://created.example.com/v1', + key: 'created-token', + }); + + expect(updated).toContain('[model_providers.x]'); + expect(updated).toContain('name = "created-provider-name"'); + expect(updated).toContain('base_url = "https://created.example.com/v1"'); + expect(updated).toContain('experimental_bearer_token = "created-token"'); + expect(updated).toContain('[features]'); + }); + + it('会在已有 section 但缺少字段时补齐,并保持 name、base_url、experimental_bearer_token 的顺序', () => { + const toml = ['model_provider = "x"', '', '[model_providers.x]', 'wire_api = "responses"'].join( + '\n' + ); + + const updated = updateCodexConfigToml(toml, CODEX_PROVIDER_ID, { + name: 'fill-provider-name', + baseUrl: 'https://fill.example.com/v1', + key: 'fill-token', + }); + + expect(updated).toContain( + [ + '[model_providers.x]', + 'name = "fill-provider-name"', + 'base_url = "https://fill.example.com/v1"', + 'experimental_bearer_token = "fill-token"', + ].join('\n') + ); + expect(updated).toContain('wire_api = "responses"'); + }); + + it('会正确转义 TOML 字符串中的特殊字符', () => { + const toml = ['model_provider = "x"', '', '[model_providers.x]'].join('\n'); + + const updated = updateCodexConfigToml(toml, CODEX_PROVIDER_ID, { + name: 'line1\nline2\t"name"', + baseUrl: 'https://example.com/v1?path=C:\\temp\\"demo"', + key: 'line1\nline2\t"value"', + }); + + expect(updated).toContain('name = "line1\\nline2\\t\\"name\\""'); + expect(updated).toContain('base_url = "https://example.com/v1?path=C:\\\\temp\\\\\\"demo\\""'); + expect(updated).toContain('experimental_bearer_token = "line1\\nline2\\t\\"value\\""'); + }); +}); diff --git a/test/launch-utils.test.ts b/test/launch-utils.test.ts new file mode 100644 index 0000000..50a6b89 --- /dev/null +++ b/test/launch-utils.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildCustomEnvEntries, + buildClaudeEnvEntries, + buildPosixEnvCommand, + buildProxyEnvEntries, + buildWindowsEnvCommand, +} from '../src/utils/launch-env-utils.js'; +import { extractExecutablePath, isWindowsNativeCommandPath } from '../src/utils/platform-utils.js'; +import type { Provider } from '../src/types/index.js'; + +describe('extractExecutablePath', () => { + it('在 Windows 上会优先选择 .cmd 而不是无扩展名 shim', () => { + const output = [ + 'E:\\ProgramFiles\\nvm\\nodejs\\claude', + 'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\claude.cmd', + 'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\claude.ps1', + ].join('\r\n'); + + expect(extractExecutablePath(output, 'windows')).toBe( + 'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\claude.cmd' + ); + }); + + it('能从 POSIX 的 which 输出中提取绝对路径', () => { + expect(extractExecutablePath('/usr/local/bin/claude\n', 'linux')).toBe('/usr/local/bin/claude'); + }); +}); + +describe('isWindowsNativeCommandPath', () => { + it('只把 Windows 原生命令后缀视为可直接启动', () => { + expect(isWindowsNativeCommandPath('E:\\ProgramFiles\\nvm\\nodejs\\claude')).toBe(false); + expect(isWindowsNativeCommandPath('E:\\ProgramFiles\\nvm\\nodejs\\claude.cmd')).toBe(true); + expect(isWindowsNativeCommandPath('E:\\ProgramFiles\\nvm\\nodejs\\claude.exe')).toBe(true); + expect(isWindowsNativeCommandPath('E:\\ProgramFiles\\nvm\\nodejs\\claude.ps1')).toBe(true); + }); +}); + +describe('launch-env-utils', () => { + const provider: Provider = { + name: 'TestProvider', + baseUrl: 'https://open.bigmodel.cn/api/anthropic', + key: 'test-token', + proxy: '127.0.0.1:7897', + defaultHaikuModel: 'glm-4.5-air', + defaultSonnetModel: 'glm-4.7', + defaultOpusModel: 'glm-4.7', + }; + + it('会为代理生成标准化的环境变量', () => { + const proxyEntries = buildProxyEnvEntries(provider.proxy); + const values = Object.fromEntries(proxyEntries.map(({ key, value }) => [key, value])); + + expect(values.HTTP_PROXY).toBe('http://127.0.0.1:7897'); + expect(values.HTTPS_PROXY).toBe('http://127.0.0.1:7897'); + expect(values.ALL_PROXY).toBe('http://127.0.0.1:7897'); + expect(values.http_proxy).toBe('http://127.0.0.1:7897'); + expect(values.https_proxy).toBe('http://127.0.0.1:7897'); + }); + + it('会为 Claude 模式生成不含弯引号的 Windows 启动命令,并包含代理变量', () => { + const envEntries = [ + ...buildProxyEnvEntries(provider.proxy), + ...buildClaudeEnvEntries(provider), + ...buildCustomEnvEntries({ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' }), + ]; + + const command = buildWindowsEnvCommand(envEntries, 'claude'); + + expect(command).toContain('set "HTTP_PROXY=http://127.0.0.1:7897"'); + expect(command).toContain('set "HTTPS_PROXY=http://127.0.0.1:7897"'); + expect(command).toContain('set "ANTHROPIC_BASE_URL=https://open.bigmodel.cn/api/anthropic"'); + expect(command).toContain('set "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1"'); + expect(command).not.toMatch(/[“”]/); + expect(command.endsWith(' && claude')).toBe(true); + }); + + it('会为 Claude 模式生成不含弯引号的 POSIX 启动命令', () => { + const envEntries = [ + ...buildProxyEnvEntries(provider.proxy), + ...buildClaudeEnvEntries(provider), + ...buildCustomEnvEntries({ TEST_VALUE: "a'b" }), + ]; + + const command = buildPosixEnvCommand(envEntries, 'claude'); + + expect(command).toContain("export HTTP_PROXY='http://127.0.0.1:7897'"); + expect(command).toContain("export ANTHROPIC_BASE_URL='https://open.bigmodel.cn/api/anthropic'"); + expect(command).toContain(`export TEST_VALUE='a'"'"'b'`); + expect(command).not.toMatch(/[“”]/); + expect(command.endsWith('; claude')).toBe(true); + }); +}); diff --git a/test/utils.test.ts b/test/utils.test.ts index 0a23d9e..7fbc6ec 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -22,11 +22,14 @@ describe('ValidationUtils', () => { }); it('flags invalid provider details', () => { - const errors = ValidationUtils.validateProvider({ - name: '', - baseUrl: 'not-a-url', - key: 'short', - }, 1); + const errors = ValidationUtils.validateProvider( + { + name: '', + baseUrl: 'not-a-url', + key: 'short', + }, + 1 + ); expect(errors.some((msg) => msg.includes('name'))).toBe(true); expect(errors.some((msg) => msg.includes('baseUrl'))).toBe(true); @@ -82,6 +85,12 @@ describe('CliParser', () => { const validation = CliParser.validateOptions({ list: true, add: true }); expect(validation.valid).toBe(false); }); + + it('rejects --codex with --env-only', () => { + const validation = CliParser.validateOptions({ codex: true, envOnly: true }); + expect(validation.valid).toBe(false); + expect(validation.error).toContain('Codex 模式不支持 --env-only'); + }); }); describe('FileUtils', () => {