@kkmila/cpc 1.0.1 → 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.
@@ -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
+ }
@@ -0,0 +1,338 @@
1
+ /**
2
+ * 基础模型字典 (Baseline)
3
+ *
4
+ * 来源: providers-baseline.json
5
+ * 用途: 当从外部 API (/v1/models) 获取到模型 ID 后, 用本字典补全
6
+ * - contextWindow: 模型上下文窗口
7
+ * - input: 模型支持的输入类型 (text / image / ...)
8
+ * - description: 模型中文描述
9
+ * - reasoning: 是否为推理模型 (boolean)
10
+ * 然后随 model 一起持久化到 ~/.config/cpc/providers.json
11
+ *
12
+ * 注意: 本字典不包含 maxTokens, 仍由 src/commands/provider.mjs
13
+ * 中的 MODEL_DEFAULTS 处理. 安装到 Agent 时, 各 agent schema 不消费
14
+ * description, 该字段仅作为 provider.json 内的元信息保留.
15
+ */
16
+
17
+ export const BASELINE_MODELS = [
18
+ {
19
+ modelId: "mimo-v2-omni",
20
+ contextWindow: 32768,
21
+ input: ["text"],
22
+ description: "小米 MiMo V2 全模态旗舰,文本/图像/音频/视频统一理解与生成。",
23
+ reasoning: false,
24
+ },
25
+ {
26
+ modelId: "mimo-v2-pro",
27
+ contextWindow: 32768,
28
+ input: ["text"],
29
+ description: "小米 MiMo V2 推理增强版,强项为复杂推理与长程任务。",
30
+ reasoning: false,
31
+ },
32
+ {
33
+ modelId: "mimo-v2-tts",
34
+ contextWindow: 32768,
35
+ input: ["text"],
36
+ description: "小米 MiMo V2 文本转语音合成模型,多音色/多语种。",
37
+ reasoning: false,
38
+ },
39
+ {
40
+ modelId: "mimo-v2.5",
41
+ contextWindow: 32768,
42
+ input: ["text"],
43
+ description: "小米 MiMo V2.5 通用对话基座,性价比首选。",
44
+ reasoning: false,
45
+ },
46
+ {
47
+ modelId: "mimo-v2.5-asr",
48
+ contextWindow: 32768,
49
+ input: ["text"],
50
+ description: "小米 MiMo V2.5 语音识别模型,音频转文字。",
51
+ reasoning: false,
52
+ },
53
+ {
54
+ modelId: "mimo-v2.5-pro",
55
+ contextWindow: 32768,
56
+ input: ["text"],
57
+ description: "小米 MiMo V2.5 推理增强版,复杂任务与编程。",
58
+ reasoning: false,
59
+ },
60
+ {
61
+ modelId: "mimo-v2.5-tts",
62
+ contextWindow: 32768,
63
+ input: ["text"],
64
+ description: "小米 MiMo V2.5 文本转语音合成模型。",
65
+ reasoning: false,
66
+ },
67
+ {
68
+ modelId: "mimo-v2.5-tts-voiceclone",
69
+ contextWindow: 32768,
70
+ input: ["text"],
71
+ description: "小米 MiMo V2.5 声音克隆 TTS,参考音频复刻音色。",
72
+ reasoning: false,
73
+ },
74
+ {
75
+ modelId: "mimo-v2.5-tts-voicedesign",
76
+ contextWindow: 32768,
77
+ input: ["text"],
78
+ description: "小米 MiMo V2.5 声音设计 TTS,按描述/特征生成新音色。",
79
+ reasoning: false,
80
+ },
81
+ {
82
+ modelId: "gemma4:e4b",
83
+ contextWindow: 32768,
84
+ input: ["text"],
85
+ description:
86
+ "Google Gemma 4 边缘端模型(4.5B effective / 8B with embeddings),128K 上下文,支持文本+图像+音频。",
87
+ reasoning: false,
88
+ },
89
+ {
90
+ modelId: "qwen3.6:27b",
91
+ contextWindow: 32768,
92
+ input: ["text"],
93
+ description:
94
+ "Qwen3.5 架构的 27.8B dense 本地模型(Q4_K_M 17GB),本地多用途对话与编程。",
95
+ reasoning: false,
96
+ },
97
+ {
98
+ modelId: "qwen3.6-plus",
99
+ contextWindow: 1000000,
100
+ input: ["text", "image"],
101
+ description:
102
+ "阿里 Qwen3.6 旗舰 VL 模型,主打 Agentic Coding、Vibe Coding 与 1M 多模态长上下文。",
103
+ reasoning: false,
104
+ },
105
+ {
106
+ modelId: "qwen3-vl-plus",
107
+ contextWindow: 1000000,
108
+ input: ["text", "image"],
109
+ description:
110
+ "通义千问视觉旗舰,OS World 视觉 Agent SOTA,文档/视频/OCR 全能理解。",
111
+ reasoning: false,
112
+ },
113
+ {
114
+ modelId: "qwen3.5-122b-a10b",
115
+ contextWindow: 262144,
116
+ input: ["text", "image"],
117
+ description:
118
+ "Qwen3.5 开源 MoE 旗舰(122B 总参/10B 激活),262K 上下文,原生视觉语言模型。",
119
+ reasoning: false,
120
+ },
121
+ {
122
+ modelId: "qwen3-coder-plus",
123
+ contextWindow: 1000000,
124
+ input: ["text"],
125
+ description:
126
+ "通义千问编码专项旗舰,1M 仓库级上下文,强工具调用与多文件重构。",
127
+ reasoning: false,
128
+ },
129
+ {
130
+ modelId: "text-embedding-v4",
131
+ contextWindow: 32000,
132
+ input: ["text"],
133
+ description:
134
+ "阿里 Qwen3-Embedding 旗舰,MTEB 多语言榜单领先,支持 8 种向量维度的语义检索首选。",
135
+ reasoning: false,
136
+ },
137
+ {
138
+ modelId: "GLM-5.0",
139
+ contextWindow: 200000,
140
+ input: ["text"],
141
+ description:
142
+ "智谱基座旗舰 744B MoE,编程对齐 Claude Opus 4.5,复杂系统工程与长程 Agent 基座。",
143
+ reasoning: false,
144
+ },
145
+ {
146
+ modelId: "GLM-5.0-anthropic",
147
+ contextWindow: 200000,
148
+ input: ["text"],
149
+ description: "GLM-5.0 的 Anthropic 协议兼容封装。",
150
+ reasoning: false,
151
+ },
152
+ {
153
+ modelId: "glm-5.1",
154
+ contextWindow: 200000,
155
+ input: ["text"],
156
+ description:
157
+ "智谱长程任务旗舰,可单任务持续自主工作 8 小时,编程对齐 Claude Opus 4.6。",
158
+ reasoning: false,
159
+ },
160
+ {
161
+ modelId: "glm-5.1-anthropic",
162
+ contextWindow: 200000,
163
+ input: ["text"],
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,
181
+ },
182
+ {
183
+ modelId: "deepseek-v4-pro",
184
+ contextWindow: 1000000,
185
+ input: ["text"],
186
+ description:
187
+ "DeepSeek V4 旗舰 1.6T MoE,开源 SOTA 世界知识与 Agent Coding,支持双思考模式。",
188
+ reasoning: false,
189
+ },
190
+ {
191
+ modelId: "deepseek-v4-pro-anthropic",
192
+ contextWindow: 1000000,
193
+ input: ["text"],
194
+ description: "DeepSeek V4-Pro 的 Anthropic 协议封装。",
195
+ reasoning: false,
196
+ },
197
+ {
198
+ modelId: "deepseek-v4-flash",
199
+ contextWindow: 1000000,
200
+ input: ["text"],
201
+ description:
202
+ "DeepSeek V4 轻量版(284B/13B 激活),1M 上下文,主打高吞吐低成本日常 Agent。",
203
+ reasoning: false,
204
+ },
205
+ {
206
+ modelId: "DeepSeek-V3.2",
207
+ contextWindow: 128000,
208
+ input: ["text"],
209
+ description:
210
+ "DeepSeek V3 末代 DSA 稀疏注意力版本,平衡推理深度与输出长度,适合日常问答与 Agent。",
211
+ reasoning: false,
212
+ },
213
+ {
214
+ modelId: "Doubao-Seed-2.0-Pro",
215
+ contextWindow: 256000,
216
+ input: ["text", "image"],
217
+ description:
218
+ "字节豆包 2.0 旗舰,多模态视觉推理/空间推理 SOTA,全面对标 GPT-5.2 / Gemini 3 Pro。",
219
+ reasoning: false,
220
+ },
221
+ {
222
+ modelId: "Doubao-Seed-2.0-Code",
223
+ contextWindow: 256000,
224
+ input: ["text", "image"],
225
+ description:
226
+ "字节豆包 2.0 编程专版,前端强、支持 UI-to-Code 视觉理解,与 TRAE 集成最佳。",
227
+ reasoning: false,
228
+ },
229
+ {
230
+ modelId: "Kimi-K2.5",
231
+ contextWindow: 256000,
232
+ input: ["text", "image"],
233
+ description:
234
+ "月之暗面原生多模态旗舰,文本+视觉联合优化,Agent Swarm 与视觉编程(UI/视频生成代码)。",
235
+ reasoning: false,
236
+ },
237
+ {
238
+ modelId: "kimi-k2.6",
239
+ contextWindow: 256000,
240
+ input: ["text", "image"],
241
+ description:
242
+ "月之暗面 K2.6,开源 SOTA 长程编码(13 小时不间断)+ Agent Swarm 300 子智能体。",
243
+ reasoning: false,
244
+ },
245
+ {
246
+ modelId: "kimi-k2.6-anthropic",
247
+ contextWindow: 256000,
248
+ input: ["text", "image"],
249
+ description: "kimi-k2.6 的 Anthropic 协议兼容封装。",
250
+ reasoning: false,
251
+ },
252
+ {
253
+ modelId: "minimax-m3",
254
+ contextWindow: 1000000,
255
+ input: ["text", "image"],
256
+ description:
257
+ "MiniMax 2026 旗舰,国内首个前沿 Coding + 1M 上下文 + 原生多模态三合一模型。",
258
+ reasoning: false,
259
+ },
260
+ {
261
+ modelId: "MiniMax-M2.7",
262
+ contextWindow: 204800,
263
+ input: ["text"],
264
+ description:
265
+ "MiniMax M2.7,模型自我进化路径,端到端软件工程交付与 Office 复杂编辑。",
266
+ reasoning: false,
267
+ },
268
+ {
269
+ modelId: "minimax-m2.7",
270
+ contextWindow: 204800,
271
+ input: ["text"],
272
+ description: "MiniMax M2.7 小写别名,与 MiniMax-M2.7 同款。",
273
+ reasoning: false,
274
+ },
275
+ ];
276
+
277
+ /**
278
+ * 在 baseline 字典中查找模型
279
+ * 匹配规则:
280
+ * 1) 精确匹配 (大小写不敏感)
281
+ * 2) 包含匹配 (查询 ID 包含 baseline 的 modelId 或反之)
282
+ * @param {string} modelId
283
+ * @returns {object|null} 命中的 baseline 记录
284
+ */
285
+ export function lookupBaselineModel(modelId) {
286
+ if (!modelId) return null;
287
+ const lower = String(modelId).toLowerCase();
288
+
289
+ // 1) 精确匹配
290
+ const exact = BASELINE_MODELS.find((m) => m.modelId.toLowerCase() === lower);
291
+ if (exact) return exact;
292
+
293
+ // 2) 包含匹配: baseline.modelId ⊆ query.id (适用 qwen3.6 / MiniMax 大小写差异)
294
+ const contains = BASELINE_MODELS.find((m) =>
295
+ lower.includes(m.modelId.toLowerCase()),
296
+ );
297
+ if (contains) return contains;
298
+
299
+ // 3) 反向包含: query.id ⊆ baseline.modelId (适用 minimax-m3 命中 MiniMax-M2.7 等)
300
+ const reverse = BASELINE_MODELS.find((m) =>
301
+ m.modelId.toLowerCase().includes(lower),
302
+ );
303
+ return reverse || null;
304
+ }
305
+
306
+ /**
307
+ * 用 baseline 字典补全 model 字段
308
+ * 命中时, baseline 的 contextWindow / input / description / reasoning 会覆盖传入值
309
+ * 命不中则原样返回
310
+ * maxTokens 不在 baseline 范围, 由调用方自己处理
311
+ * @param {object} model 至少有 { id }
312
+ * @returns {object} 增强后的 model
313
+ */
314
+ export function enrichModelWithBaseline(model) {
315
+ if (!model || !model.id) return model;
316
+ const baseline = lookupBaselineModel(model.id);
317
+ if (!baseline) return model;
318
+
319
+ const enriched = { ...model };
320
+
321
+ if (typeof baseline.contextWindow === "number") {
322
+ enriched.contextWindow = baseline.contextWindow;
323
+ }
324
+
325
+ if (Array.isArray(baseline.input) && baseline.input.length > 0) {
326
+ enriched.input = [...baseline.input];
327
+ }
328
+
329
+ if (baseline.description) {
330
+ enriched.description = baseline.description;
331
+ }
332
+
333
+ if (typeof baseline.reasoning === "boolean") {
334
+ enriched.reasoning = baseline.reasoning;
335
+ }
336
+
337
+ return enriched;
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
+ }