@tkpdx01/ccc 1.6.3 → 1.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,6 +40,7 @@ ccc delete [profile] # Delete profile
40
40
  ccc sync [profile] # Sync from template, preserve credentials
41
41
  ccc sync --all # Sync all profiles
42
42
  ccc apply [profile] # Write profile config to ~/.claude or ~/.codex
43
+ ccc resettodefault # Restore pre-apply ~/.codex and clean OPENAI env exports
43
44
  ```
44
45
 
45
46
  ### WebDAV Cloud Sync
@@ -69,7 +70,8 @@ Each profile is a directory containing `auth.json` + `config.toml`. Launched via
69
70
  CODEX_HOME=~/.ccc/codex-profiles/<name>/ codex
70
71
  ```
71
72
 
72
- No global environment variables are modified — everything is process-scoped.
73
+ `ccc <profile>` 启动仍是进程级环境变量,不污染全局。
74
+ `ccc apply`(Codex)会同步 `OPENAI_BASE_URL` / `OPENAI_API_KEY` 到 shell rc,并可用 `ccc resettodefault` 回滚。
73
75
 
74
76
  ### Storage
75
77
 
@@ -89,9 +91,9 @@ No global environment variables are modified — everything is process-scoped.
89
91
  - **Dual CLI support** — Claude Code + OpenAI Codex in one tool
90
92
  - **Unified index** — All profiles sorted together, launch by number
91
93
  - **Apply command** — Push a profile's config to `~/.claude` or `~/.codex`
94
+ - **Reset to default** — Restore pre-apply Codex config and shell env exports
92
95
  - **Template sync** — Update from main settings, keep credentials
93
96
  - **Cloud sync** — E2E encrypted WebDAV sync across devices
94
- - **Zero env pollution** — API keys stored in config files, not shell env
95
97
 
96
98
  ## Security
97
99
 
package/index.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  deleteCommand,
18
18
  syncCommand,
19
19
  applyCommand,
20
+ resetToDefaultCommand,
20
21
  webdavCommand,
21
22
  helpCommand
22
23
  } from './src/commands/index.js';
@@ -27,7 +28,7 @@ const program = new Command();
27
28
  program
28
29
  .name('ccc')
29
30
  .description('Claude Code / Codex Settings Launcher - 管理多个 Claude Code 和 Codex 配置文件')
30
- .version('1.6.0');
31
+ .version('1.6.6');
31
32
 
32
33
  // 注册所有命令
33
34
  listCommand(program);
@@ -38,6 +39,7 @@ editCommand(program);
38
39
  deleteCommand(program);
39
40
  syncCommand(program);
40
41
  applyCommand(program);
42
+ resetToDefaultCommand(program);
41
43
  webdavCommand(program);
42
44
  helpCommand(program);
43
45
 
@@ -51,7 +53,7 @@ program
51
53
 
52
54
  if (profile) {
53
55
  // 检查是否是子命令
54
- if (['list', 'ls', 'use', 'show', 'new', 'edit', 'delete', 'rm', 'sync', 'apply', 'webdav', 'help'].includes(profile)) {
56
+ if (['list', 'ls', 'use', 'show', 'new', 'edit', 'delete', 'rm', 'sync', 'apply', 'resettodefault', 'webdav', 'help'].includes(profile)) {
55
57
  return; // 让子命令处理
56
58
  }
57
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tkpdx01/ccc",
3
- "version": "1.6.3",
3
+ "version": "1.6.6",
4
4
  "description": "Claude Code / Codex Settings Launcher - Manage multiple Claude Code and Codex profiles",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -0,0 +1,133 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+
4
+ const REQUEST_TIMEOUT_MS = 8000;
5
+ const MANUAL_INPUT_VALUE = '__manual_input__';
6
+
7
+ function normalizeBaseUrl(baseUrl) {
8
+ const trimmed = (baseUrl || '').trim();
9
+ return trimmed || 'https://api.openai.com/v1';
10
+ }
11
+
12
+ function buildModelsEndpoint(baseUrl) {
13
+ const normalized = normalizeBaseUrl(baseUrl).replace(/\/+$/, '');
14
+ if (normalized.endsWith('/models')) {
15
+ return normalized;
16
+ }
17
+
18
+ let url;
19
+ try {
20
+ url = new URL(normalized);
21
+ } catch {
22
+ return `${normalized}/models`;
23
+ }
24
+
25
+ const path = url.pathname || '/';
26
+ if (path === '/' || path === '') {
27
+ url.pathname = '/v1/models';
28
+ } else {
29
+ url.pathname = `${path.replace(/\/+$/, '')}/models`;
30
+ }
31
+ return url.toString();
32
+ }
33
+
34
+ export async function fetchOpenAIModelIds(baseUrl, apiKey) {
35
+ const token = (apiKey || '').trim();
36
+ if (!token) {
37
+ throw new Error('API Key 为空');
38
+ }
39
+
40
+ const endpoint = buildModelsEndpoint(baseUrl);
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
43
+
44
+ try {
45
+ const response = await fetch(endpoint, {
46
+ method: 'GET',
47
+ headers: {
48
+ Authorization: `Bearer ${token}`,
49
+ Accept: 'application/json'
50
+ },
51
+ signal: controller.signal
52
+ });
53
+
54
+ if (!response.ok) {
55
+ const body = await response.text();
56
+ const snippet = body.replace(/\s+/g, ' ').slice(0, 120);
57
+ throw new Error(`HTTP ${response.status}${snippet ? `: ${snippet}` : ''}`);
58
+ }
59
+
60
+ const data = await response.json();
61
+ const models = Array.isArray(data?.data) ? data.data : [];
62
+
63
+ return models
64
+ .map(item => (typeof item?.id === 'string' ? item.id.trim() : ''))
65
+ .filter(Boolean)
66
+ .sort((a, b) => a.localeCompare(b));
67
+ } finally {
68
+ clearTimeout(timer);
69
+ }
70
+ }
71
+
72
+ export async function promptCodexModel(baseUrl, apiKey, currentModel = '') {
73
+ const current = (currentModel || '').trim();
74
+
75
+ if (!(apiKey || '').trim()) {
76
+ const { model } = await inquirer.prompt([
77
+ {
78
+ type: 'input',
79
+ name: 'model',
80
+ message: 'Model (留空使用默认):',
81
+ default: current
82
+ }
83
+ ]);
84
+ return model;
85
+ }
86
+
87
+ console.log(chalk.gray('正在获取模型列表...'));
88
+
89
+ try {
90
+ const modelIds = await fetchOpenAIModelIds(baseUrl, apiKey);
91
+ if (modelIds.length === 0) {
92
+ throw new Error('返回了空模型列表');
93
+ }
94
+
95
+ const choices = [
96
+ { name: '(默认模型)', value: '' },
97
+ ...modelIds.map(id => ({ name: id, value: id })),
98
+ { name: '手动输入模型 ID', value: MANUAL_INPUT_VALUE }
99
+ ];
100
+
101
+ let defaultChoice = '';
102
+ if (current) {
103
+ defaultChoice = modelIds.includes(current) ? current : MANUAL_INPUT_VALUE;
104
+ }
105
+
106
+ const { selectedModel } = await inquirer.prompt([
107
+ {
108
+ type: 'list',
109
+ name: 'selectedModel',
110
+ message: '选择模型:',
111
+ choices,
112
+ default: defaultChoice
113
+ }
114
+ ]);
115
+
116
+ if (selectedModel !== MANUAL_INPUT_VALUE) {
117
+ return selectedModel;
118
+ }
119
+ } catch (error) {
120
+ const reason = error?.name === 'AbortError' ? '请求超时' : (error?.message || '未知错误');
121
+ console.log(chalk.yellow(`获取模型列表失败,改为手动输入(${reason})`));
122
+ }
123
+
124
+ const { model } = await inquirer.prompt([
125
+ {
126
+ type: 'input',
127
+ name: 'model',
128
+ message: 'Model (留空使用默认):',
129
+ default: current
130
+ }
131
+ ]);
132
+ return model;
133
+ }
@@ -69,8 +69,21 @@ export function applyCommand(program) {
69
69
  result = applyClaudeProfile(profileInfo.name);
70
70
  }
71
71
 
72
- if (result) {
72
+ const success = profileInfo.type === 'codex'
73
+ ? Boolean(result && (result.success ?? result))
74
+ : Boolean(result);
75
+
76
+ if (success) {
73
77
  console.log(chalk.green(`\n✓ ${typeLabel} 配置 "${profileInfo.name}" 已应用到 ${targetDir}`));
78
+
79
+ if (profileInfo.type === 'codex' && result?.envSync?.filePath) {
80
+ const home = process.env.HOME || '';
81
+ const rcPathDisplay = home && result.envSync.filePath.startsWith(home)
82
+ ? `~${result.envSync.filePath.slice(home.length)}`
83
+ : result.envSync.filePath;
84
+ console.log(chalk.gray(` 已同步 OPENAI_BASE_URL / OPENAI_API_KEY 到 ${rcPathDisplay}`));
85
+ console.log(chalk.gray(` 当前终端可执行: source ${rcPathDisplay}`));
86
+ }
74
87
  } else {
75
88
  console.log(chalk.red(`\n✗ 应用失败`));
76
89
  process.exit(1);
@@ -4,8 +4,6 @@ import inquirer from 'inquirer';
4
4
  import {
5
5
  getAllProfiles,
6
6
  getDefaultProfile,
7
- profileExists,
8
- codexProfileExists,
9
7
  anyProfileExists,
10
8
  getProfilePath,
11
9
  readProfile,
@@ -22,6 +20,7 @@ import {
22
20
  createCodexProfile,
23
21
  deleteCodexProfile
24
22
  } from '../profiles.js';
23
+ import { promptCodexModel } from '../codex-models.js';
25
24
 
26
25
  export function editCommand(program) {
27
26
  program
@@ -67,18 +66,12 @@ export function editCommand(program) {
67
66
  const { apiKey: currentApiKey, baseUrl: currentBaseUrl, model: currentModel } = getCodexProfileCredentials(profileInfo.name);
68
67
 
69
68
  console.log(chalk.cyan(`\n当前配置 (${profileInfo.name}) ${chalk.blue('[Codex]')}:`));
70
- console.log(chalk.gray(` OPENAI_API_KEY: ${currentApiKey ? currentApiKey.substring(0, 10) + '...' : '未设置'}`));
71
69
  console.log(chalk.gray(` Base URL: ${currentBaseUrl || '未设置'}`));
70
+ console.log(chalk.gray(` OPENAI_API_KEY: ${currentApiKey ? currentApiKey.substring(0, 10) + '...' : '未设置'}`));
72
71
  console.log(chalk.gray(` Model: ${currentModel || '(默认)'}`));
73
72
  console.log();
74
73
 
75
- const { apiKey, baseUrl, model, newName } = await inquirer.prompt([
76
- {
77
- type: 'input',
78
- name: 'apiKey',
79
- message: 'OPENAI_API_KEY:',
80
- default: currentApiKey || ''
81
- },
74
+ const { apiKey, baseUrl, newName } = await inquirer.prompt([
82
75
  {
83
76
  type: 'input',
84
77
  name: 'baseUrl',
@@ -87,9 +80,9 @@ export function editCommand(program) {
87
80
  },
88
81
  {
89
82
  type: 'input',
90
- name: 'model',
91
- message: 'Model (留空使用默认):',
92
- default: currentModel || ''
83
+ name: 'apiKey',
84
+ message: 'OPENAI_API_KEY:',
85
+ default: currentApiKey || ''
93
86
  },
94
87
  {
95
88
  type: 'input',
@@ -98,6 +91,7 @@ export function editCommand(program) {
98
91
  default: profileInfo.name
99
92
  }
100
93
  ]);
94
+ const model = await promptCodexModel(baseUrl, apiKey, currentModel || '');
101
95
 
102
96
  if (newName && newName !== profileInfo.name) {
103
97
  const check = anyProfileExists(newName);
@@ -19,6 +19,7 @@ export function showHelp() {
19
19
  console.log(chalk.gray(' ccc sync [profile] ') + '从模板同步配置(保留 API 凭证)');
20
20
  console.log(chalk.gray(' ccc sync --all ') + '同步所有配置');
21
21
  console.log(chalk.gray(' ccc apply [profile] ') + '将配置应用到默认目录(~/.claude 或 ~/.codex)');
22
+ console.log(chalk.gray(' ccc resettodefault ') + '恢复 apply 前的 ~/.codex 配置并移除 OPENAI 环境变量');
22
23
  console.log(chalk.gray(' ccc edit [profile] ') + '编辑配置');
23
24
  console.log(chalk.gray(' ccc delete, rm [name] ') + '删除配置');
24
25
  console.log(chalk.gray(' ccc help ') + '显示此帮助信息');
@@ -42,7 +43,7 @@ export function showHelp() {
42
43
  console.log(chalk.gray(' ccc new ') + '交互式创建,选择 Claude 或 Codex 类型');
43
44
  console.log(chalk.gray(' ccc new myprofile ') + '指定名称创建,随后选择类型并填写凭证');
44
45
  console.log(chalk.gray(' ') + chalk.dim('Claude 需要: ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN'));
45
- console.log(chalk.gray(' ') + chalk.dim('Codex 需要: OPENAI_API_KEY + Base URL + Model(可选)'));
46
+ console.log(chalk.gray(' ') + chalk.dim('Codex 需要: Base URL + OPENAI_API_KEY + Model(可从接口拉取后选择)'));
46
47
  console.log();
47
48
 
48
49
  console.log(chalk.yellow(' 示例:'));
@@ -6,6 +6,6 @@ export { editCommand } from './edit.js';
6
6
  export { deleteCommand } from './delete.js';
7
7
  export { syncCommand } from './sync.js';
8
8
  export { applyCommand } from './apply.js';
9
+ export { resetToDefaultCommand } from './resettodefault.js';
9
10
  export { webdavCommand } from './webdav.js';
10
11
  export { helpCommand, showHelp } from './help.js';
11
-
@@ -2,10 +2,7 @@ import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
3
  import {
4
4
  ensureDirs,
5
- getProfiles,
6
5
  getAllProfiles,
7
- profileExists,
8
- codexProfileExists,
9
6
  anyProfileExists,
10
7
  createProfileFromTemplate,
11
8
  createCodexProfile,
@@ -13,6 +10,7 @@ import {
13
10
  ensureClaudeSettingsExtras
14
11
  } from '../profiles.js';
15
12
  import { launchClaude, launchCodex } from '../launch.js';
13
+ import { promptCodexModel } from '../codex-models.js';
16
14
 
17
15
  const RESERVED_PROFILE_NAMES = [
18
16
  'list',
@@ -27,6 +25,7 @@ const RESERVED_PROFILE_NAMES = [
27
25
  'rm',
28
26
  'sync',
29
27
  'apply',
28
+ 'resettodefault',
30
29
  'webdav',
31
30
  'help'
32
31
  ];
@@ -104,12 +103,6 @@ export function newCommand(program) {
104
103
  if (profileType === 'codex') {
105
104
  // Codex profile 创建
106
105
  const answers = await inquirer.prompt([
107
- {
108
- type: 'input',
109
- name: 'apiKey',
110
- message: 'OPENAI_API_KEY:',
111
- default: ''
112
- },
113
106
  {
114
107
  type: 'input',
115
108
  name: 'baseUrl',
@@ -118,8 +111,8 @@ export function newCommand(program) {
118
111
  },
119
112
  {
120
113
  type: 'input',
121
- name: 'model',
122
- message: 'Model (留空使用默认):',
114
+ name: 'apiKey',
115
+ message: 'OPENAI_API_KEY:',
123
116
  default: ''
124
117
  },
125
118
  {
@@ -130,6 +123,7 @@ export function newCommand(program) {
130
123
  validate: validateProfileName
131
124
  }
132
125
  ]);
126
+ const model = await promptCodexModel(answers.baseUrl, answers.apiKey, '');
133
127
 
134
128
  const finalName = answers.finalName;
135
129
  if (finalName !== name) {
@@ -151,7 +145,7 @@ export function newCommand(program) {
151
145
  }
152
146
 
153
147
  ensureDirs();
154
- createCodexProfile(finalName, answers.apiKey, answers.baseUrl, answers.model);
148
+ createCodexProfile(finalName, answers.apiKey, answers.baseUrl, model);
155
149
  console.log(chalk.green(`\n✓ Codex 配置 "${finalName}" 已创建`));
156
150
 
157
151
  const allProfiles = getAllProfiles();
@@ -0,0 +1,42 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { resetCodexDefaultProfile } from '../profiles.js';
4
+
5
+ export function resetToDefaultCommand(program) {
6
+ program
7
+ .command('resettodefault')
8
+ .description('恢复 apply 前的 ~/.codex 配置,并移除 OPENAI 相关环境变量')
9
+ .action(async () => {
10
+ const { confirm } = await inquirer.prompt([
11
+ {
12
+ type: 'confirm',
13
+ name: 'confirm',
14
+ message: '恢复 ~/.codex 到 apply 前状态,并清理 OPENAI 环境变量?',
15
+ default: false
16
+ }
17
+ ]);
18
+
19
+ if (!confirm) {
20
+ console.log(chalk.yellow('已取消'));
21
+ process.exit(0);
22
+ }
23
+
24
+ const result = resetCodexDefaultProfile();
25
+ if (!result.success) {
26
+ if (result.reason === 'no_backup') {
27
+ console.log(chalk.yellow('未找到可恢复的备份(请先执行一次 ccc apply <codex-profile>)'));
28
+ process.exit(0);
29
+ }
30
+ console.log(chalk.red('恢复失败:备份状态文件损坏'));
31
+ process.exit(1);
32
+ }
33
+
34
+ const home = process.env.HOME || '';
35
+ const rcPathDisplay = home && result.shellRcPath.startsWith(home)
36
+ ? `~${result.shellRcPath.slice(home.length)}`
37
+ : result.shellRcPath;
38
+
39
+ console.log(chalk.green('✓ 已恢复 ~/.codex 原始配置'));
40
+ console.log(chalk.green(`✓ 已清理/还原 ${rcPathDisplay} 中的 OPENAI 环境变量`));
41
+ });
42
+ }
package/src/profiles.js CHANGED
@@ -336,6 +336,218 @@ export function clearDefaultProfile() {
336
336
  // Codex Profile 管理
337
337
  // ============================================================
338
338
 
339
+ const OPENAI_DEFAULT_BASE_URL = 'https://api.openai.com/v1';
340
+ const CCC_OPENAI_COMPAT_PROVIDER = 'ccc_openai';
341
+ const CODEX_RESET_DIR = path.join(CODEX_HOME_PATH, '.ccc-reset-default');
342
+ const CODEX_RESET_AUTH_BACKUP = path.join(CODEX_RESET_DIR, 'auth.json.original');
343
+ const CODEX_RESET_CONFIG_BACKUP = path.join(CODEX_RESET_DIR, 'config.toml.original');
344
+ const CODEX_RESET_META_PATH = path.join(CODEX_RESET_DIR, 'meta.json');
345
+
346
+ function normalizeBaseUrl(baseUrl) {
347
+ return (baseUrl || '').trim().replace(/\/+$/, '');
348
+ }
349
+
350
+ function isCustomOpenAIBaseUrl(baseUrl) {
351
+ const normalized = normalizeBaseUrl(baseUrl);
352
+ return normalized && normalized !== normalizeBaseUrl(OPENAI_DEFAULT_BASE_URL);
353
+ }
354
+
355
+ function extractBaseUrlFromConfigToml(configToml) {
356
+ if (!configToml) return OPENAI_DEFAULT_BASE_URL;
357
+ const baseUrlMatch = configToml.match(/base_url\s*=\s*"([^"]+)"/);
358
+ return baseUrlMatch ? baseUrlMatch[1] : OPENAI_DEFAULT_BASE_URL;
359
+ }
360
+
361
+ function upsertTomlKey(block, key, valueLiteral) {
362
+ const keyPattern = new RegExp(`^\\s*${key}\\s*=\\s*.*$`, 'm');
363
+ if (keyPattern.test(block)) {
364
+ return block.replace(keyPattern, `${key} = ${valueLiteral}`);
365
+ }
366
+ return `${block.trimEnd()}\n${key} = ${valueLiteral}\n`;
367
+ }
368
+
369
+ function escapeRegExp(value) {
370
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
371
+ }
372
+
373
+ function escapeShellSingleQuote(value) {
374
+ return String(value ?? '').replace(/'/g, `'\"'\"'`);
375
+ }
376
+
377
+ function getPreferredShellRcPath() {
378
+ const shellPath = (process.env.SHELL || '').toLowerCase();
379
+ if (shellPath.includes('zsh')) {
380
+ return path.join(os.homedir(), '.zshrc');
381
+ }
382
+ if (shellPath.includes('bash')) {
383
+ return path.join(os.homedir(), '.bashrc');
384
+ }
385
+ return path.join(os.homedir(), '.profile');
386
+ }
387
+
388
+ function stripShellExport(content, key) {
389
+ const pattern = new RegExp(`^\\s*export\\s+${escapeRegExp(key)}=.*(?:\\r?\\n)?`, 'gm');
390
+ return content.replace(pattern, '');
391
+ }
392
+
393
+ function extractShellExportLine(content, key) {
394
+ const pattern = new RegExp(`^\\s*export\\s+${escapeRegExp(key)}=.*$`, 'm');
395
+ const match = content.match(pattern);
396
+ return match ? match[0] : '';
397
+ }
398
+
399
+ function upsertShellExport(content, key, rawValue) {
400
+ if (rawValue === undefined || rawValue === null || rawValue === '') {
401
+ return { content, changed: false };
402
+ }
403
+
404
+ const line = `export ${key}='${escapeShellSingleQuote(rawValue)}'`;
405
+ let nextContent = stripShellExport(content, key);
406
+ if (nextContent && !nextContent.endsWith('\n')) {
407
+ nextContent += '\n';
408
+ }
409
+ nextContent += `${line}\n`;
410
+ return { content: nextContent, changed: nextContent !== content };
411
+ }
412
+
413
+ function upsertShellExportLine(content, key, line) {
414
+ let nextContent = stripShellExport(content, key);
415
+ if (!line) {
416
+ return { content: nextContent, changed: nextContent !== content };
417
+ }
418
+ if (nextContent && !nextContent.endsWith('\n')) {
419
+ nextContent += '\n';
420
+ }
421
+ nextContent += `${line}\n`;
422
+ return { content: nextContent, changed: nextContent !== content };
423
+ }
424
+
425
+ function ensureCodexResetBackup(shellRcPath) {
426
+ if (fs.existsSync(CODEX_RESET_META_PATH)) {
427
+ return;
428
+ }
429
+
430
+ if (!fs.existsSync(CODEX_RESET_DIR)) {
431
+ fs.mkdirSync(CODEX_RESET_DIR, { recursive: true });
432
+ }
433
+
434
+ const authPath = path.join(CODEX_HOME_PATH, 'auth.json');
435
+ const configPath = path.join(CODEX_HOME_PATH, 'config.toml');
436
+ const authExisted = fs.existsSync(authPath);
437
+ const configExisted = fs.existsSync(configPath);
438
+
439
+ if (authExisted) {
440
+ fs.copyFileSync(authPath, CODEX_RESET_AUTH_BACKUP);
441
+ }
442
+ if (configExisted) {
443
+ fs.copyFileSync(configPath, CODEX_RESET_CONFIG_BACKUP);
444
+ }
445
+
446
+ const rcContent = fs.existsSync(shellRcPath) ? fs.readFileSync(shellRcPath, 'utf-8') : '';
447
+ const meta = {
448
+ version: 1,
449
+ createdAt: new Date().toISOString(),
450
+ authExisted,
451
+ configExisted,
452
+ shellRcPath,
453
+ originalExports: {
454
+ OPENAI_BASE_URL: extractShellExportLine(rcContent, 'OPENAI_BASE_URL'),
455
+ OPENAI_API_KEY: extractShellExportLine(rcContent, 'OPENAI_API_KEY')
456
+ }
457
+ };
458
+
459
+ fs.writeFileSync(CODEX_RESET_META_PATH, JSON.stringify(meta, null, 2) + '\n');
460
+ }
461
+
462
+ function syncCodexEnvToShell(baseUrl, apiKey, shellRcPath) {
463
+ const rcPath = shellRcPath || getPreferredShellRcPath();
464
+ const current = fs.existsSync(rcPath) ? fs.readFileSync(rcPath, 'utf-8') : '';
465
+
466
+ let next = current;
467
+ let changed = false;
468
+
469
+ const baseUrlResult = upsertShellExport(next, 'OPENAI_BASE_URL', baseUrl);
470
+ next = baseUrlResult.content;
471
+ changed = changed || baseUrlResult.changed;
472
+
473
+ const apiKeyResult = upsertShellExport(next, 'OPENAI_API_KEY', apiKey);
474
+ next = apiKeyResult.content;
475
+ changed = changed || apiKeyResult.changed;
476
+
477
+ if (changed) {
478
+ fs.writeFileSync(rcPath, next);
479
+ }
480
+
481
+ return { filePath: rcPath, changed };
482
+ }
483
+
484
+ function normalizeCodexAuthForApply(auth) {
485
+ if (!auth || typeof auth !== 'object' || Array.isArray(auth)) {
486
+ return auth;
487
+ }
488
+ const apiKey = typeof auth.OPENAI_API_KEY === 'string' ? auth.OPENAI_API_KEY : '';
489
+ if (apiKey) {
490
+ return { auth_mode: 'apikey', OPENAI_API_KEY: apiKey };
491
+ }
492
+ return auth;
493
+ }
494
+
495
+ function ensureCodexOpenAICompatConfig(configToml, baseUrl) {
496
+ if (!isCustomOpenAIBaseUrl(baseUrl)) {
497
+ return configToml;
498
+ }
499
+
500
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
501
+ let output = configToml || '# Codex profile managed by ccc\n';
502
+ const firstSectionMatch = output.match(/^\s*\[[^\]]+\]/m);
503
+ const firstSectionIndex = firstSectionMatch && firstSectionMatch.index !== undefined
504
+ ? firstSectionMatch.index
505
+ : output.length;
506
+
507
+ let preamble = output.slice(0, firstSectionIndex);
508
+ const rest = output.slice(firstSectionIndex);
509
+
510
+ // 如果用户显式指定了非 openai provider,尊重用户配置,不自动覆盖
511
+ const providerMatch = preamble.match(/^\s*model_provider\s*=\s*"([^"]+)"/m);
512
+ if (providerMatch && !['openai', CCC_OPENAI_COMPAT_PROVIDER].includes(providerMatch[1])) {
513
+ return output;
514
+ }
515
+
516
+ if (providerMatch) {
517
+ preamble = preamble.replace(
518
+ /^\s*model_provider\s*=\s*"([^"]+)"/m,
519
+ `model_provider = "${CCC_OPENAI_COMPAT_PROVIDER}"`
520
+ );
521
+ } else {
522
+ preamble = `${preamble.replace(/\s*$/, '\n')}model_provider = "${CCC_OPENAI_COMPAT_PROVIDER}"\n`;
523
+ }
524
+
525
+ if (rest.trim()) {
526
+ output = `${preamble.trimEnd()}\n\n${rest.replace(/^\s*/, '')}`;
527
+ } else {
528
+ output = `${preamble.trimEnd()}\n`;
529
+ }
530
+
531
+ const sectionPattern = new RegExp(
532
+ `\\[model_providers\\.${CCC_OPENAI_COMPAT_PROVIDER}\\][\\s\\S]*?(?=\\n\\[|$)`
533
+ );
534
+
535
+ if (sectionPattern.test(output)) {
536
+ output = output.replace(sectionPattern, (section) => {
537
+ let next = section;
538
+ next = upsertTomlKey(next, 'name', '"OpenAI Compatible"');
539
+ next = upsertTomlKey(next, 'base_url', `"${normalizedBaseUrl}"`);
540
+ next = upsertTomlKey(next, 'wire_api', '"responses"');
541
+ next = upsertTomlKey(next, 'requires_openai_auth', 'true');
542
+ return next.trimEnd();
543
+ });
544
+ } else {
545
+ output = `${output.trimEnd()}\n\n[model_providers.${CCC_OPENAI_COMPAT_PROVIDER}]\nname = "OpenAI Compatible"\nbase_url = "${normalizedBaseUrl}"\nwire_api = "responses"\nrequires_openai_auth = true\n`;
546
+ }
547
+
548
+ return `${output.trimEnd()}\n`;
549
+ }
550
+
339
551
  // 获取 Codex profile 目录路径
340
552
  export function getCodexProfileDir(name) {
341
553
  return path.join(CODEX_PROFILES_DIR, name);
@@ -388,16 +600,20 @@ export function saveCodexProfile(name, auth, configToml) {
388
600
  // 生成 Codex config.toml 内容
389
601
  export function generateCodexConfigToml(baseUrl, model) {
390
602
  let lines = ['# Codex profile managed by ccc'];
603
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl) || OPENAI_DEFAULT_BASE_URL;
391
604
 
392
605
  if (model) {
393
606
  lines.push(`model = "${model}"`);
394
607
  }
395
608
 
396
- if (baseUrl && baseUrl !== 'https://api.openai.com/v1') {
609
+ if (isCustomOpenAIBaseUrl(normalizedBaseUrl)) {
610
+ lines.push(`model_provider = "${CCC_OPENAI_COMPAT_PROVIDER}"`);
397
611
  lines.push('');
398
- lines.push('[model_providers.openai]');
399
- lines.push(`name = "OpenAI"`);
400
- lines.push(`base_url = "${baseUrl}"`);
612
+ lines.push(`[model_providers.${CCC_OPENAI_COMPAT_PROVIDER}]`);
613
+ lines.push('name = "OpenAI Compatible"');
614
+ lines.push(`base_url = "${normalizedBaseUrl}"`);
615
+ lines.push('wire_api = "responses"');
616
+ lines.push('requires_openai_auth = true');
401
617
  }
402
618
 
403
619
  lines.push('');
@@ -432,7 +648,7 @@ export function getCodexProfileCredentials(name) {
432
648
  if (modelMatch) model = modelMatch[1];
433
649
  }
434
650
 
435
- return { apiKey, baseUrl: baseUrl || 'https://api.openai.com/v1', model: model || '' };
651
+ return { apiKey, baseUrl: baseUrl || OPENAI_DEFAULT_BASE_URL, model: model || '' };
436
652
  }
437
653
 
438
654
  // 删除 Codex profile
@@ -457,20 +673,6 @@ export function syncCodexProfileWithTemplate(name) {
457
673
  // 保留当前 profile 的 base_url 和 model
458
674
  const { baseUrl, model } = getCodexProfileCredentials(name);
459
675
 
460
- // 在模板基础上覆盖 base_url 和 model
461
- // 如果当前 profile 有自定义 base_url,追加到模板
462
- if (baseUrl && baseUrl !== 'https://api.openai.com/v1') {
463
- // 检查模板是否已有 [model_providers.openai] 节
464
- if (templateConfig.includes('[model_providers.openai]')) {
465
- templateConfig = templateConfig.replace(
466
- /(\[model_providers\.openai\][^\[]*?)base_url\s*=\s*"[^"]*"/,
467
- `$1base_url = "${baseUrl}"`
468
- );
469
- } else {
470
- templateConfig += `\n[model_providers.openai]\nbase_url = "${baseUrl}"\n`;
471
- }
472
- }
473
-
474
676
  if (model) {
475
677
  if (templateConfig.match(/^model\s*=/m)) {
476
678
  templateConfig = templateConfig.replace(/^model\s*=\s*"[^"]*"/m, `model = "${model}"`);
@@ -479,6 +681,9 @@ export function syncCodexProfileWithTemplate(name) {
479
681
  }
480
682
  }
481
683
 
684
+ // 对第三方 base_url 自动补齐 provider 兼容配置,避免依赖 OPENAI_BASE_URL 环境变量
685
+ templateConfig = ensureCodexOpenAICompatConfig(templateConfig, baseUrl);
686
+
482
687
  saveCodexProfile(name, current.auth, templateConfig);
483
688
  return { auth: current.auth, configToml: templateConfig };
484
689
  }
@@ -496,6 +701,13 @@ export function applyCodexProfile(name) {
496
701
  const profile = readCodexProfile(name);
497
702
  if (!profile) return false;
498
703
 
704
+ const shellRcPath = getPreferredShellRcPath();
705
+ const baseUrl = extractBaseUrlFromConfigToml(profile.configToml);
706
+ const apiKey = profile.auth?.OPENAI_API_KEY || '';
707
+
708
+ // 首次 apply 时备份 ~/.codex 与 shell env 现场,供 resettodefault 回滚
709
+ ensureCodexResetBackup(shellRcPath);
710
+
499
711
  if (!fs.existsSync(CODEX_HOME_PATH)) {
500
712
  fs.mkdirSync(CODEX_HOME_PATH, { recursive: true });
501
713
  }
@@ -503,15 +715,75 @@ export function applyCodexProfile(name) {
503
715
  // 写入 auth.json
504
716
  fs.writeFileSync(
505
717
  path.join(CODEX_HOME_PATH, 'auth.json'),
506
- JSON.stringify(profile.auth, null, 2) + '\n'
718
+ JSON.stringify(normalizeCodexAuthForApply(profile.auth), null, 2) + '\n'
507
719
  );
508
720
 
509
721
  // 写入 config.toml(如果有内容)
510
722
  if (profile.configToml && profile.configToml.trim()) {
511
- fs.writeFileSync(path.join(CODEX_HOME_PATH, 'config.toml'), profile.configToml);
723
+ const compatConfig = ensureCodexOpenAICompatConfig(profile.configToml, baseUrl);
724
+ fs.writeFileSync(path.join(CODEX_HOME_PATH, 'config.toml'), compatConfig);
512
725
  }
513
726
 
514
- return true;
727
+ const envSync = syncCodexEnvToShell(baseUrl, apiKey, shellRcPath);
728
+
729
+ return { success: true, envSync };
730
+ }
731
+
732
+ // 恢复 apply 前的 ~/.codex 配置,并移除/还原相关 OPENAI 环境变量
733
+ export function resetCodexDefaultProfile() {
734
+ if (!fs.existsSync(CODEX_RESET_META_PATH)) {
735
+ return { success: false, reason: 'no_backup' };
736
+ }
737
+
738
+ let meta;
739
+ try {
740
+ meta = JSON.parse(fs.readFileSync(CODEX_RESET_META_PATH, 'utf-8'));
741
+ } catch {
742
+ return { success: false, reason: 'invalid_backup' };
743
+ }
744
+
745
+ if (!fs.existsSync(CODEX_HOME_PATH)) {
746
+ fs.mkdirSync(CODEX_HOME_PATH, { recursive: true });
747
+ }
748
+
749
+ const authPath = path.join(CODEX_HOME_PATH, 'auth.json');
750
+ const configPath = path.join(CODEX_HOME_PATH, 'config.toml');
751
+
752
+ if (meta.authExisted && fs.existsSync(CODEX_RESET_AUTH_BACKUP)) {
753
+ fs.copyFileSync(CODEX_RESET_AUTH_BACKUP, authPath);
754
+ } else if (fs.existsSync(authPath)) {
755
+ fs.unlinkSync(authPath);
756
+ }
757
+
758
+ if (meta.configExisted && fs.existsSync(CODEX_RESET_CONFIG_BACKUP)) {
759
+ fs.copyFileSync(CODEX_RESET_CONFIG_BACKUP, configPath);
760
+ } else if (fs.existsSync(configPath)) {
761
+ fs.unlinkSync(configPath);
762
+ }
763
+
764
+ const shellRcPath = meta.shellRcPath || getPreferredShellRcPath();
765
+ const rcExists = fs.existsSync(shellRcPath);
766
+ const rcBefore = rcExists ? fs.readFileSync(shellRcPath, 'utf-8') : '';
767
+ let rcAfter = rcBefore;
768
+
769
+ const originalBaseUrlLine = meta.originalExports?.OPENAI_BASE_URL || '';
770
+ const originalApiKeyLine = meta.originalExports?.OPENAI_API_KEY || '';
771
+
772
+ rcAfter = upsertShellExportLine(rcAfter, 'OPENAI_BASE_URL', originalBaseUrlLine).content;
773
+ rcAfter = upsertShellExportLine(rcAfter, 'OPENAI_API_KEY', originalApiKeyLine).content;
774
+
775
+ const envChanged = rcAfter !== rcBefore;
776
+ if (envChanged || (!rcExists && (originalBaseUrlLine || originalApiKeyLine))) {
777
+ fs.writeFileSync(shellRcPath, rcAfter);
778
+ }
779
+
780
+ fs.rmSync(CODEX_RESET_DIR, { recursive: true, force: true });
781
+
782
+ return {
783
+ success: true,
784
+ shellRcPath,
785
+ envChanged
786
+ };
515
787
  }
516
788
 
517
789
  // ============================================================