@kkmila/cpc 1.0.4 → 1.0.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
@@ -58,6 +58,29 @@ cpc add
58
58
  cpc install openai --agent pi
59
59
  ```
60
60
 
61
+ ### 安装到 Codex Agent(全局默认 provider)
62
+
63
+ Codex 一次只能使用一个模型(写入 `~/.codex/config.toml` 顶层的 `model` + `model_provider`)。
64
+ `cpc install` 会把 API key 跨平台落地到一个环境变量,并在 `[model_providers.<name>]` 段用 `env_key` 引用它,
65
+ 装完直接 `codex` 即走该 provider,无需 `--profile`。
66
+
67
+ ```bash
68
+ # 非交互:直接指定单模型 + wire_api
69
+ cpc install tele_home --agent codex --model qwen3.6-plus --wire-api responses
70
+
71
+ # 交互:多模型时会弹单选让你挑一个作为默认
72
+ # 并询问 wire_api(responses = Codex Responses 原生协议;chat_completions = 标准 OpenAI 兼容)
73
+ cpc install tele_home --agent codex
74
+ ```
75
+
76
+ 可选 `--reasoning-effort minimal|low|medium|high` 一并写入 `model_reasoning_effort`。
77
+
78
+ 行为说明:
79
+ - 原始 `config.toml` 会先备份为 `config.toml.cpc.bak`。
80
+ - 只 upsert 顶层 `model_provider` / `model`(及可选 `model_reasoning_effort`)和 `[model_providers.<name>]` 段内字段,
81
+ 不会误伤 `model_personality` 等同前缀 key,也不会破坏 `[features]` / `[mcp_servers.*]` / `[desktop]` 等段。
82
+ - `cpc list-installed --agent codex` 可查看当前 provider、model、wire_api、env_key。
83
+
61
84
  ### 测试连接
62
85
 
63
86
  ```bash
@@ -76,6 +99,17 @@ cpc test openai
76
99
  - cursor
77
100
  - opencode
78
101
 
102
+ ### Codex 的特殊性
103
+
104
+ - 一次只能用一个模型:`cpc install --agent codex` 会把选中的模型设为全局默认,
105
+ 多模型时强制让用户挑一个;不像 pi/openclaw 能一次装多个。
106
+ - provider 的 API key 通过 **环境变量** 引用(`env_key`),cpc 跨平台落地:
107
+ - Linux / macOS:按 `$SHELL` 写入 `~/.zshrc` 或 `~/.bashrc`(幂等,已存在则替换值)
108
+ - Windows:用 `setx` 写入用户环境变量
109
+ - 环境变量名规范为 `CPC_<NAME>_API_KEY`(大写、非字母数字转 `_`)
110
+ - 装完新开终端自动加载;当前终端需 `source ~/.bashrc`(或对应 rc 文件)。
111
+ `cpc install` 输出会明确提示该路径。
112
+
79
113
  ## 配置文件
80
114
 
81
115
  - `~/.config/cpc/providers.json` - 提供商配置
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kkmila/cpc",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Custom Provider CLI for managing model providers",
5
5
  "type": "module",
6
6
  "bin": {
package/registry.yaml CHANGED
@@ -20,6 +20,7 @@ agents:
20
20
  codex:
21
21
  name: Codex
22
22
  home: ~/.codex
23
+ home_env: CODEX_HOME
23
24
  description: "OpenAI Codex agent"
24
25
 
25
26
  openclaw:
@@ -4,8 +4,10 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
4
4
  import { resolve, join } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import yaml from "js-yaml";
7
- import { loadRegistry, getAgents, resolveAgentPaths } from "../lib/agents.mjs";
7
+ import { loadRegistry, getAgents, resolveAgentPaths, detectCurrentAgent } from "../lib/agents.mjs";
8
8
  import { enrichModelWithBaseline } from "../lib/baseline-models.mjs";
9
+ import { persistEnvVar, envKeyNameForProvider } from "../lib/env.mjs";
10
+ import { writeCodexProviderConfig, readCodexProviderInfo } from "../lib/codex-toml.mjs";
9
11
 
10
12
  /**
11
13
  * Get the config directory path (~/.config/cpc)
@@ -121,10 +123,10 @@ const MODEL_DEFAULTS = {
121
123
  "gpt-4o": { contextWindow: 128000, maxTokens: 16384, reasoning: false },
122
124
  "gpt-4o-mini": { contextWindow: 128000, maxTokens: 16384, reasoning: false },
123
125
  "gpt-3.5-turbo": { contextWindow: 16385, maxTokens: 4096, reasoning: false },
124
- o1: { contextWindow: 200000, maxTokens: 100000, reasoning: true },
125
- "o1-mini": { contextWindow: 128000, maxTokens: 65536, reasoning: true },
126
- "o1-preview": { contextWindow: 128000, maxTokens: 32768, reasoning: true },
127
- "o3-mini": { contextWindow: 200000, maxTokens: 100000, reasoning: true },
126
+ o1: { contextWindow: 200000, maxTokens: 100000, reasoning: false },
127
+ "o1-mini": { contextWindow: 128000, maxTokens: 65536, reasoning: false },
128
+ "o1-preview": { contextWindow: 128000, maxTokens: 32768, reasoning: false },
129
+ "o3-mini": { contextWindow: 200000, maxTokens: 100000, reasoning: false },
128
130
 
129
131
  // Anthropic
130
132
  "claude-3-opus-20240229": {
@@ -159,7 +161,7 @@ const MODEL_DEFAULTS = {
159
161
  "deepseek-reasoner": {
160
162
  contextWindow: 65536,
161
163
  maxTokens: 8192,
162
- reasoning: true,
164
+ reasoning: false,
163
165
  },
164
166
 
165
167
  // Qwen
@@ -167,6 +169,10 @@ const MODEL_DEFAULTS = {
167
169
  "qwen-plus": { contextWindow: 32768, maxTokens: 8192, reasoning: false },
168
170
  "qwen-max": { contextWindow: 32768, maxTokens: 8192, reasoning: false },
169
171
 
172
+ // 智谱 GLM-5.2
173
+ "glm-5.2": { contextWindow: 1000000, maxTokens: 131072, reasoning: false },
174
+ "glm-5.2-anthropic": { contextWindow: 1000000, maxTokens: 131072, reasoning: false },
175
+
170
176
  // 通用默认值
171
177
  default: { contextWindow: 32768, maxTokens: 4096, reasoning: false },
172
178
  };
@@ -188,16 +194,9 @@ function getModelDefaults(modelId) {
188
194
  }
189
195
  }
190
196
 
191
- // 检测是否是推理模型
192
- const isReasoning =
193
- lowerId.includes("reason") ||
194
- lowerId.includes("o1") ||
195
- lowerId.includes("o3") ||
196
- lowerId.includes("think");
197
-
198
197
  return {
199
198
  ...MODEL_DEFAULTS["default"],
200
- reasoning: isReasoning,
199
+ reasoning: false,
201
200
  };
202
201
  }
203
202
 
@@ -323,24 +322,26 @@ function getAgentProviderConfig(agentId, provider) {
323
322
  },
324
323
  };
325
324
 
326
- case "codex":
325
+ case "codex": {
326
+ // codex 一次只能用一个模型(顶层 model + model_provider)。
327
+ // provider 段用 env_key 引用环境变量,key 本身由 cpc 跨平台落地。
328
+ const envKey = envKeyNameForProvider(provider.name);
327
329
  return {
328
- configToml: {
329
- model_provider: provider.name,
330
+ codex: {
331
+ providerName: provider.name,
332
+ baseUrl: provider.baseUrl,
333
+ wireApi:
334
+ provider.api === "anthropic-messages"
335
+ ? "chat_completions"
336
+ : provider.api === "openai-completions"
337
+ ? "chat_completions"
338
+ : "responses",
339
+ envKey,
330
340
  model: provider.models[0]?.id || "default",
331
- model_providers: {
332
- [provider.name]: {
333
- name: provider.name,
334
- base_url: provider.baseUrl,
335
- wire_api:
336
- provider.api === "anthropic-messages"
337
- ? "chat_completions"
338
- : "responses",
339
- requires_openai_auth: false,
340
- },
341
- },
341
+ apiKey: provider.apiKey,
342
342
  },
343
343
  };
344
+ }
344
345
 
345
346
  case "openclaw":
346
347
  return {
@@ -417,7 +418,7 @@ function getAgentProviderConfig(agentId, provider) {
417
418
  /**
418
419
  * Install provider configuration to agent
419
420
  */
420
- function installProviderToAgent(agentId, provider, agentHome) {
421
+ function installProviderToAgent(agentId, provider, agentHome, opts = {}) {
421
422
  const config = getAgentProviderConfig(agentId, provider);
422
423
  if (!config) {
423
424
  console.log(chalk.yellow(` ⚠ 未知 agent: ${agentId}`));
@@ -442,7 +443,13 @@ function installProviderToAgent(agentId, provider, agentHome) {
442
443
  const modelsPath = join(agentHome, "models.json");
443
444
  let models = { providers: {} };
444
445
  if (existsSync(modelsPath)) {
445
- models = JSON.parse(readFileSync(modelsPath, "utf8"));
446
+ try {
447
+ const content = readFileSync(modelsPath, "utf8").trim();
448
+ if (content) models = JSON.parse(content);
449
+ } catch {
450
+ // 空文件或损坏的 JSON,重置为默认值
451
+ models = { providers: {} };
452
+ }
446
453
  }
447
454
  models.providers = {
448
455
  ...models.providers,
@@ -467,38 +474,55 @@ function installProviderToAgent(agentId, provider, agentHome) {
467
474
 
468
475
  case "codex": {
469
476
  const configPath = join(agentHome, "config.toml");
470
- let configContent = "";
471
- if (existsSync(configPath)) {
472
- configContent = readFileSync(configPath, "utf8");
473
- }
474
-
475
- const providerSection = `\n[model_providers.${provider.name}]\nname = "${provider.name}"\nbase_url = "${provider.baseUrl}"\nwire_api = "${provider.api === "anthropic-messages" ? "chat_completions" : "responses"}"\nrequires_openai_auth = false\n`;
476
-
477
- const modelLine = `model_provider = "${provider.name}"\n`;
478
- const modelIdLine = `model = "${provider.models[0]?.id || "default"}"\n`;
477
+ const c = config.codex;
479
478
 
480
- if (configContent.includes("model_provider =")) {
481
- configContent = configContent.replace(
482
- /model_provider = .*\n/,
483
- modelLine,
479
+ // 1) 跨平台落地 API key 到环境变量
480
+ let envResult;
481
+ try {
482
+ envResult = persistEnvVar(c.envKey, c.apiKey);
483
+ } catch (err) {
484
+ console.error(
485
+ chalk.red(` ✗ 落地环境变量 ${c.envKey} 失败: ${err.message}`),
484
486
  );
485
- } else {
486
- configContent = modelLine + configContent;
487
+ return false;
487
488
  }
488
489
 
489
- if (configContent.includes("model =")) {
490
- configContent = configContent.replace(/model = .*\n/, modelIdLine);
491
- } else {
492
- configContent = modelIdLine + configContent;
493
- }
494
-
495
- if (!configContent.includes(`[model_providers.${provider.name}]`)) {
496
- configContent += providerSection;
490
+ // 2) 稳健写入 config.toml(顶层 + provider 段,备份原文件)
491
+ try {
492
+ const { backed } = writeCodexProviderConfig(configPath, {
493
+ providerName: c.providerName,
494
+ baseUrl: c.baseUrl,
495
+ wireApi: opts.wireApi || c.wireApi,
496
+ envKey: c.envKey,
497
+ model: c.model,
498
+ modelReasoningEffort: opts.modelReasoningEffort,
499
+ });
500
+ console.log(chalk.green(` ✓ 更新 Codex config.toml`));
501
+ if (backed) {
502
+ console.log(chalk.dim(` • 备份: ${backed}`));
503
+ }
504
+ console.log(
505
+ chalk.dim(
506
+ ` • env_key=$${c.envKey} (${envResult.method}${envResult.target ? ": " + envResult.target : ""}${envResult.created ? ", 新增" : ", 已存在"})`,
507
+ ),
508
+ );
509
+ if (process.platform !== "win32") {
510
+ console.log(
511
+ chalk.dim(
512
+ ` • 新终端自动加载; 当前终端需执行: source ${envResult.target}`,
513
+ ),
514
+ );
515
+ }
516
+ console.log(
517
+ chalk.dim(` • 启动: codex (全局默认即 ${c.providerName}/${c.model})`),
518
+ );
519
+ return true;
520
+ } catch (err) {
521
+ console.error(
522
+ chalk.red(` ✗ 更新 ${agentId} 失败: ${err.message}`),
523
+ );
524
+ return false;
497
525
  }
498
-
499
- writeFileSync(configPath, configContent);
500
- console.log(chalk.green(` ✓ 更新 Codex config.toml`));
501
- return true;
502
526
  }
503
527
 
504
528
  case "openclaw": {
@@ -563,15 +587,9 @@ function installProviderToAgent(agentId, provider, agentHome) {
563
587
  * Check if an agent is installed by looking at its home directory
564
588
  */
565
589
  function isAgentInstalled(agentId, agents) {
566
- const agent = agents[agentId];
590
+ const agent = resolveAgentPaths(agents[agentId]);
567
591
  if (!agent || !agent.home) return false;
568
-
569
- // Expand ~ to home directory
570
- const home = agent.home.replace(
571
- /^~/,
572
- process.env.HOME || process.env.USERPROFILE,
573
- );
574
- return existsSync(home);
592
+ return existsSync(agent.home);
575
593
  }
576
594
 
577
595
  export function registerProvider(program, repoRoot) {
@@ -1076,6 +1094,192 @@ export function registerProvider(program, repoRoot) {
1076
1094
  );
1077
1095
  });
1078
1096
 
1097
+ // ── cpc update ───────────────────────────────────────────────
1098
+ program
1099
+ .command("update [providerName]")
1100
+ .description("重新从 API 拉取模型并更新 providers.json + 已安装的 agent")
1101
+ .option("--all", "选择全部模型")
1102
+ .option("--agent <agent>", "指定目标 agent")
1103
+ .action(async (providerName, opts) => {
1104
+ const providersData = loadProviders();
1105
+ const providers = Object.entries(providersData.providers);
1106
+
1107
+ if (providers.length === 0) {
1108
+ console.log(chalk.yellow("No providers configured."));
1109
+ console.log(chalk.dim("Use `cpc add` to add a new provider first."));
1110
+ return;
1111
+ }
1112
+
1113
+ // Step 1: 选择 provider
1114
+ if (!providerName) {
1115
+ providerName = await select({
1116
+ message: "Select provider to update:",
1117
+ choices: providers.map(([name, config]) => ({
1118
+ name: `${name.padEnd(20)} (${config.models?.length || 0} models)`,
1119
+ value: name,
1120
+ })),
1121
+ });
1122
+ }
1123
+
1124
+ const provider = providersData.providers[providerName];
1125
+ if (!provider) {
1126
+ console.error(chalk.red(`Provider "${providerName}" not found.`));
1127
+ process.exit(1);
1128
+ }
1129
+
1130
+ // Step 2: 从 API 拉取模型
1131
+ console.log(chalk.dim(`\nFetching models from ${provider.baseUrl}...`));
1132
+ const result = await fetchModelsFromAPI(provider.baseUrl, provider.apiKey, provider.api);
1133
+
1134
+ if (!result.success) {
1135
+ console.log(chalk.red(`✗ Failed to fetch models: ${result.error}`));
1136
+ return;
1137
+ }
1138
+
1139
+ console.log(chalk.green(`✓ Found ${result.models.length} models from API`));
1140
+
1141
+ // Step 3: 选择模型
1142
+ const oldIds = (provider.models || []).map((m) => m.id);
1143
+ const newIds = result.models.map((m) => m.id);
1144
+
1145
+ // 报告新增和移除的模型
1146
+ const addedIds = newIds.filter((id) => !oldIds.includes(id));
1147
+ const removedIds = oldIds.filter((id) => !newIds.includes(id));
1148
+ const keptIds = oldIds.filter((id) => newIds.includes(id));
1149
+
1150
+ if (addedIds.length > 0) {
1151
+ console.log(chalk.cyan(` + ${addedIds.length} new model(s): ${addedIds.join(", ")}`));
1152
+ }
1153
+ if (removedIds.length > 0) {
1154
+ console.log(chalk.yellow(` - ${removedIds.length} removed model(s): ${removedIds.join(", ")}`));
1155
+ }
1156
+ if (keptIds.length > 0) {
1157
+ console.log(chalk.dim(` = ${keptIds.length} kept model(s): ${keptIds.join(", ")}`));
1158
+ }
1159
+
1160
+ // 让用户选择要安装的模型
1161
+ let selectedIds;
1162
+ if (opts.all) {
1163
+ selectedIds = result.models.map((m) => m.id);
1164
+ console.log(chalk.dim(`Auto-selected all ${selectedIds.length} models`));
1165
+ } else {
1166
+ selectedIds = await checkbox({
1167
+ message: "Select models to install:",
1168
+ choices: result.models.map((m) => {
1169
+ const isNew = !oldIds.includes(m.id);
1170
+ return {
1171
+ name: `${isNew ? chalk.cyan("+ ") : chalk.dim(" ")}${m.id.padEnd(40)} ctx:${m.contextWindow || "?"}`,
1172
+ value: m.id,
1173
+ checked: true,
1174
+ };
1175
+ }),
1176
+ instructions: {
1177
+ navigator: "↑↓ navigate",
1178
+ select: "space select",
1179
+ all: "a toggle all",
1180
+ },
1181
+ });
1182
+ }
1183
+
1184
+ if (selectedIds.length === 0) {
1185
+ console.log(chalk.yellow("No models selected. Cancelled."));
1186
+ return;
1187
+ }
1188
+
1189
+ // Step 4: 用 baseline + MODEL_DEFAULTS 补全模型属性
1190
+ const updatedModels = [];
1191
+ for (const modelId of selectedIds) {
1192
+ const apiModel = result.models.find((m) => m.id === modelId);
1193
+ const defaults = getModelDefaults(modelId);
1194
+ const configured = enrichModelWithBaseline({
1195
+ id: modelId,
1196
+ name: apiModel?.name || modelId,
1197
+ contextWindow: apiModel?.contextWindow || defaults.contextWindow,
1198
+ maxTokens: apiModel?.maxTokens || defaults.maxTokens,
1199
+ reasoning: false,
1200
+ input: apiModel?.input || defaults.input || ["text"],
1201
+ });
1202
+ if (configured.description) {
1203
+ console.log(chalk.dim(` ↪ ${modelId}: baseline 命中, contextWindow=${configured.contextWindow}, input=[${configured.input.join(",")}]`));
1204
+ }
1205
+ updatedModels.push(configured);
1206
+ }
1207
+
1208
+ // Step 5: 更新 providers.json
1209
+ provider.models = updatedModels;
1210
+ provider.updatedAt = new Date().toISOString();
1211
+ saveProviders(providersData);
1212
+ console.log(chalk.green(`\n✓ Provider "${providerName}" updated: ${updatedModels.length} model(s)`));
1213
+
1214
+ // Step 6: 更新已安装的 agent
1215
+ const registry = loadRegistry(repoRoot);
1216
+ const agents = getAgents(registry);
1217
+ const resolvedAgents = Object.fromEntries(
1218
+ Object.entries(agents).map(([id, a]) => [id, resolveAgentPaths(a)]),
1219
+ );
1220
+ const installProvider = { ...provider, models: updatedModels };
1221
+
1222
+ // 找出哪些 agent 之前安装过这个 provider
1223
+ let agentIds;
1224
+ if (opts.agent) {
1225
+ agentIds = opts.agent === "all" ? Object.keys(agents) : [opts.agent];
1226
+ } else {
1227
+ // 只显示 pi 和 opencode,默认不勾选
1228
+ const visibleAgentIds = Object.keys(resolvedAgents)
1229
+ .filter((id) => id === "pi" || id === "opencode");
1230
+
1231
+ if (visibleAgentIds.length === 0) {
1232
+ console.log(chalk.dim("\nNo pi or opencode agents found. Skipping agent update."));
1233
+ return;
1234
+ }
1235
+
1236
+ agentIds = await checkbox({
1237
+ message: "Select target agents to update:",
1238
+ choices: visibleAgentIds.map((id) => {
1239
+ const a = resolvedAgents[id];
1240
+ const installed = isAgentInstalled(id, resolvedAgents);
1241
+ const mark = installed ? " (installed)" : "";
1242
+ return {
1243
+ name: `${id.padEnd(14)} ${a.description || ""}${mark}`,
1244
+ value: id,
1245
+ checked: false,
1246
+ };
1247
+ }),
1248
+ instructions: {
1249
+ navigator: "↑↓ navigate",
1250
+ select: "space select",
1251
+ all: "a toggle all",
1252
+ },
1253
+ });
1254
+ }
1255
+
1256
+ if (agentIds.length === 0) {
1257
+ console.log(chalk.yellow("No agents selected. Skipping agent update."));
1258
+ return;
1259
+ }
1260
+
1261
+ console.log();
1262
+ let successCount = 0;
1263
+ for (const aid of agentIds) {
1264
+ const agent = resolvedAgents[aid];
1265
+ if (!agent) {
1266
+ console.log(chalk.red(`Unknown agent: ${aid}`));
1267
+ continue;
1268
+ }
1269
+ console.log(chalk.bold(`→ ${aid}`));
1270
+ console.log(chalk.dim(` ${updatedModels.length} model(s)`));
1271
+ const success = installProviderToAgent(aid, installProvider, agent.home);
1272
+ if (success) successCount++;
1273
+ console.log();
1274
+ }
1275
+
1276
+ if (successCount > 0) {
1277
+ console.log(chalk.green.bold(`✓ Provider updated in ${successCount} agent(s)!`));
1278
+ } else {
1279
+ console.log(chalk.yellow("No agents were updated."));
1280
+ }
1281
+ });
1282
+
1079
1283
  // ── cpc remove ───────────────────────────────────────────────
1080
1284
  program
1081
1285
  .command("remove [name]")
@@ -1128,6 +1332,8 @@ export function registerProvider(program, repoRoot) {
1128
1332
  .option("--model <model>", "Install only specific model")
1129
1333
  .option("--all", "Install all models")
1130
1334
  .option("--first", "Install only first model")
1335
+ .option("--wire-api <api>", "Codex wire_api (responses | chat_completions)")
1336
+ .option("--reasoning-effort <level>", "Codex model_reasoning_effort (minimal|low|medium|high)")
1131
1337
  .action(async (providerName, opts) => {
1132
1338
  const registry = loadRegistry(repoRoot);
1133
1339
  const agents = getAgents(registry);
@@ -1209,16 +1415,20 @@ export function registerProvider(program, repoRoot) {
1209
1415
  if (opts.agent) {
1210
1416
  agentIds = opts.agent === "all" ? Object.keys(agents) : [opts.agent];
1211
1417
  } else {
1212
- // Auto-detect installed agents
1418
+ // Auto-detect: 只显示 pi 和 opencode,默认不勾选
1419
+ const visibleAgentIds = Object.keys(agents)
1420
+ .filter((id) => id === "pi" || id === "opencode");
1421
+ const currentAgent = detectCurrentAgent(agents);
1213
1422
  const selected = await checkbox({
1214
- message: "Select target agents (installed agents are pre-selected):",
1423
+ message: "Select target agents:",
1215
1424
  choices: [
1216
- ...Object.entries(agents).map(([id, a]) => {
1217
- const installed = isAgentInstalled(id, agents);
1425
+ ...visibleAgentIds.map((id) => {
1426
+ const a = agents[id];
1427
+ const mark = id === currentAgent ? " ← current" : "";
1218
1428
  return {
1219
- name: `${id.padEnd(14)} ${a.description || ""}${installed ? " ✓" : ""}`,
1429
+ name: `${id.padEnd(14)} ${a.description || ""}${mark}`,
1220
1430
  value: id,
1221
- checked: installed,
1431
+ checked: false,
1222
1432
  };
1223
1433
  }),
1224
1434
  ],
@@ -1241,21 +1451,92 @@ export function registerProvider(program, repoRoot) {
1241
1451
  console.log();
1242
1452
  let successCount = 0;
1243
1453
 
1454
+ // codex 特殊处理:全局只能用一个 model,需确定 wire_api(协议)。
1455
+ // 如果目标含 codex 且选了多个模型,先让用户挑一个作为默认。
1456
+ const hasCodex = agentIds.includes("codex");
1457
+ let codexDefaultModel = null;
1458
+ let codexWireApi = opts.wireApi || null;
1459
+ let codexReasoningEffort = opts.reasoningEffort || null;
1460
+ if (hasCodex) {
1461
+ let codexModels = installProvider.models;
1462
+ if (codexModels.length > 1) {
1463
+ console.log(
1464
+ chalk.yellow(
1465
+ `⚠ codex 一次只能用一个模型(顶层 model),多模型时仅生效默认这一个`,
1466
+ ),
1467
+ );
1468
+ const picked = await select({
1469
+ message: "选择 codex 的默认 model:",
1470
+ choices: codexModels.map((m) => ({
1471
+ name: `${m.id}${m.description ? " — " + m.description : ""}`,
1472
+ value: m.id,
1473
+ })),
1474
+ });
1475
+ codexDefaultModel = picked;
1476
+ } else {
1477
+ codexDefaultModel = codexModels[0]?.id;
1478
+ }
1479
+ // wire_api 选择(未传 --wire-api 时交互确认)
1480
+ if (!codexWireApi) {
1481
+ const inferred =
1482
+ installProvider.api === "responses"
1483
+ ? "responses"
1484
+ : installProvider.api === "anthropic-messages"
1485
+ ? "chat_completions"
1486
+ : "chat_completions";
1487
+ codexWireApi = await select({
1488
+ message: `codex wire_api(与 ${installProvider.name} 通信协议):`,
1489
+ choices: [
1490
+ {
1491
+ name: `responses — Codex Responses 原生协议(CCX 网关用这个)${inferred === "responses" ? " [推断]" : ""}`,
1492
+ value: "responses",
1493
+ },
1494
+ {
1495
+ name: `chat_completions — 标准 OpenAI 兼容协议(多数网关用这个)${inferred === "chat_completions" ? " [推断]" : ""}`,
1496
+ value: "chat_completions",
1497
+ },
1498
+ ],
1499
+ });
1500
+ }
1501
+ }
1502
+
1244
1503
  for (const aid of agentIds) {
1245
1504
  const agentDef = agents[aid];
1246
1505
  if (!agentDef) {
1247
1506
  console.log(chalk.red(`Unknown agent: ${aid}`));
1248
1507
  continue;
1249
- }
1508
+ }
1250
1509
 
1251
1510
  const agent = resolveAgentPaths(agentDef);
1252
1511
  console.log(chalk.bold(`→ ${aid}`));
1253
- console.log(chalk.dim(` ${installProvider.models.length} model(s)`));
1512
+
1513
+ let targetProvider = installProvider;
1514
+ let targetOpts = {};
1515
+ if (aid === "codex") {
1516
+ const picked =
1517
+ installProvider.models.find(
1518
+ (m) => m.id === codexDefaultModel,
1519
+ ) || installProvider.models[0];
1520
+ targetProvider = { ...installProvider, models: [picked] };
1521
+ targetOpts = {
1522
+ wireApi: codexWireApi,
1523
+ modelReasoningEffort: codexReasoningEffort,
1524
+ };
1525
+ console.log(
1526
+ chalk.dim(` model: ${picked.id} (codex 全局默认, 单模型)`),
1527
+ );
1528
+ console.log(chalk.dim(` wire_api: ${codexWireApi}`));
1529
+ } else {
1530
+ console.log(
1531
+ chalk.dim(` ${targetProvider.models.length} model(s)`),
1532
+ );
1533
+ }
1254
1534
 
1255
1535
  const success = installProviderToAgent(
1256
1536
  aid,
1257
- installProvider,
1537
+ targetProvider,
1258
1538
  agent.home,
1539
+ targetOpts,
1259
1540
  );
1260
1541
  if (success) successCount++;
1261
1542
  console.log();
@@ -1529,7 +1810,13 @@ export function registerProvider(program, repoRoot) {
1529
1810
  case "pi": {
1530
1811
  const modelsPath = join(agent.home, "models.json");
1531
1812
  if (existsSync(modelsPath)) {
1532
- const models = JSON.parse(readFileSync(modelsPath, "utf8"));
1813
+ let models = { providers: {} };
1814
+ try {
1815
+ const content = readFileSync(modelsPath, "utf8").trim();
1816
+ if (content) models = JSON.parse(content);
1817
+ } catch {
1818
+ models = { providers: {} };
1819
+ }
1533
1820
  if (
1534
1821
  models.providers &&
1535
1822
  Object.keys(models.providers).length > 0
@@ -1578,20 +1865,15 @@ export function registerProvider(program, repoRoot) {
1578
1865
 
1579
1866
  case "codex": {
1580
1867
  const configPath = join(agent.home, "config.toml");
1581
- if (existsSync(configPath)) {
1582
- const content = readFileSync(configPath, "utf8");
1583
- const providerMatch = content.match(/model_provider = "(.*?)"/);
1584
- const modelMatch = content.match(/model = "(.*?)"/);
1585
- if (providerMatch) {
1586
- console.log(` Provider: ${chalk.dim(providerMatch[1])}`);
1587
- console.log(
1588
- ` Model: ${chalk.dim(modelMatch ? modelMatch[1] : "not set")}`,
1589
- );
1590
- } else {
1591
- console.log(chalk.dim(" No provider configured"));
1592
- }
1868
+ const info = readCodexProviderInfo(configPath);
1869
+ if (info.provider) {
1870
+ console.log(` Provider: ${chalk.dim(info.provider)}`);
1871
+ console.log(` Model: ${chalk.dim(info.model || "not set")}`);
1872
+ console.log(` Base URL: ${chalk.dim(info.baseUrl || "not set")}`);
1873
+ console.log(` Wire API: ${chalk.dim(info.wireApi || "not set")}`);
1874
+ console.log(` Env Key: ${chalk.dim(info.envKey || "(none)")}`);
1593
1875
  } else {
1594
- console.log(chalk.dim(" Config file not found"));
1876
+ console.log(chalk.dim(" No provider configured"));
1595
1877
  }
1596
1878
  break;
1597
1879
  }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
+ import { execSync } from "node:child_process";
3
4
  import yaml from "js-yaml";
4
5
  import { expandHome } from "./fs.mjs";
5
6
 
@@ -27,10 +28,102 @@ export function getAgent(registry, agentId) {
27
28
 
28
29
  /**
29
30
  * Resolve ~ paths in agent definition.
31
+ *
32
+ * 如果 agent 声明了 home_env(如 codex 的 CODEX_HOME),且该环境变量已设置,
33
+ * 优先用它覆盖 home —— 因为 agent 运行时(如 orca 托管的 codex)会用这个
34
+ * 隔离的 home,而不是 registry 里的默认路径。否则 agent 会读到错误目录。
30
35
  */
31
36
  export function resolveAgentPaths(agent) {
37
+ const resolvedHome =
38
+ agent.home_env && process.env[agent.home_env]
39
+ ? process.env[agent.home_env]
40
+ : expandHome(agent.home);
32
41
  return {
33
42
  ...agent,
34
- home: expandHome(agent.home),
43
+ home: resolvedHome,
35
44
  };
36
45
  }
46
+
47
+ // ── 当前 agent 检测 ───────────────────────────────────────────
48
+ // 用于 install 时默认预选 cpc 自己跑在哪个 agent 内。
49
+ // 注意:orca 会把 CODEX_HOME / OPENCODE_CONFIG_DIR 等「workspace 级」env 注入
50
+ // 到工作区所有 shell(即便当前在 pi 内也会设 CODEX_HOME),所以 env 存在性
51
+ // 不能用来判断「当前在哪个 agent」。只有 PPID 链里的 agent 进程名才是 ground
52
+ // truth。env 信号仅作 PPID 不可用(非 Linux)时的兜底,且只用 agent 自身注入、
53
+ // 非 orca 注入的变量(如 PI_CODING_AGENT)。
54
+
55
+ // PPID 链里能识别的进程名前缀 → registry agentId
56
+ const COMM_TO_AGENT = [
57
+ ["pi", "pi"],
58
+ ["codex", "codex"],
59
+ ["claude", "claude-code"],
60
+ ["opencode", "opencode"],
61
+ ["hermes", "hermes"],
62
+ ["omp", "omp"],
63
+ ["openclaw", "openclaw"],
64
+ ];
65
+
66
+ // agent 自身注入(非 orca)的存在性 env —— 仅 PPID 不可用时兜底
67
+ const SELF_PRESENCE_ENV = {
68
+ pi: ["PI_CODING_AGENT"],
69
+ };
70
+
71
+ function readProcComm(pid) {
72
+ // Linux: /proc/<pid>/status
73
+ try {
74
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
75
+ const c = status.match(/^Name:\s*(\S+)/m);
76
+ const p = status.match(/^PPid:\s*(\d+)/m);
77
+ return { comm: c ? c[1] : null, ppid: p ? p[1] : null };
78
+ } catch {
79
+ return { comm: null, ppid: null };
80
+ }
81
+ }
82
+
83
+ function readCommViaPs(pid) {
84
+ // macOS / 其他 Unix 兜底
85
+ try {
86
+ const out = execSync(`ps -p ${pid} -o ppid=,comm=`, {
87
+ encoding: "utf8",
88
+ stdio: ["ignore", "pipe", "ignore"],
89
+ timeout: 1000,
90
+ }).trim();
91
+ const m = out.match(/^(\d+)\s+(.*)$/);
92
+ if (!m) return { comm: null, ppid: null };
93
+ return { comm: m[2].trim(), ppid: m[1] };
94
+ } catch {
95
+ return { comm: null, ppid: null };
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 探测 cpc 当前跑在哪个 agent 内(用于 install 时默认预选)。
101
+ * 返回 agentId(registry key)或 null。
102
+ *
103
+ * 优先级:PPID 链进程名(ground truth)→ agent 自身注入 env(兜底)。
104
+ * 不使用 home_env / ORCA_* 存在性(orca 会 workspace 级注入,会误判)。
105
+ */
106
+ export function detectCurrentAgent(agents) {
107
+ const ids = Object.keys(agents || {});
108
+
109
+ // 1) PPID 链进程名匹配(最可靠)
110
+ const reader = process.platform === "linux" ? readProcComm : readCommViaPs;
111
+ let pid = process.ppid;
112
+ for (let i = 0; i < 8 && pid; i++) {
113
+ const { comm, ppid } = reader(pid);
114
+ if (!comm) break;
115
+ const hit = COMM_TO_AGENT.find(
116
+ ([c]) => comm === c || comm.startsWith(c),
117
+ );
118
+ if (hit) return hit[1];
119
+ if (!ppid || ppid === "0" || ppid === "1") break;
120
+ pid = Number(ppid);
121
+ }
122
+
123
+ // 2) 兜底:agent 自身注入的 env(非 orca 注入)
124
+ for (const id of ids) {
125
+ const vars = SELF_PRESENCE_ENV[id];
126
+ if (vars?.some((v) => process.env[v])) return id;
127
+ }
128
+ return null;
129
+ }
@@ -6,9 +6,10 @@
6
6
  * - contextWindow: 模型上下文窗口
7
7
  * - input: 模型支持的输入类型 (text / image / ...)
8
8
  * - description: 模型中文描述
9
+ * - reasoning: 是否为推理模型 (boolean)
9
10
  * 然后随 model 一起持久化到 ~/.config/cpc/providers.json
10
11
  *
11
- * 注意: 本字典不包含 maxTokens / reasoning, 仍由 src/commands/provider.mjs
12
+ * 注意: 本字典不包含 maxTokens, 仍由 src/commands/provider.mjs
12
13
  * 中的 MODEL_DEFAULTS 处理. 安装到 Agent 时, 各 agent schema 不消费
13
14
  * description, 该字段仅作为 provider.json 内的元信息保留.
14
15
  */
@@ -19,54 +20,63 @@ export const BASELINE_MODELS = [
19
20
  contextWindow: 32768,
20
21
  input: ["text"],
21
22
  description: "小米 MiMo V2 全模态旗舰,文本/图像/音频/视频统一理解与生成。",
23
+ reasoning: false,
22
24
  },
23
25
  {
24
26
  modelId: "mimo-v2-pro",
25
27
  contextWindow: 32768,
26
28
  input: ["text"],
27
29
  description: "小米 MiMo V2 推理增强版,强项为复杂推理与长程任务。",
30
+ reasoning: false,
28
31
  },
29
32
  {
30
33
  modelId: "mimo-v2-tts",
31
34
  contextWindow: 32768,
32
35
  input: ["text"],
33
36
  description: "小米 MiMo V2 文本转语音合成模型,多音色/多语种。",
37
+ reasoning: false,
34
38
  },
35
39
  {
36
40
  modelId: "mimo-v2.5",
37
41
  contextWindow: 32768,
38
42
  input: ["text"],
39
43
  description: "小米 MiMo V2.5 通用对话基座,性价比首选。",
44
+ reasoning: false,
40
45
  },
41
46
  {
42
47
  modelId: "mimo-v2.5-asr",
43
48
  contextWindow: 32768,
44
49
  input: ["text"],
45
50
  description: "小米 MiMo V2.5 语音识别模型,音频转文字。",
51
+ reasoning: false,
46
52
  },
47
53
  {
48
54
  modelId: "mimo-v2.5-pro",
49
55
  contextWindow: 32768,
50
56
  input: ["text"],
51
57
  description: "小米 MiMo V2.5 推理增强版,复杂任务与编程。",
58
+ reasoning: false,
52
59
  },
53
60
  {
54
61
  modelId: "mimo-v2.5-tts",
55
62
  contextWindow: 32768,
56
63
  input: ["text"],
57
64
  description: "小米 MiMo V2.5 文本转语音合成模型。",
65
+ reasoning: false,
58
66
  },
59
67
  {
60
68
  modelId: "mimo-v2.5-tts-voiceclone",
61
69
  contextWindow: 32768,
62
70
  input: ["text"],
63
71
  description: "小米 MiMo V2.5 声音克隆 TTS,参考音频复刻音色。",
72
+ reasoning: false,
64
73
  },
65
74
  {
66
75
  modelId: "mimo-v2.5-tts-voicedesign",
67
76
  contextWindow: 32768,
68
77
  input: ["text"],
69
78
  description: "小米 MiMo V2.5 声音设计 TTS,按描述/特征生成新音色。",
79
+ reasoning: false,
70
80
  },
71
81
  {
72
82
  modelId: "gemma4:e4b",
@@ -74,6 +84,7 @@ export const BASELINE_MODELS = [
74
84
  input: ["text"],
75
85
  description:
76
86
  "Google Gemma 4 边缘端模型(4.5B effective / 8B with embeddings),128K 上下文,支持文本+图像+音频。",
87
+ reasoning: false,
77
88
  },
78
89
  {
79
90
  modelId: "qwen3.6:27b",
@@ -81,6 +92,7 @@ export const BASELINE_MODELS = [
81
92
  input: ["text"],
82
93
  description:
83
94
  "Qwen3.5 架构的 27.8B dense 本地模型(Q4_K_M 17GB),本地多用途对话与编程。",
95
+ reasoning: false,
84
96
  },
85
97
  {
86
98
  modelId: "qwen3.6-plus",
@@ -88,6 +100,7 @@ export const BASELINE_MODELS = [
88
100
  input: ["text", "image"],
89
101
  description:
90
102
  "阿里 Qwen3.6 旗舰 VL 模型,主打 Agentic Coding、Vibe Coding 与 1M 多模态长上下文。",
103
+ reasoning: false,
91
104
  },
92
105
  {
93
106
  modelId: "qwen3-vl-plus",
@@ -95,6 +108,7 @@ export const BASELINE_MODELS = [
95
108
  input: ["text", "image"],
96
109
  description:
97
110
  "通义千问视觉旗舰,OS World 视觉 Agent SOTA,文档/视频/OCR 全能理解。",
111
+ reasoning: false,
98
112
  },
99
113
  {
100
114
  modelId: "qwen3.5-122b-a10b",
@@ -102,6 +116,7 @@ export const BASELINE_MODELS = [
102
116
  input: ["text", "image"],
103
117
  description:
104
118
  "Qwen3.5 开源 MoE 旗舰(122B 总参/10B 激活),262K 上下文,原生视觉语言模型。",
119
+ reasoning: false,
105
120
  },
106
121
  {
107
122
  modelId: "qwen3-coder-plus",
@@ -109,6 +124,7 @@ export const BASELINE_MODELS = [
109
124
  input: ["text"],
110
125
  description:
111
126
  "通义千问编码专项旗舰,1M 仓库级上下文,强工具调用与多文件重构。",
127
+ reasoning: false,
112
128
  },
113
129
  {
114
130
  modelId: "text-embedding-v4",
@@ -116,6 +132,7 @@ export const BASELINE_MODELS = [
116
132
  input: ["text"],
117
133
  description:
118
134
  "阿里 Qwen3-Embedding 旗舰,MTEB 多语言榜单领先,支持 8 种向量维度的语义检索首选。",
135
+ reasoning: false,
119
136
  },
120
137
  {
121
138
  modelId: "GLM-5.0",
@@ -123,12 +140,14 @@ export const BASELINE_MODELS = [
123
140
  input: ["text"],
124
141
  description:
125
142
  "智谱基座旗舰 744B MoE,编程对齐 Claude Opus 4.5,复杂系统工程与长程 Agent 基座。",
143
+ reasoning: false,
126
144
  },
127
145
  {
128
146
  modelId: "GLM-5.0-anthropic",
129
147
  contextWindow: 200000,
130
148
  input: ["text"],
131
149
  description: "GLM-5.0 的 Anthropic 协议兼容封装。",
150
+ reasoning: false,
132
151
  },
133
152
  {
134
153
  modelId: "glm-5.1",
@@ -136,12 +155,29 @@ export const BASELINE_MODELS = [
136
155
  input: ["text"],
137
156
  description:
138
157
  "智谱长程任务旗舰,可单任务持续自主工作 8 小时,编程对齐 Claude Opus 4.6。",
158
+ reasoning: false,
139
159
  },
140
160
  {
141
161
  modelId: "glm-5.1-anthropic",
142
162
  contextWindow: 200000,
143
163
  input: ["text"],
144
164
  description: "glm-5.1 的 Anthropic 协议兼容封装。",
165
+ reasoning: false,
166
+ },
167
+ {
168
+ modelId: "glm-5.2",
169
+ contextWindow: 1000000,
170
+ input: ["text"],
171
+ description:
172
+ "智谱 GLM-5 系列旗舰(7533.3亿/400亿激活),Solid 1M 无损上下文 + 128K 输出,面向长程 Agentic Coding 与多端可部署产物开发。",
173
+ reasoning: false,
174
+ },
175
+ {
176
+ modelId: "glm-5.2-anthropic",
177
+ contextWindow: 1000000,
178
+ input: ["text"],
179
+ description: "glm-5.2 的 Anthropic 协议兼容封装。",
180
+ reasoning: false,
145
181
  },
146
182
  {
147
183
  modelId: "deepseek-v4-pro",
@@ -149,12 +185,14 @@ export const BASELINE_MODELS = [
149
185
  input: ["text"],
150
186
  description:
151
187
  "DeepSeek V4 旗舰 1.6T MoE,开源 SOTA 世界知识与 Agent Coding,支持双思考模式。",
188
+ reasoning: false,
152
189
  },
153
190
  {
154
191
  modelId: "deepseek-v4-pro-anthropic",
155
192
  contextWindow: 1000000,
156
193
  input: ["text"],
157
194
  description: "DeepSeek V4-Pro 的 Anthropic 协议封装。",
195
+ reasoning: false,
158
196
  },
159
197
  {
160
198
  modelId: "deepseek-v4-flash",
@@ -162,6 +200,7 @@ export const BASELINE_MODELS = [
162
200
  input: ["text"],
163
201
  description:
164
202
  "DeepSeek V4 轻量版(284B/13B 激活),1M 上下文,主打高吞吐低成本日常 Agent。",
203
+ reasoning: false,
165
204
  },
166
205
  {
167
206
  modelId: "DeepSeek-V3.2",
@@ -169,6 +208,7 @@ export const BASELINE_MODELS = [
169
208
  input: ["text"],
170
209
  description:
171
210
  "DeepSeek V3 末代 DSA 稀疏注意力版本,平衡推理深度与输出长度,适合日常问答与 Agent。",
211
+ reasoning: false,
172
212
  },
173
213
  {
174
214
  modelId: "Doubao-Seed-2.0-Pro",
@@ -176,6 +216,7 @@ export const BASELINE_MODELS = [
176
216
  input: ["text", "image"],
177
217
  description:
178
218
  "字节豆包 2.0 旗舰,多模态视觉推理/空间推理 SOTA,全面对标 GPT-5.2 / Gemini 3 Pro。",
219
+ reasoning: false,
179
220
  },
180
221
  {
181
222
  modelId: "Doubao-Seed-2.0-Code",
@@ -183,6 +224,7 @@ export const BASELINE_MODELS = [
183
224
  input: ["text", "image"],
184
225
  description:
185
226
  "字节豆包 2.0 编程专版,前端强、支持 UI-to-Code 视觉理解,与 TRAE 集成最佳。",
227
+ reasoning: false,
186
228
  },
187
229
  {
188
230
  modelId: "Kimi-K2.5",
@@ -190,6 +232,7 @@ export const BASELINE_MODELS = [
190
232
  input: ["text", "image"],
191
233
  description:
192
234
  "月之暗面原生多模态旗舰,文本+视觉联合优化,Agent Swarm 与视觉编程(UI/视频生成代码)。",
235
+ reasoning: false,
193
236
  },
194
237
  {
195
238
  modelId: "kimi-k2.6",
@@ -197,12 +240,14 @@ export const BASELINE_MODELS = [
197
240
  input: ["text", "image"],
198
241
  description:
199
242
  "月之暗面 K2.6,开源 SOTA 长程编码(13 小时不间断)+ Agent Swarm 300 子智能体。",
243
+ reasoning: false,
200
244
  },
201
245
  {
202
246
  modelId: "kimi-k2.6-anthropic",
203
247
  contextWindow: 256000,
204
248
  input: ["text", "image"],
205
249
  description: "kimi-k2.6 的 Anthropic 协议兼容封装。",
250
+ reasoning: false,
206
251
  },
207
252
  {
208
253
  modelId: "minimax-m3",
@@ -210,6 +255,7 @@ export const BASELINE_MODELS = [
210
255
  input: ["text", "image"],
211
256
  description:
212
257
  "MiniMax 2026 旗舰,国内首个前沿 Coding + 1M 上下文 + 原生多模态三合一模型。",
258
+ reasoning: false,
213
259
  },
214
260
  {
215
261
  modelId: "MiniMax-M2.7",
@@ -217,12 +263,14 @@ export const BASELINE_MODELS = [
217
263
  input: ["text"],
218
264
  description:
219
265
  "MiniMax M2.7,模型自我进化路径,端到端软件工程交付与 Office 复杂编辑。",
266
+ reasoning: false,
220
267
  },
221
268
  {
222
269
  modelId: "minimax-m2.7",
223
270
  contextWindow: 204800,
224
271
  input: ["text"],
225
272
  description: "MiniMax M2.7 小写别名,与 MiniMax-M2.7 同款。",
273
+ reasoning: false,
226
274
  },
227
275
  ];
228
276
 
@@ -257,9 +305,9 @@ export function lookupBaselineModel(modelId) {
257
305
 
258
306
  /**
259
307
  * 用 baseline 字典补全 model 字段
260
- * 命中时, baseline 的 contextWindow / input / description 会覆盖传入值
308
+ * 命中时, baseline 的 contextWindow / input / description / reasoning 会覆盖传入值
261
309
  * 命不中则原样返回
262
- * maxTokens / reasoning 不在 baseline 范围, 由调用方自己处理
310
+ * maxTokens 不在 baseline 范围, 由调用方自己处理
263
311
  * @param {object} model 至少有 { id }
264
312
  * @returns {object} 增强后的 model
265
313
  */
@@ -282,5 +330,9 @@ export function enrichModelWithBaseline(model) {
282
330
  enriched.description = baseline.description;
283
331
  }
284
332
 
333
+ if (typeof baseline.reasoning === "boolean") {
334
+ enriched.reasoning = baseline.reasoning;
335
+ }
336
+
285
337
  return enriched;
286
338
  }
@@ -0,0 +1,175 @@
1
+ import { existsSync, readFileSync, writeFileSync, copyFileSync } from "node:fs";
2
+
3
+ /**
4
+ * Codex config.toml 稳健操作工具。
5
+ *
6
+ * 现有问题:provider.mjs 旧实现用 `/model = .*\n/` 简单正则替换顶层
7
+ * key,并把 provider 段整段字符串拼接,丢掉 env_key、可能误伤同前缀
8
+ * key(model_personality / model_reasoning_effort)。
9
+ *
10
+ * 本模块用"顶层区域隔离 + 段内逐字段 upsert"的方式重写:
11
+ * - 顶层 key 只在第一个 table section 之前查找/替换/插入
12
+ * - provider 段:已存在则段内逐字段更新(不丢用户手动加的字段),
13
+ * 不存在则追加
14
+ */
15
+
16
+ function escapeRegex(s) {
17
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18
+ }
19
+
20
+ function ensureNewline(s) {
21
+ return s === "" || s.endsWith("\n") ? s : s + "\n";
22
+ }
23
+
24
+ /**
25
+ * 在"顶层区域"(第一个 table section 之前)精确替换或插入一个字符串 key。
26
+ * 只匹配行首 `key =`,不会误伤 `key_suffix =` 同前缀 key。
27
+ */
28
+ function setTopLevelString(content, key, value) {
29
+ const line = `${key} = "${value}"`;
30
+ const secMatch = content.match(/^[ \t]*\[/m);
31
+ const topEnd = secMatch ? secMatch.index : content.length;
32
+ const top = content.slice(0, topEnd);
33
+ const rest = content.slice(topEnd);
34
+
35
+ const re = new RegExp(`^[ \\t]*${escapeRegex(key)}[ \\t]*=.*$`, "m");
36
+ if (re.test(top)) {
37
+ return top.replace(re, line) + rest;
38
+ }
39
+ // 顶层没有该 key:插到顶层末尾(第一个 section 之前)
40
+ return ensureNewline(top) + line + "\n" + rest;
41
+ }
42
+
43
+ /**
44
+ * 找 [section] 段的 { start, headerEnd, bodyEnd }。
45
+ * bodyEnd = 下一个 section 开始处 或 文件尾。
46
+ */
47
+ function findSectionRange(content, sectionHeader) {
48
+ const re = new RegExp(`^[ \\t]*${escapeRegex(sectionHeader)}[ \\t]*$`, "m");
49
+ const m = re.exec(content);
50
+ if (!m) return null;
51
+ const headerEnd = m.index + m[0].length;
52
+ const after = content.slice(headerEnd);
53
+ const next = after.match(/^[ \t]*\[/m);
54
+ const bodyEnd = next ? headerEnd + next.index : content.length;
55
+ return { start: m.index, headerEnd, bodyEnd };
56
+ }
57
+
58
+ /**
59
+ * 在 section 段体内 upsert 一个字符串字段。
60
+ * 已存在则替换该行,不存在则在段体末尾追加。
61
+ */
62
+ function upsertFieldInSection(content, sectionHeader, key, value) {
63
+ const range = findSectionRange(content, sectionHeader);
64
+ if (!range) return content;
65
+ const line = `${key} = "${value}"`;
66
+ const body = content.slice(range.headerEnd, range.bodyEnd);
67
+ const re = new RegExp(`^[ \\t]*${escapeRegex(key)}[ \\t]*=.*$`, "m");
68
+ if (re.test(body)) {
69
+ const newBody = body.replace(re, line);
70
+ return content.slice(0, range.headerEnd) + newBody + content.slice(range.bodyEnd);
71
+ }
72
+ // 段体末尾追加(bodyEnd 之前)
73
+ const insertion = `${line}\n`;
74
+ return content.slice(0, range.bodyEnd) + insertion + content.slice(range.bodyEnd);
75
+ }
76
+
77
+ /**
78
+ * 备份 config.toml -> config.toml.cpc.bak
79
+ */
80
+ export function backupCodexConfig(configPath) {
81
+ if (!existsSync(configPath)) return null;
82
+ const bak = `${configPath}.cpc.bak`;
83
+ copyFileSync(configPath, bak);
84
+ return bak;
85
+ }
86
+
87
+ /**
88
+ * 写入 codex provider 配置到 config.toml(全局默认方式)。
89
+ *
90
+ * - 顶层:model_provider, model(可选 model_reasoning_effort)
91
+ * - 段:[model_providers.NAME] 内 upsert name/base_url/wire_api/env_key
92
+ *
93
+ * @param {string} configPath ~/.codex/config.toml
94
+ * @param {object} opts
95
+ * @param {string} opts.providerName
96
+ * @param {string} opts.baseUrl
97
+ * @param {string} opts.wireApi "responses" | "chat_completions"
98
+ * @param {string} [opts.envKey] 环境变量名;不传则不写 env_key
99
+ * @param {string} opts.model 全局默认 model id
100
+ * @param {string} [opts.modelReasoningEffort]
101
+ * @returns {{backed: string|null, wrote: string}}
102
+ */
103
+ export function writeCodexProviderConfig(configPath, opts) {
104
+ const backed = backupCodexConfig(configPath);
105
+ let content = existsSync(configPath)
106
+ ? readFileSync(configPath, "utf8")
107
+ : "";
108
+
109
+ // 顶层 key
110
+ content = setTopLevelString(content, "model_provider", opts.providerName);
111
+ content = setTopLevelString(content, "model", opts.model);
112
+ if (opts.modelReasoningEffort) {
113
+ content = setTopLevelString(
114
+ content,
115
+ "model_reasoning_effort",
116
+ opts.modelReasoningEffort,
117
+ );
118
+ }
119
+
120
+ // provider 段
121
+ const header = `[model_providers.${opts.providerName}]`;
122
+ if (!findSectionRange(content, header)) {
123
+ // 追加新段,确保前导空行
124
+ const sep = content && !content.endsWith("\n\n")
125
+ ? content.endsWith("\n") ? "\n" : "\n\n"
126
+ : "";
127
+ content = content + sep + header + "\n";
128
+ }
129
+
130
+ content = upsertFieldInSection(content, header, "name", opts.providerName);
131
+ content = upsertFieldInSection(content, header, "base_url", opts.baseUrl);
132
+ content = upsertFieldInSection(content, header, "wire_api", opts.wireApi);
133
+ if (opts.envKey) {
134
+ content = upsertFieldInSection(content, header, "env_key", opts.envKey);
135
+ }
136
+
137
+ writeFileSync(configPath, content);
138
+ return { backed, wrote: configPath };
139
+ }
140
+
141
+ /**
142
+ * 读取 codex config.toml 当前的 provider 配置摘要(用于 list-installed)。
143
+ * @returns {{provider: string|null, model: string|null, envKey: string|null, baseUrl: string|null, wireApi: string|null}}
144
+ */
145
+ export function readCodexProviderInfo(configPath) {
146
+ if (!existsSync(configPath)) {
147
+ return { provider: null, model: null, envKey: null, baseUrl: null, wireApi: null };
148
+ }
149
+ const content = readFileSync(configPath, "utf8");
150
+ const get = (re) => {
151
+ const m = content.match(re);
152
+ return m ? m[1] : null;
153
+ };
154
+ const provider = get(/^[ \t]*model_provider[ \t]*=\s*"([^"]*)"/m);
155
+ const model = get(/^[ \t]*model[ \t]*=\s*"([^"]*)"/m);
156
+ let envKey = null;
157
+ let baseUrl = null;
158
+ let wireApi = null;
159
+ if (provider) {
160
+ const range = findSectionRange(content, `[model_providers.${provider}]`);
161
+ if (range) {
162
+ const body = content.slice(range.headerEnd, range.bodyEnd);
163
+ const pick = (key) => {
164
+ const m = body.match(
165
+ new RegExp(`^[ \\t]*${key}[ \\t]*=\\s*"([^"]*)"`, "m"),
166
+ );
167
+ return m ? m[1] : null;
168
+ };
169
+ envKey = pick("env_key");
170
+ baseUrl = pick("base_url");
171
+ wireApi = pick("wire_api");
172
+ }
173
+ }
174
+ return { provider, model, envKey, baseUrl, wireApi };
175
+ }
@@ -0,0 +1,115 @@
1
+ import { homedir } from "node:os";
2
+ import {
3
+ existsSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ appendFileSync,
7
+ } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { execSync } from "node:child_process";
10
+
11
+ /**
12
+ * 跨平台环境变量持久化模块。
13
+ *
14
+ * codex 的 model_providers 只能用 `env_key` 引用环境变量,
15
+ * 不能内联 key。cpc 安装到 codex 时,需要把 provider.apiKey
16
+ * 落地到一个环境变量里,并保证新开的终端/codex 进程能读到。
17
+ *
18
+ * 平台策略:
19
+ * - Linux / macOS: 按 $SHELL 检测写 ~/.zshrc 或 ~/.bashrc(幂等)
20
+ * - Windows: 用 setx 写入用户环境变量(注册表 HKCU\Environment)
21
+ */
22
+
23
+ /**
24
+ * 推断当前 shell 的 rc 文件路径。
25
+ * @returns {string|null} rc 文件绝对路径;Windows 返回 null
26
+ */
27
+ function detectShellRc() {
28
+ const platform = process.platform;
29
+ if (platform === "win32") return null;
30
+
31
+ const shell = process.env.SHELL || "";
32
+ const home = homedir();
33
+ if (shell.includes("zsh")) return join(home, ".zshrc");
34
+ if (shell.includes("bash")) return join(home, ".bashrc");
35
+
36
+ // $SHELL 缺失时按平台兜底:macOS 默认 zsh,其余默认 bash
37
+ if (platform === "darwin") return join(home, ".zshrc");
38
+ return join(home, ".bashrc");
39
+ }
40
+
41
+ /**
42
+ * 幂等地把 `export KEY="value"` 写入 rc 文件。
43
+ * 已存在该 export 行则替换值,不存在则追加。
44
+ */
45
+ function persistToRc(rcPath, key, value) {
46
+ const exportLine = `export ${key}="${value}"`;
47
+ let content = "";
48
+ if (existsSync(rcPath)) {
49
+ content = readFileSync(rcPath, "utf8");
50
+ }
51
+ // 精确匹配 `export KEY=...` 整行
52
+ const regex = new RegExp(`^export ${key}=.*$`, "m");
53
+ if (regex.test(content)) {
54
+ const updated = content.replace(regex, exportLine);
55
+ if (updated !== content) {
56
+ writeFileSync(rcPath, updated);
57
+ }
58
+ return false; // 不是新增
59
+ }
60
+ const sep = content && !content.endsWith("\n") ? "\n" : "";
61
+ const block = `${sep}# Added by cpc (codex provider)\n${exportLine}\n`;
62
+ appendFileSync(rcPath, block);
63
+ return true; // 新增
64
+ }
65
+
66
+ /**
67
+ * Windows: 用 setx 持久化用户环境变量。
68
+ * 注意 setx 设置的变量只在新进程生效,当前进程不会自动有。
69
+ */
70
+ function persistWithSetx(key, value) {
71
+ // setx 对带空格的值需要引号;API key 一般是 token 串,安全起见仍加引号
72
+ execSync(`setx ${key} "${value}"`, {
73
+ shell: "cmd.exe",
74
+ stdio: "ignore",
75
+ });
76
+ }
77
+
78
+ /**
79
+ * 跨平台持久化环境变量。
80
+ *
81
+ * @param {string} key 环境变量名
82
+ * @param {string} value 值
83
+ * @returns {{method: "rc"|"setx"|"skip", target: string, created: boolean}}
84
+ */
85
+ export function persistEnvVar(key, value) {
86
+ if (!key || value == null) {
87
+ return { method: "skip", target: "", created: false };
88
+ }
89
+
90
+ const rc = detectShellRc();
91
+ if (rc) {
92
+ const created = persistToRc(rc, key, value);
93
+ return { method: "rc", target: rc, created };
94
+ }
95
+
96
+ // Windows
97
+ try {
98
+ persistWithSetx(key, value);
99
+ return { method: "setx", target: "HKCU\\Environment", created: true };
100
+ } catch (err) {
101
+ throw new Error(`Failed to set env var on Windows: ${err.message}`);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * 把 provider 名转成规范环境变量名:CPC_<NAME_UPPER>_API_KEY
107
+ * 非字母数字字符 -> _
108
+ */
109
+ export function envKeyNameForProvider(providerName) {
110
+ const safe = String(providerName)
111
+ .toUpperCase()
112
+ .replace(/[^A-Z0-9]+/g, "_")
113
+ .replace(/^_+|_+$/g, "");
114
+ return `CPC_${safe}_API_KEY`;
115
+ }