@tkpdx01/ccc 1.6.2 → 1.6.5

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
@@ -1,7 +1,5 @@
1
1
  # ccc — Claude Code / Codex Launcher
2
2
 
3
- ![ccc cover](cover.png)
4
-
5
3
  Manage multiple API profiles for **Claude Code** and **OpenAI Codex**. Switch between providers, keys, and endpoints instantly.
6
4
 
7
5
  管理 **Claude Code** 和 **OpenAI Codex** 的多套 API 配置,一键切换 Provider、Key 和 Endpoint。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tkpdx01/ccc",
3
- "version": "1.6.2",
3
+ "version": "1.6.5",
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
+ }
@@ -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);
@@ -42,7 +42,7 @@ export function showHelp() {
42
42
  console.log(chalk.gray(' ccc new ') + '交互式创建,选择 Claude 或 Codex 类型');
43
43
  console.log(chalk.gray(' ccc new myprofile ') + '指定名称创建,随后选择类型并填写凭证');
44
44
  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(可选)'));
45
+ console.log(chalk.gray(' ') + chalk.dim('Codex 需要: Base URL + OPENAI_API_KEY + Model(可从接口拉取后选择)'));
46
46
  console.log();
47
47
 
48
48
  console.log(chalk.yellow(' 示例:'));
@@ -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',
@@ -104,12 +102,6 @@ export function newCommand(program) {
104
102
  if (profileType === 'codex') {
105
103
  // Codex profile 创建
106
104
  const answers = await inquirer.prompt([
107
- {
108
- type: 'input',
109
- name: 'apiKey',
110
- message: 'OPENAI_API_KEY:',
111
- default: ''
112
- },
113
105
  {
114
106
  type: 'input',
115
107
  name: 'baseUrl',
@@ -118,8 +110,8 @@ export function newCommand(program) {
118
110
  },
119
111
  {
120
112
  type: 'input',
121
- name: 'model',
122
- message: 'Model (留空使用默认):',
113
+ name: 'apiKey',
114
+ message: 'OPENAI_API_KEY:',
123
115
  default: ''
124
116
  },
125
117
  {
@@ -130,6 +122,7 @@ export function newCommand(program) {
130
122
  validate: validateProfileName
131
123
  }
132
124
  ]);
125
+ const model = await promptCodexModel(answers.baseUrl, answers.apiKey, '');
133
126
 
134
127
  const finalName = answers.finalName;
135
128
  if (finalName !== name) {
@@ -151,7 +144,7 @@ export function newCommand(program) {
151
144
  }
152
145
 
153
146
  ensureDirs();
154
- createCodexProfile(finalName, answers.apiKey, answers.baseUrl, answers.model);
147
+ createCodexProfile(finalName, answers.apiKey, answers.baseUrl, model);
155
148
  console.log(chalk.green(`\n✓ Codex 配置 "${finalName}" 已创建`));
156
149
 
157
150
  const allProfiles = getAllProfiles();
package/src/profiles.js CHANGED
@@ -336,6 +336,88 @@ 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
+
342
+ function normalizeBaseUrl(baseUrl) {
343
+ return (baseUrl || '').trim().replace(/\/+$/, '');
344
+ }
345
+
346
+ function isCustomOpenAIBaseUrl(baseUrl) {
347
+ const normalized = normalizeBaseUrl(baseUrl);
348
+ return normalized && normalized !== normalizeBaseUrl(OPENAI_DEFAULT_BASE_URL);
349
+ }
350
+
351
+ function extractBaseUrlFromConfigToml(configToml) {
352
+ if (!configToml) return OPENAI_DEFAULT_BASE_URL;
353
+ const baseUrlMatch = configToml.match(/base_url\s*=\s*"([^"]+)"/);
354
+ return baseUrlMatch ? baseUrlMatch[1] : OPENAI_DEFAULT_BASE_URL;
355
+ }
356
+
357
+ function upsertTomlKey(block, key, valueLiteral) {
358
+ const keyPattern = new RegExp(`^\\s*${key}\\s*=\\s*.*$`, 'm');
359
+ if (keyPattern.test(block)) {
360
+ return block.replace(keyPattern, `${key} = ${valueLiteral}`);
361
+ }
362
+ return `${block.trimEnd()}\n${key} = ${valueLiteral}\n`;
363
+ }
364
+
365
+ function ensureCodexOpenAICompatConfig(configToml, baseUrl) {
366
+ if (!isCustomOpenAIBaseUrl(baseUrl)) {
367
+ return configToml;
368
+ }
369
+
370
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
371
+ let output = configToml || '# Codex profile managed by ccc\n';
372
+ const firstSectionMatch = output.match(/^\s*\[[^\]]+\]/m);
373
+ const firstSectionIndex = firstSectionMatch && firstSectionMatch.index !== undefined
374
+ ? firstSectionMatch.index
375
+ : output.length;
376
+
377
+ let preamble = output.slice(0, firstSectionIndex);
378
+ const rest = output.slice(firstSectionIndex);
379
+
380
+ // 如果用户显式指定了非 openai provider,尊重用户配置,不自动覆盖
381
+ const providerMatch = preamble.match(/^\s*model_provider\s*=\s*"([^"]+)"/m);
382
+ if (providerMatch && !['openai', CCC_OPENAI_COMPAT_PROVIDER].includes(providerMatch[1])) {
383
+ return output;
384
+ }
385
+
386
+ if (providerMatch) {
387
+ preamble = preamble.replace(
388
+ /^\s*model_provider\s*=\s*"([^"]+)"/m,
389
+ `model_provider = "${CCC_OPENAI_COMPAT_PROVIDER}"`
390
+ );
391
+ } else {
392
+ preamble = `${preamble.replace(/\s*$/, '\n')}model_provider = "${CCC_OPENAI_COMPAT_PROVIDER}"\n`;
393
+ }
394
+
395
+ if (rest.trim()) {
396
+ output = `${preamble.trimEnd()}\n\n${rest.replace(/^\s*/, '')}`;
397
+ } else {
398
+ output = `${preamble.trimEnd()}\n`;
399
+ }
400
+
401
+ const sectionPattern = new RegExp(
402
+ `\\[model_providers\\.${CCC_OPENAI_COMPAT_PROVIDER}\\][\\s\\S]*?(?=\\n\\[|$)`
403
+ );
404
+
405
+ if (sectionPattern.test(output)) {
406
+ output = output.replace(sectionPattern, (section) => {
407
+ let next = section;
408
+ next = upsertTomlKey(next, 'name', '"OpenAI Compatible"');
409
+ next = upsertTomlKey(next, 'base_url', `"${normalizedBaseUrl}"`);
410
+ next = upsertTomlKey(next, 'wire_api', '"responses"');
411
+ next = upsertTomlKey(next, 'requires_openai_auth', 'true');
412
+ return next.trimEnd();
413
+ });
414
+ } else {
415
+ 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`;
416
+ }
417
+
418
+ return `${output.trimEnd()}\n`;
419
+ }
420
+
339
421
  // 获取 Codex profile 目录路径
340
422
  export function getCodexProfileDir(name) {
341
423
  return path.join(CODEX_PROFILES_DIR, name);
@@ -388,16 +470,20 @@ export function saveCodexProfile(name, auth, configToml) {
388
470
  // 生成 Codex config.toml 内容
389
471
  export function generateCodexConfigToml(baseUrl, model) {
390
472
  let lines = ['# Codex profile managed by ccc'];
473
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl) || OPENAI_DEFAULT_BASE_URL;
391
474
 
392
475
  if (model) {
393
476
  lines.push(`model = "${model}"`);
394
477
  }
395
478
 
396
- if (baseUrl && baseUrl !== 'https://api.openai.com/v1') {
479
+ if (isCustomOpenAIBaseUrl(normalizedBaseUrl)) {
480
+ lines.push(`model_provider = "${CCC_OPENAI_COMPAT_PROVIDER}"`);
397
481
  lines.push('');
398
- lines.push('[model_providers.openai]');
399
- lines.push(`name = "OpenAI"`);
400
- lines.push(`base_url = "${baseUrl}"`);
482
+ lines.push(`[model_providers.${CCC_OPENAI_COMPAT_PROVIDER}]`);
483
+ lines.push('name = "OpenAI Compatible"');
484
+ lines.push(`base_url = "${normalizedBaseUrl}"`);
485
+ lines.push('wire_api = "responses"');
486
+ lines.push('requires_openai_auth = true');
401
487
  }
402
488
 
403
489
  lines.push('');
@@ -432,7 +518,7 @@ export function getCodexProfileCredentials(name) {
432
518
  if (modelMatch) model = modelMatch[1];
433
519
  }
434
520
 
435
- return { apiKey, baseUrl: baseUrl || 'https://api.openai.com/v1', model: model || '' };
521
+ return { apiKey, baseUrl: baseUrl || OPENAI_DEFAULT_BASE_URL, model: model || '' };
436
522
  }
437
523
 
438
524
  // 删除 Codex profile
@@ -457,20 +543,6 @@ export function syncCodexProfileWithTemplate(name) {
457
543
  // 保留当前 profile 的 base_url 和 model
458
544
  const { baseUrl, model } = getCodexProfileCredentials(name);
459
545
 
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
546
  if (model) {
475
547
  if (templateConfig.match(/^model\s*=/m)) {
476
548
  templateConfig = templateConfig.replace(/^model\s*=\s*"[^"]*"/m, `model = "${model}"`);
@@ -479,6 +551,9 @@ export function syncCodexProfileWithTemplate(name) {
479
551
  }
480
552
  }
481
553
 
554
+ // 对第三方 base_url 自动补齐 provider 兼容配置,避免依赖 OPENAI_BASE_URL 环境变量
555
+ templateConfig = ensureCodexOpenAICompatConfig(templateConfig, baseUrl);
556
+
482
557
  saveCodexProfile(name, current.auth, templateConfig);
483
558
  return { auth: current.auth, configToml: templateConfig };
484
559
  }
@@ -508,7 +583,9 @@ export function applyCodexProfile(name) {
508
583
 
509
584
  // 写入 config.toml(如果有内容)
510
585
  if (profile.configToml && profile.configToml.trim()) {
511
- fs.writeFileSync(path.join(CODEX_HOME_PATH, 'config.toml'), profile.configToml);
586
+ const baseUrl = extractBaseUrlFromConfigToml(profile.configToml);
587
+ const compatConfig = ensureCodexOpenAICompatConfig(profile.configToml, baseUrl);
588
+ fs.writeFileSync(path.join(CODEX_HOME_PATH, 'config.toml'), compatConfig);
512
589
  }
513
590
 
514
591
  return true;
package/cover.png DELETED
Binary file