@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 +4 -2
- package/index.js +4 -2
- package/package.json +1 -1
- package/src/codex-models.js +133 -0
- package/src/commands/apply.js +14 -1
- package/src/commands/edit.js +7 -13
- package/src/commands/help.js +2 -1
- package/src/commands/index.js +1 -1
- package/src/commands/new.js +6 -12
- package/src/commands/resettodefault.js +42 -0
- package/src/profiles.js +294 -22
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
|
-
|
|
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.
|
|
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
|
@@ -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
|
+
}
|
package/src/commands/apply.js
CHANGED
|
@@ -69,8 +69,21 @@ export function applyCommand(program) {
|
|
|
69
69
|
result = applyClaudeProfile(profileInfo.name);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
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);
|
package/src/commands/edit.js
CHANGED
|
@@ -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,
|
|
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: '
|
|
91
|
-
message: '
|
|
92
|
-
default:
|
|
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);
|
package/src/commands/help.js
CHANGED
|
@@ -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 需要:
|
|
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(' 示例:'));
|
package/src/commands/index.js
CHANGED
|
@@ -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
|
-
|
package/src/commands/new.js
CHANGED
|
@@ -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: '
|
|
122
|
-
message: '
|
|
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,
|
|
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 (
|
|
609
|
+
if (isCustomOpenAIBaseUrl(normalizedBaseUrl)) {
|
|
610
|
+
lines.push(`model_provider = "${CCC_OPENAI_COMPAT_PROVIDER}"`);
|
|
397
611
|
lines.push('');
|
|
398
|
-
lines.push(
|
|
399
|
-
lines.push(
|
|
400
|
-
lines.push(`base_url = "${
|
|
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 ||
|
|
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
|
-
|
|
723
|
+
const compatConfig = ensureCodexOpenAICompatConfig(profile.configToml, baseUrl);
|
|
724
|
+
fs.writeFileSync(path.join(CODEX_HOME_PATH, 'config.toml'), compatConfig);
|
|
512
725
|
}
|
|
513
726
|
|
|
514
|
-
|
|
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
|
// ============================================================
|