@yhong91/cpac 0.1.24 → 0.1.25

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.
Files changed (3) hide show
  1. package/README.md +28 -47
  2. package/dist/cpac.js +394 -91
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -47,9 +47,9 @@ cpac uninstall --target codex # 字节级恢复原配置
47
47
  cpac claude # Claude Code 走 CPA
48
48
  cpac opencode # opencode 走 CPA(模型选 cpac/<模型名>)
49
49
 
50
- # 5. 可选:Claude 模型槽位覆盖(持久缓存;默认自动选择已覆盖多数场景,见下文)
51
- cpac claude-models # 查看当前覆盖
52
- cpac claude-models --haiku # 下拉选择 haiku 槽模型
50
+ # 5. 可选:Agent 配置向导与模型选择
51
+ cpac codex setup # 进入 Codex 配置向导(Subagent 模型多选、最大上下文、Multi-Agent V2)
52
+ cpac claude setup # 进入 Claude Code 模型向导(Sonnet、Haiku、Opus 槽位选择)
53
53
 
54
54
  # 6. 升级(装上新版本后自动 sync 已接入的 agent)
55
55
  cpac upgrade
@@ -59,7 +59,7 @@ cpac upgrade
59
59
 
60
60
  ## Agent 自动化安装指南
61
61
 
62
- 本节面向 AI agent:按顺序执行即可完成安装与配置,**全程无交互**。注意:`--v2_models` 复选框和 `claude-models` 不带值的下拉选择是 TTY-only,agent 必须走下方直接写配置 JSON 的路径。
62
+ 本节面向 AI agent:按顺序执行即可完成安装与配置,**全程无交互**。注意:`cpac codex --config` `cpac claude --config` 的交互向导是 TTY-only,agent 必须走下方直接写配置 JSON 的路径。
63
63
 
64
64
  1. **检查前置**:`node --version` ≥ 20;确认用户需要哪些 agent(`codex`/`claude`/`pi`/`kimi`/`opencode` 在 PATH 上)。向用户索取 CPA API key。
65
65
 
@@ -72,7 +72,7 @@ source ~/.zshrc
72
72
  cpac status # 验证:CPA_API_KEY 应显示 configured
73
73
  ```
74
74
 
75
- 3. **写入模型配置**(创建或合并 `~/.config/cpac/config.json`,已有键保留):
75
+ 1. **写入模型配置**(创建或合并 `~/.config/cpac/config.json`,已有键保留):
76
76
 
77
77
  ```json
78
78
  {
@@ -80,20 +80,23 @@ cpac status # 验证:CPA_API_KEY 应显示 configured
80
80
  }
81
81
  ```
82
82
 
83
- Claude 槽位(opus/sonnet/haiku)通常不用配:cpac 启动时自动选择——同家族 claude 模型优先,否则 gemini → grok → luna → 目录第一个;opus 无匹配则不设。这个链条在常见目录上正好给出理想结果:sonnet 落在 claude-sonnet-*(它同时充当 Auto mode 分类器的替身,避免分类请求失败后回退到昂贵的会话主模型),haiku 落在快的 gemini flash。只有目录奇怪时才用 `cpac claude-models --opus/--sonnet/--haiku <模型>` 显式覆盖。codex spawn subagent 取三个 GPT + gemini + grok 五席(顺序即优先级,Codex 只广告 catalog 前 5)。候选替换:`mimo-v2.5`(haiku)、`grok-4.5`(haiku)。若上述 slug 不在目录中(`curl -s -H "Authorization: Bearer $CPA_API_KEY" "$CPA_BASE_URL/v1/models"` 可查),按家族就近替换。分类器机制细节见 [docs/claude-auto-mode-classifier.md](docs/claude-auto-mode-classifier.md)。
83
+ Claude 槽位(opus/sonnet/haiku)通常不用配:cpac 启动时自动选择——同家族 claude 模型优先,否则 gemini → grok → luna → 目录第一个;opus 无匹配则不设。这个链条在常见目录上正好给出理想结果:sonnet 落在 claude-sonnet-*(它同时充当 Auto mode 分类器的替身,避免分类请求失败后回退到昂贵的会话主模型),haiku 落在快的 gemini flash。只有目录奇怪时才用 `cpac claude setup` 交互式配置。codex spawn subagent 取三个 GPT + gemini + grok 五席(顺序即优先级,Codex 只广告 catalog 前 5),可通过 `cpac codex setup` 交互式配置。候选替换:`mimo-v2.5`(haiku)、`grok-4.5`(haiku)。若上述 slug 不在目录中(`curl -s -H "Authorization: Bearer $CPA_API_KEY" "$CPA_BASE_URL/v1/models"` 可查),按家族就近替换。分类器机制细节见 [docs/claude-auto-mode-classifier.md](docs/claude-auto-mode-classifier.md)。
84
84
 
85
- 4. **持久注入**(只对检测到的目标;codex 会应用步骤 3 的 spawn_models):
85
+ 1. **持久注入**(只对检测到的目标;codex 会应用步骤 3 的 spawn_models):
86
86
 
87
87
  ```bash
88
88
  cpac install --all
89
89
  cpac status # 验证:各 target 显示 ready/injected
90
90
  ```
91
91
 
92
- 5. **临时接管类 agent 无需安装**,直接告知用户用 `cpac claude` / `cpac opencode` 启动(步骤 3 的 claude_models 自动生效)。
92
+ 1. **统一启动与配置**:
93
+ - 启动:`cpac <codex|claude|kimi|grok|pi|opencode> [args...]`(所有原生参数如 `-c` 等原样透明传递)
94
+ - 配置:`cpac <codex|claude> setup`(交互式配置模型与特性)
95
+ - 还原:`cpac <agent> clear`(还原或重置配置)
93
96
 
94
- 6. **已安装后升级**:npm 发布新版本后,等 publish 成功再执行 `cpac upgrade`。它会按需 `npm install -g`,再对已接入的 Codex / Pi / Kimi 做 `sync`。不要拆成 `npm install -g @yhong91/cpac` 再手动 `cpac sync`。只刷新配置、不升 CLI 时才用 `cpac sync`。
97
+ 2. **已安装后升级**:npm 发布新版本后,等 publish 成功再执行 `cpac upgrade`。它会按需 `npm install -g`,再对已接入的 Codex / Pi / Kimi / Grok 做 `sync`。不要拆成 `npm install -g @yhong91/cpac` 再手动 `cpac sync`。只刷新配置、不升 CLI 时才用 `cpac sync`。
95
98
 
96
- 失败处理:任何一步报错时先跑 `cpac status`;密钥未配置回到步骤 2;注入后异常用 `cpac uninstall --target <id>` 恢复原配置。
99
+ 失败处理:任何一步报错时先跑 `cpac status`;密钥未配置回到步骤 2;注入后异常用 `cpac <agent> clear` 或 `cpac restore --target <id>` 恢复原配置。
97
100
 
98
101
  ## 首次引导
99
102
 
@@ -179,34 +182,20 @@ cpac version
179
182
 
180
183
  ### Claude Code
181
184
 
182
- 通过 CPA 启动 Claude Code,后续参数原样传递:
185
+ 通过 CPA 启动 Claude Code,后续所有原生参数原样传递(如 `-c` 继续会话、`--model` 等):
183
186
 
184
187
  ```bash
185
188
  cpac claude
189
+ cpac claude -c # 继续上一次对话(原生 -c 原样直通)
186
190
  cpac claude --model claude-sonnet-4-5
187
191
  cpac claude -- -p "检查当前项目"
188
192
  ```
189
193
 
190
- `cpac claude` 会在本地起一个临时 loopback 代理,并为 Claude Code 子进程设置:
194
+ #### 配置与清除
191
195
 
192
- ```text
193
- ANTHROPIC_BASE_URL=http://127.0.0.1:<临时端口>
194
- ANTHROPIC_AUTH_TOKEN=<CPA_API_KEY>
195
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
196
- CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1
197
- ```
198
-
199
- 同时清除可能覆盖网关选择的 `ANTHROPIC_API_KEY` 和 Claude Code 云 provider 环境变量。它不会修改 `~/.claude/`,并原样返回 Claude Code 的退出码。
200
-
201
- 代理开启 Claude Code 的 gateway model discovery:`/model` 选择器会列出 CPA 目录里的全部模型。claude 系模型直接使用原 id;其它模型以 `claude-cpac--<模型名>` 别名出现(例如 `claude-cpac--gpt-5.6-sol`),请求发出时由代理还原为真实模型名。
202
-
203
- 启动时还会把模型槽位(`ANTHROPIC_DEFAULT_OPUS/SONNET/HAIKU_MODEL` 及 `ANTHROPIC_SMALL_FAST_MODEL`)指向目录里的模型,避免 haiku/subagent 任务打到网关不认识的默认 id。默认自动选择(claude 系优先:按家族名匹配 opus/sonnet/haiku,否则最大窗口给 opus/sonnet、最小窗口给 haiku),可用命令覆盖:
204
-
205
- ```sh
206
- cpac claude-models # 查看当前覆盖
207
- cpac claude-models --opus claude-opus-4-8 --haiku glm-5.3 # 设置/合并
208
- cpac claude-models --haiku # 不带值:arrow-key 下拉选择
209
- cpac claude-models --reset # 恢复自动选择
196
+ ```bash
197
+ cpac claude setup # 交互式依次配置 Sonnet / Haiku / Opus
198
+ cpac claude clear # 重置所有覆盖,恢复自动选择
210
199
  ```
211
200
 
212
201
  未设置的槽位启动时自动选择:claude 池内同家族模型优先,否则按 gemini → grok → luna → 目录第一个回退;opus 无匹配则不设。
@@ -223,33 +212,25 @@ Claude Code 的发现协议不携带 context 信息,claude 前缀的未知模
223
212
 
224
213
  ### Codex
225
214
 
226
- 注入 CPA 模型目录:
215
+ 启动 Codex,后续所有原生参数原样传递(如 `-c` 动态覆盖配置、`exec` 等):
227
216
 
228
217
  ```bash
229
- cpac inject [--v2_off] [--v2_models] [--max_context] [--config PATH]
218
+ cpac codex # 自动确保注入和代理后拉起 Codex
219
+ cpac codex -c model="gpt-5" # 动态覆盖 Codex 配置(原生 -c 原样直通)
220
+ cpac codex exec "帮我写一个测试"
230
221
  ```
231
222
 
232
- 查看状态:
223
+ #### 配置与清除
233
224
 
234
225
  ```bash
235
- cpac status
226
+ cpac codex setup # 交互式配置 Subagent 候选模型、最大上下文和 Multi-Agent V2
227
+ cpac codex clear # 还原 Codex 原生配置并关闭代理
236
228
  ```
237
229
 
238
- `status` 列出所有支持的 agent 及当前状态:
239
-
240
- ```text
241
- codex: injected proxy=http://127.0.0.1:10101/v1 sha256=...
242
- claude: ready
243
- pi: ready
244
- ```
245
-
246
- - **codex**:`native`(未注入)、`injected`(已注入,附代理地址和配置摘要)、`injected (loopback proxy stopped)` 或 `injected (config missing)`
247
- - **claude**:`ready`(密钥已配置)或 `not configured`
248
- - **pi**:`ready`(密钥已配置)或 `not configured`
249
-
250
- 恢复注入前的原配置:
230
+ #### 传统注入命令
251
231
 
252
232
  ```bash
233
+ cpac inject [--v2_off] [--v2_models] [--max_context] [--config PATH]
253
234
  cpac restore
254
235
  ```
255
236
 
package/dist/cpac.js CHANGED
@@ -262,7 +262,13 @@ export async function fetchCatalog(cpaUrl, apiKey) {
262
262
  // set auto_compact_token_limit at 90% (Codex's own convention), keeping
263
263
  // compaction ahead of the hard ceiling.
264
264
  export function liftContextWindows(bytes) {
265
- const document = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
265
+ let document;
266
+ try {
267
+ document = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
268
+ }
269
+ catch {
270
+ throw new CPACError("invalid CPA catalog");
271
+ }
266
272
  for (const model of document.models) {
267
273
  const maxWindow = typeof model.max_context_window === "number" && model.max_context_window > 0
268
274
  ? Math.floor(model.max_context_window)
@@ -280,7 +286,13 @@ export function liftContextWindows(bytes) {
280
286
  // Move `order` slugs to the front of the rich catalog (stable for the rest),
281
287
  // so the Codex client's first-5 spawn_agent advertisement picks them.
282
288
  export function reorderCatalog(bytes, order) {
283
- const document = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
289
+ let document;
290
+ try {
291
+ document = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
292
+ }
293
+ catch {
294
+ throw new CPACError("invalid CPA catalog");
295
+ }
284
296
  const rank = new Map(order.map((slug, index) => [slug, index]));
285
297
  const models = document.models.map((model, index) => ({ model, index }));
286
298
  models.sort((left, right) => {
@@ -300,7 +312,13 @@ export function reorderCatalog(bytes, order) {
300
312
  export function saveSpawnModels(configPath, models) {
301
313
  let document = {};
302
314
  if (existsSync(configPath)) {
303
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
315
+ let parsed;
316
+ try {
317
+ parsed = JSON.parse(readFileSync(configPath, "utf8"));
318
+ }
319
+ catch {
320
+ throw new CPACError("config must be a valid JSON object");
321
+ }
304
322
  if (!objectValue(parsed))
305
323
  throw new CPACError("config must be a JSON object");
306
324
  document = parsed;
@@ -356,6 +374,9 @@ async function checkboxPicker(title, items, max) {
356
374
  return;
357
375
  }
358
376
  if (key === "\r" || key === "\n") {
377
+ if (max === 1 && selected.size === 0) {
378
+ selected.add(cursor);
379
+ }
359
380
  finish();
360
381
  return;
361
382
  }
@@ -388,7 +409,13 @@ export async function pickSpawnModels(config) {
388
409
  if (!apiKey)
389
410
  throw new CPACError(`environment variable ${config.api_key_env} is not set`);
390
411
  const catalog = await fetchCatalog(config.cpa_url, apiKey);
391
- const document = JSON.parse(new TextDecoder().decode(catalog.bytes));
412
+ let document;
413
+ try {
414
+ document = JSON.parse(new TextDecoder().decode(catalog.bytes));
415
+ }
416
+ catch {
417
+ throw new CPACError("invalid CPA catalog");
418
+ }
392
419
  const slugs = document.models.map((model) => model.slug);
393
420
  const picked = await checkboxPicker("Select spawn_agent models", slugs, MAX_SPAWN_MODELS);
394
421
  if (picked.length === 0)
@@ -699,13 +726,18 @@ function responseHeaders(headers) {
699
726
  return forwarded;
700
727
  }
701
728
  function upstreamUrl(cpaUrl, requestUrl) {
702
- const incoming = new URL(requestUrl, "http://127.0.0.1");
703
- const suffix = incoming.pathname === "/v1"
704
- ? ""
705
- : incoming.pathname.startsWith("/v1/")
706
- ? incoming.pathname.slice(3)
707
- : incoming.pathname;
708
- return new URL(`${apiBase(cpaUrl)}${suffix}${incoming.search}`);
729
+ try {
730
+ const incoming = new URL(requestUrl, "http://127.0.0.1");
731
+ const suffix = incoming.pathname === "/v1"
732
+ ? ""
733
+ : incoming.pathname.startsWith("/v1/")
734
+ ? incoming.pathname.slice(3)
735
+ : incoming.pathname;
736
+ return new URL(`${apiBase(cpaUrl)}${suffix}${incoming.search}`);
737
+ }
738
+ catch {
739
+ throw new CPACError(`invalid upstream request URL: ${requestUrl}`);
740
+ }
709
741
  }
710
742
  export async function createLoopbackProxy(cpaUrl, apiKey, proxyId, port) {
711
743
  const server = createServer((request, response) => {
@@ -1088,7 +1120,7 @@ async function startProxyProcess(config, apiKey) {
1088
1120
  });
1089
1121
  });
1090
1122
  }
1091
- async function stopProxyProcess(proxy) {
1123
+ export async function stopProxyProcess(proxy) {
1092
1124
  if (!(await proxyIsHealthy(proxy)))
1093
1125
  return;
1094
1126
  try {
@@ -1102,7 +1134,7 @@ async function stopProxyProcess(proxy) {
1102
1134
  // The server may close the connection while shutting down.
1103
1135
  }
1104
1136
  }
1105
- function readState(stateDir) {
1137
+ export function readState(stateDir) {
1106
1138
  const path = join(stateDir, "state.json");
1107
1139
  if (!existsSync(path))
1108
1140
  return null;
@@ -1148,7 +1180,7 @@ function readState(stateDir) {
1148
1180
  }
1149
1181
  return value;
1150
1182
  }
1151
- function stateProxy(state) {
1183
+ export function stateProxy(state) {
1152
1184
  return state.proxy_id && state.proxy_pid && state.proxy_port
1153
1185
  ? { id: state.proxy_id, pid: state.proxy_pid, port: state.proxy_port }
1154
1186
  : null;
@@ -1460,7 +1492,7 @@ export function saveApiKeyExport(profile, name, apiKey) {
1460
1492
  atomicWrite(profile, Buffer.from(content), mode);
1461
1493
  }
1462
1494
  async function guide(config) {
1463
- console.log(`CPA Companion\n\nCPA: ${config.cpa_url}\nCPA_API_KEY: ${process.env[config.api_key_env]?.trim() ? "configured" : "not configured"}\n\nCommands:\n cpac claude [args...] Launch Claude Code through CPA\n cpac inject Inject CPA into Codex and start its loopback proxy\n cpac proxy Run the injected loopback proxy in the foreground\n cpac status Show agent support status (Codex, Claude, Pi, Kimi)\n cpac restore Restore the original Codex config\n cpac pi install Install CPA provider extension for Pi\n cpac pi uninstall Remove the Pi CPA provider extension\n cpac pi status Show Pi extension install status\n cpac kimi install Write CPA provider config for Kimi Code\n cpac kimi uninstall Remove the Kimi Code CPA provider config\n cpac kimi status Show Kimi Code config install status\n cpac detect Show supported targets and install status\n cpac install Install CPA integration into detected targets\n cpac sync Refresh installed Codex/Pi/Kimi injections from the current CPA\n cpac uninstall Remove CPA integration from installed targets\n cpac upgrade Update cpac from npm and sync installed agents\n cpac --help Show command usage`);
1495
+ console.log(`CPA Companion\n\nCPA: ${config.cpa_url}\nCPA_API_KEY: ${process.env[config.api_key_env]?.trim() ? "configured" : "not configured"}\n\nCommands:\n cpac <agent> [args...] Launch an agent (codex, kimi, grok, pi, claude, opencode) through CPA\n cpac inject Inject CPA into Codex and start its loopback proxy\n cpac proxy Run the injected loopback proxy in the foreground\n cpac status Show agent support status (Codex, Claude, Pi, Kimi, Grok)\n cpac restore Restore the original Codex config\n cpac detect Show supported targets and install status\n cpac install Install CPA integration into detected targets\n cpac sync Refresh installed Codex/Pi/Kimi/Grok injections from CPA\n cpac uninstall Remove CPA integration from installed targets\n cpac upgrade Update cpac from npm and sync installed agents\n cpac --help Show command usage`);
1464
1496
  if (process.env[config.api_key_env]?.trim())
1465
1497
  return 0;
1466
1498
  const apiKey = await promptSecret(config.api_key_env);
@@ -1481,7 +1513,13 @@ export async function runOpencode(config, args, executable = "opencode") {
1481
1513
  if (!apiKey)
1482
1514
  throw new CPACError(`environment variable ${config.api_key_env} is not set`);
1483
1515
  const catalog = await fetchCatalog(config.cpa_url, apiKey);
1484
- const document = JSON.parse(new TextDecoder().decode(catalog.bytes));
1516
+ let document;
1517
+ try {
1518
+ document = JSON.parse(new TextDecoder().decode(catalog.bytes));
1519
+ }
1520
+ catch {
1521
+ throw new CPACError("invalid CPA catalog");
1522
+ }
1485
1523
  const models = {};
1486
1524
  for (const row of document.models) {
1487
1525
  const id = catalogModelId(row);
@@ -1523,24 +1561,119 @@ export async function runOpencode(config, args, executable = "opencode") {
1523
1561
  child.once("close", (code) => resolve(code ?? 1));
1524
1562
  });
1525
1563
  }
1526
- export async function runClaudeModels(parsed) {
1527
- if (parsed.pick.length) {
1528
- const config = loadConfig(parsed.configPath, true);
1529
- const apiKey = process.env[config.api_key_env]?.trim();
1530
- if (!apiKey)
1531
- throw new CPACError(`environment variable ${config.api_key_env} is not set`);
1564
+ export async function runCodexConfig(config, configPath, options = {}) {
1565
+ if (options.reset) {
1566
+ let raw = {};
1567
+ try {
1568
+ const file = JSON.parse(readFileSync(configPath, "utf8"));
1569
+ if (!objectValue(file))
1570
+ throw new CPACError("config must be a JSON object");
1571
+ raw = file;
1572
+ }
1573
+ catch (error) {
1574
+ if (error.code !== "ENOENT") {
1575
+ if (error instanceof CPACError)
1576
+ throw error;
1577
+ throw new CPACError(`cannot read config: ${error instanceof Error ? error.message : String(error)}`);
1578
+ }
1579
+ }
1580
+ delete raw.spawn_models;
1581
+ atomicWrite(configPath, Buffer.from(`${JSON.stringify(raw, null, 2)}\n`));
1582
+ await runRestore(config, ["codex"], { all: false, dryRun: false });
1583
+ console.log("Codex configuration reset to default");
1584
+ return 0;
1585
+ }
1586
+ if (options.interactive && (!process.stdin.isTTY || !process.stderr.isTTY)) {
1587
+ console.log(config.spawn_models
1588
+ ? JSON.stringify(config.spawn_models, null, 2)
1589
+ : "spawn_models not set");
1590
+ return 0;
1591
+ }
1592
+ let spawnModels = config.spawn_models;
1593
+ let v2Off = options.v2Off ?? false;
1594
+ let maxContext = options.maxContext ?? false;
1595
+ if (options.interactive || options.v2Models) {
1596
+ const picked = await pickSpawnModels(config);
1597
+ if (picked.length > 0) {
1598
+ spawnModels = picked;
1599
+ saveSpawnModels(configPath, picked);
1600
+ console.log(`spawn_models saved to ${configPath}: ${picked.join(", ")}`);
1601
+ }
1602
+ if (options.interactive) {
1603
+ const [maxCtxChoice] = await checkboxPicker("Codex Context Window Size:", [
1604
+ "Standard (default input budget, e.g. 200K~272K)",
1605
+ "Max Context Window (lift to full upper bound, e.g. 921K~1M, 90% auto-compact limit)",
1606
+ ], 1);
1607
+ maxContext = maxCtxChoice?.startsWith("Max Context") ?? false;
1608
+ const [v2Choice] = await checkboxPicker("Codex Multi-Agent V2 mode:", ["Enabled (Default)", "Disabled (--v2_off)"], 1);
1609
+ v2Off = v2Choice === "Disabled (--v2_off)";
1610
+ }
1611
+ }
1612
+ const updatedConfig = { ...config, spawn_models: spawnModels };
1613
+ await runInstall(updatedConfig, ["codex"], {
1614
+ all: false,
1615
+ dryRun: false,
1616
+ force: true,
1617
+ v2Off,
1618
+ maxContext,
1619
+ });
1620
+ console.log("Codex configuration updated and synced.");
1621
+ return 0;
1622
+ }
1623
+ export async function runClaudeConfig(config, configPath, options = {}) {
1624
+ if (options.reset) {
1625
+ let raw = {};
1626
+ try {
1627
+ const file = JSON.parse(readFileSync(configPath, "utf8"));
1628
+ if (!objectValue(file))
1629
+ throw new CPACError("config must be a JSON object");
1630
+ raw = file;
1631
+ }
1632
+ catch (error) {
1633
+ if (error.code !== "ENOENT") {
1634
+ if (error instanceof CPACError)
1635
+ throw error;
1636
+ throw new CPACError(`cannot read config: ${error instanceof Error ? error.message : String(error)}`);
1637
+ }
1638
+ }
1639
+ delete raw.claude_models;
1640
+ atomicWrite(configPath, Buffer.from(`${JSON.stringify(raw, null, 2)}\n`));
1641
+ console.log("Claude Code tier models reset to CPA catalog auto-selection");
1642
+ return 0;
1643
+ }
1644
+ if (options.interactive && (!process.stdin.isTTY || !process.stderr.isTTY)) {
1645
+ console.log(config.claude_models
1646
+ ? JSON.stringify(config.claude_models, null, 2)
1647
+ : "claude_models not set; cpac claude auto-picks tier models from the CPA catalog");
1648
+ return 0;
1649
+ }
1650
+ const models = { ...(options.models || {}) };
1651
+ const shouldPick = options.interactive || (options.pick && options.pick.length > 0);
1652
+ if (shouldPick) {
1653
+ const apiKey = await resolveApiKey(config.api_key_env);
1532
1654
  const catalog = await fetchCatalog(config.cpa_url, apiKey);
1533
- const document = JSON.parse(new TextDecoder().decode(catalog.bytes));
1534
- const slugs = document.models.map((model) => model.slug);
1535
- for (const slot of parsed.pick) {
1536
- const [picked] = await checkboxPicker(`Select ${slot} model`, slugs, 1);
1537
- if (!picked)
1538
- throw new CPACError("no model selected");
1539
- parsed.models[slot] = picked;
1655
+ let document;
1656
+ try {
1657
+ document = JSON.parse(new TextDecoder().decode(catalog.bytes));
1658
+ }
1659
+ catch {
1660
+ throw new CPACError("CPA catalog response is not valid JSON");
1661
+ }
1662
+ const slugs = (document.models ?? [])
1663
+ .map((model) => (typeof model?.slug === "string" ? model.slug : ""))
1664
+ .filter(Boolean);
1665
+ const slotsToPick = options.pick && options.pick.length > 0
1666
+ ? options.pick
1667
+ : ["sonnet", "haiku", "opus"];
1668
+ for (const slot of slotsToPick) {
1669
+ const choices = slot === "sonnet" ? slugs : ["(auto-select from catalog)", ...slugs];
1670
+ const [picked] = await checkboxPicker(`Select Claude Code ${slot.toUpperCase()} tier model:`, choices, 1);
1671
+ if (picked && picked !== "(auto-select from catalog)") {
1672
+ models[slot] = picked;
1673
+ }
1540
1674
  }
1541
1675
  }
1542
- if (!parsed.reset && !Object.keys(parsed.models).length) {
1543
- const config = loadConfig(parsed.configPath, true);
1676
+ if (!Object.keys(models).length && !shouldPick) {
1544
1677
  console.log(config.claude_models
1545
1678
  ? JSON.stringify(config.claude_models, null, 2)
1546
1679
  : "claude_models not set; cpac claude auto-picks tier models from the CPA catalog");
@@ -1548,7 +1681,7 @@ export async function runClaudeModels(parsed) {
1548
1681
  }
1549
1682
  let raw = {};
1550
1683
  try {
1551
- const file = JSON.parse(readFileSync(parsed.configPath, "utf8"));
1684
+ const file = JSON.parse(readFileSync(configPath, "utf8"));
1552
1685
  if (!objectValue(file))
1553
1686
  throw new CPACError("config must be a JSON object");
1554
1687
  raw = file;
@@ -1560,23 +1693,25 @@ export async function runClaudeModels(parsed) {
1560
1693
  throw new CPACError(`cannot read config: ${error instanceof Error ? error.message : String(error)}`);
1561
1694
  }
1562
1695
  }
1563
- if (parsed.reset) {
1564
- delete raw.claude_models;
1565
- }
1566
- else {
1567
- const existing = objectValue(raw.claude_models)
1568
- ? { ...raw.claude_models }
1569
- : {};
1570
- Object.assign(existing, parsed.models);
1571
- raw.claude_models = existing;
1572
- }
1573
- atomicWrite(parsed.configPath, Buffer.from(`${JSON.stringify(raw, null, 2)}\n`));
1574
- loadConfig(parsed.configPath);
1575
- console.log(parsed.reset
1576
- ? "claude_models cleared"
1577
- : `claude_models updated: ${JSON.stringify(raw.claude_models)}`);
1696
+ const existing = objectValue(raw.claude_models)
1697
+ ? { ...raw.claude_models }
1698
+ : {};
1699
+ Object.assign(existing, models);
1700
+ raw.claude_models = existing;
1701
+ atomicWrite(configPath, Buffer.from(`${JSON.stringify(raw, null, 2)}\n`));
1702
+ loadConfig(configPath);
1703
+ console.log(`Claude Code tier models updated in ${configPath}:\n${JSON.stringify(raw.claude_models, null, 2)}`);
1578
1704
  return 0;
1579
1705
  }
1706
+ export async function runClaudeModels(parsed) {
1707
+ const config = loadConfig(parsed.configPath, true);
1708
+ return await runClaudeConfig(config, parsed.configPath, {
1709
+ models: parsed.models,
1710
+ pick: parsed.pick,
1711
+ reset: parsed.reset,
1712
+ interactive: false,
1713
+ });
1714
+ }
1580
1715
  export async function runClaude(config, args, executable = "claude") {
1581
1716
  const apiKey = await resolveApiKey(config.api_key_env);
1582
1717
  const proxy = await createClaudeProxy(config.cpa_url, apiKey);
@@ -1653,6 +1788,53 @@ export async function runClaude(config, args, executable = "claude") {
1653
1788
  });
1654
1789
  });
1655
1790
  }
1791
+ export async function runTargetLauncher(config, targetId, args, executable = targetId, options = {}) {
1792
+ const target = TARGETS.find((t) => t.id === targetId);
1793
+ if (!target)
1794
+ throw new CPACError(`unknown target: ${targetId}`);
1795
+ if (["kimi", "grok"].includes(targetId)) {
1796
+ const state = readState(config.state_dir);
1797
+ const proxy = state ? stateProxy(state) : null;
1798
+ if (!proxy || !(await proxyIsHealthy(proxy))) {
1799
+ const apiKey = await resolveApiKey(config.api_key_env);
1800
+ const started = await startProxyProcess(config, apiKey);
1801
+ const fingerprint = proxyFingerprint(config, apiKey);
1802
+ const catalog = await fetchCatalog(config.cpa_url, apiKey);
1803
+ const catalogPath = join(config.state_dir, "codex-models.json");
1804
+ mkdirSync(config.state_dir, { recursive: true, mode: 0o700 });
1805
+ atomicWrite(catalogPath, catalog.bytes);
1806
+ const originalMode = existsSync(config.codex_config)
1807
+ ? statSync(config.codex_config).mode & 0o7777
1808
+ : 0o600;
1809
+ atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, state ? state.config_existed : false, state ? state.config_mode : originalMode, started, fingerprint));
1810
+ }
1811
+ }
1812
+ if (target.installed(config)) {
1813
+ try {
1814
+ await runSync(config, [targetId], {
1815
+ all: false,
1816
+ dryRun: false,
1817
+ v2Off: options.v2Off,
1818
+ maxContext: options.maxContext,
1819
+ });
1820
+ }
1821
+ catch (error) {
1822
+ console.warn(`warning: auto-sync failed (${error instanceof Error ? error.message : String(error)}); launching with existing config`);
1823
+ }
1824
+ }
1825
+ else {
1826
+ await target.install(config, false, options.v2Off, options.maxContext);
1827
+ }
1828
+ return await new Promise((resolvePromise, rejectPromise) => {
1829
+ const child = spawn(executable, args, { stdio: "inherit" });
1830
+ child.once("error", (error) => {
1831
+ rejectPromise(new CPACError(error instanceof Error && "code" in error && error.code === "ENOENT"
1832
+ ? `${executable} not found`
1833
+ : `cannot start ${executable}: ${error.message}`));
1834
+ });
1835
+ child.once("close", (code) => resolvePromise(code ?? 1));
1836
+ });
1837
+ }
1656
1838
  export async function runProxy(config) {
1657
1839
  const apiKey = await resolveApiKey(config.api_key_env);
1658
1840
  const state = readState(config.state_dir);
@@ -1819,7 +2001,13 @@ function kimiInputHasImage(row) {
1819
2001
  export async function installKimiConfig(config) {
1820
2002
  const apiKey = await resolveApiKey(config.api_key_env);
1821
2003
  const catalog = await fetchCatalog(config.cpa_url, apiKey);
1822
- const document = JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes));
2004
+ let document;
2005
+ try {
2006
+ document = JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes));
2007
+ }
2008
+ catch {
2009
+ throw new CPACError("invalid CPA catalog");
2010
+ }
1823
2011
  const rows = catalogModelRows(document) ?? [];
1824
2012
  if (rows.length === 0)
1825
2013
  throw new CPACError("CPA catalog contains no models");
@@ -1941,7 +2129,13 @@ function writeGrokBlock(path, block) {
1941
2129
  export async function installGrokConfig(config) {
1942
2130
  const apiKey = await resolveApiKey(config.api_key_env);
1943
2131
  const catalog = await fetchCatalog(config.cpa_url, apiKey);
1944
- const document = JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes));
2132
+ let document;
2133
+ try {
2134
+ document = JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes));
2135
+ }
2136
+ catch {
2137
+ throw new CPACError("invalid CPA catalog");
2138
+ }
1945
2139
  const rows = catalogModelRows(document) ?? [];
1946
2140
  if (rows.length === 0)
1947
2141
  throw new CPACError("CPA catalog contains no models");
@@ -2207,6 +2401,17 @@ export async function runUninstall(config, requested, options) {
2207
2401
  }
2208
2402
  return 0;
2209
2403
  }
2404
+ export async function runRestore(config, requested, options) {
2405
+ if (requested.length > 0 || options.all) {
2406
+ return runUninstall(config, requested, options);
2407
+ }
2408
+ if (options.dryRun) {
2409
+ console.log("would restore the original Codex config");
2410
+ return 0;
2411
+ }
2412
+ await restore(config);
2413
+ return 0;
2414
+ }
2210
2415
  function compareVersions(a, b) {
2211
2416
  const left = a.split(".").map(Number);
2212
2417
  const right = b.split(".").map(Number);
@@ -2270,21 +2475,21 @@ export async function runUpgrade(config, checkOnly) {
2270
2475
  function usage() {
2271
2476
  return [
2272
2477
  "Usage: cpac",
2478
+ " cpac <codex|kimi|grok|pi|claude|opencode> [args...]",
2479
+ " cpac <codex|claude> setup",
2480
+ " cpac <codex|claude|kimi|grok|pi> clear",
2273
2481
  " cpac inject [--v2_off] [--v2_models] [--max_context] [--config PATH]",
2274
- " cpac <status|restore> [--config PATH]",
2482
+ " cpac restore [--target codex,pi,kimi,grok] [--all] [--dry-run] [--config PATH]",
2483
+ " cpac status [--config PATH]",
2275
2484
  " cpac proxy [--config PATH]",
2276
- " cpac claude [--config PATH] [--] [claude args...]",
2277
- " cpac opencode [--config PATH] [--] [opencode args...]",
2278
- " cpac claude-models [--opus [M]] [--sonnet [M]] [--haiku [M]] [--reset] [--config PATH]",
2279
2485
  " cpac detect [--json] [--home PATH]",
2280
2486
  " cpac install [--target codex,pi,kimi,grok] [--all] [--dry-run] [--force] [--v2_off] [--v2_models] [--max_context] [--home PATH]",
2281
2487
  " cpac sync [--target codex,pi,kimi,grok] [--all] [--dry-run] [--v2_off] [--v2_models] [--max_context] [--home PATH]",
2282
- " cpac uninstall [--target codex,pi,kimi,grok] [--all] [--dry-run] [--home PATH]",
2283
2488
  " cpac upgrade [--check]",
2284
2489
  " cpac version",
2285
2490
  "",
2286
- "install/uninstall manage persistent injections only; claude and opencode are",
2287
- "ephemeral launch them with `cpac claude` / `cpac opencode` instead.",
2491
+ "launch an agent directly with `cpac <agent> [args...]` (all args forwarded).",
2492
+ "configure an agent with `cpac <agent> setup` or restore with `cpac <agent> clear`.",
2288
2493
  ].join("\n");
2289
2494
  }
2290
2495
  function parseArgs(args) {
@@ -2295,6 +2500,7 @@ function parseArgs(args) {
2295
2500
  args[0] === "install" ||
2296
2501
  args[0] === "sync" ||
2297
2502
  args[0] === "uninstall" ||
2503
+ args[0] === "restore" ||
2298
2504
  args[0] === "upgrade" ||
2299
2505
  args[0] === "version") {
2300
2506
  const parsed = {
@@ -2358,10 +2564,100 @@ function parseArgs(args) {
2358
2564
  }
2359
2565
  return parsed;
2360
2566
  }
2567
+ if (["codex", "kimi", "grok", "pi", "claude", "opencode"].includes(args[0])) {
2568
+ const targetId = args[0];
2569
+ let configPath = defaultConfigPath();
2570
+ if (args[1] === "clear" || args[1] === "--clear") {
2571
+ for (let i = 2; i < args.length; i++) {
2572
+ if (args[i] === "--config") {
2573
+ const val = args[++i];
2574
+ if (val)
2575
+ configPath = resolve(expandUserPath(val));
2576
+ }
2577
+ }
2578
+ if (targetId === "claude") {
2579
+ return {
2580
+ command: "claude-config",
2581
+ configPath,
2582
+ models: {},
2583
+ pick: [],
2584
+ reset: true,
2585
+ interactive: false,
2586
+ };
2587
+ }
2588
+ return {
2589
+ command: "restore",
2590
+ configPath,
2591
+ json: false,
2592
+ targets: [targetId],
2593
+ all: false,
2594
+ dryRun: false,
2595
+ force: false,
2596
+ check: false,
2597
+ v2Off: false,
2598
+ v2Models: false,
2599
+ maxContext: false,
2600
+ };
2601
+ }
2602
+ if (args[1] === "setup" || args[1] === "--setup") {
2603
+ for (let i = 2; i < args.length; i++) {
2604
+ if (args[i] === "--config") {
2605
+ const val = args[++i];
2606
+ if (val)
2607
+ configPath = resolve(expandUserPath(val));
2608
+ }
2609
+ }
2610
+ if (targetId === "codex") {
2611
+ return {
2612
+ command: "codex-config",
2613
+ configPath,
2614
+ v2Off: false,
2615
+ v2Models: false,
2616
+ maxContext: false,
2617
+ reset: false,
2618
+ interactive: true,
2619
+ };
2620
+ }
2621
+ if (targetId === "claude") {
2622
+ return {
2623
+ command: "claude-config",
2624
+ configPath,
2625
+ models: {},
2626
+ pick: [],
2627
+ reset: false,
2628
+ interactive: true,
2629
+ };
2630
+ }
2631
+ throw new CPACError(`${targetId} does not require setup (all CPA models are auto-synced). Run 'cpac ${targetId}' or 'cpac install --target ${targetId}'.`);
2632
+ }
2633
+ const passArgs = args.slice(1);
2634
+ if (targetId === "codex") {
2635
+ return {
2636
+ command: "codex",
2637
+ configPath,
2638
+ args: passArgs,
2639
+ v2Off: false,
2640
+ v2Models: false,
2641
+ maxContext: false,
2642
+ };
2643
+ }
2644
+ if (targetId === "claude") {
2645
+ return {
2646
+ command: "claude",
2647
+ configPath,
2648
+ args: passArgs,
2649
+ };
2650
+ }
2651
+ return {
2652
+ command: targetId,
2653
+ configPath,
2654
+ args: passArgs,
2655
+ };
2656
+ }
2361
2657
  if (args[0] === "claude-models") {
2362
2658
  if (args.includes("-h") || args.includes("--help")) {
2363
2659
  console.log([
2364
- "Usage: cpac claude-models [--opus [M]] [--sonnet [M]] [--haiku [M]] [--reset] [--config PATH]",
2660
+ "Usage: cpac claude --config [--opus [M]] [--sonnet [M]] [--haiku [M]] [--reset] [--config PATH]",
2365
2661
  "",
2366
2662
  "Sets the model Claude Code uses per task tier. Saved to the config file",
2367
2663
  "and applied on every `cpac claude` launch:",
@@ -2410,35 +2706,14 @@ function parseArgs(args) {
2410
2706
  throw new CPACError(`unknown option: ${arg}`);
2411
2707
  }
2412
2708
  }
2413
- return { command: "claude-models", configPath, models, pick, reset };
2414
- }
2415
- if (args[0] === "claude") {
2416
- let configPath = defaultConfigPath();
2417
- let index = 1;
2418
- if (args[index] === "--config") {
2419
- const value = args[++index];
2420
- if (!value)
2421
- throw new CPACError("--config requires a path");
2422
- configPath = resolve(expandUserPath(value));
2423
- index += 1;
2424
- }
2425
- if (args[index] === "--")
2426
- index += 1;
2427
- return { command: "claude", configPath, args: args.slice(index) };
2428
- }
2429
- if (args[0] === "opencode") {
2430
- let configPath = defaultConfigPath();
2431
- let index = 1;
2432
- if (args[index] === "--config") {
2433
- const value = args[++index];
2434
- if (!value)
2435
- throw new CPACError("--config requires a path");
2436
- configPath = resolve(expandUserPath(value));
2437
- index += 1;
2438
- }
2439
- if (args[index] === "--")
2440
- index += 1;
2441
- return { command: "opencode", configPath, args: args.slice(index) };
2709
+ return {
2710
+ command: "claude-config",
2711
+ configPath,
2712
+ models,
2713
+ pick,
2714
+ reset,
2715
+ interactive: !reset && Object.keys(models).length === 0 && pick.length === 0,
2716
+ };
2442
2717
  }
2443
2718
  if (args.includes("-h") || args.includes("--help")) {
2444
2719
  console.log(usage());
@@ -2473,7 +2748,7 @@ function parseArgs(args) {
2473
2748
  positional.push(args[index]);
2474
2749
  }
2475
2750
  if (positional.length !== 1 ||
2476
- !["inject", "status", "restore", "proxy"].includes(positional[0])) {
2751
+ !["inject", "status", "proxy"].includes(positional[0])) {
2477
2752
  throw new CPACError(usage());
2478
2753
  }
2479
2754
  if (positional[0] !== "inject" && (v2Off || v2Models || maxContext)) {
@@ -2536,8 +2811,8 @@ export async function main(args = process.argv.slice(2)) {
2536
2811
  maxContext: parsed.maxContext,
2537
2812
  });
2538
2813
  }
2539
- if (parsed.command === "uninstall")
2540
- return await runUninstall(config, parsed.targets, {
2814
+ if (parsed.command === "uninstall" || parsed.command === "restore")
2815
+ return await runRestore(config, parsed.targets, {
2541
2816
  all: parsed.all,
2542
2817
  dryRun: parsed.dryRun,
2543
2818
  });
@@ -2549,8 +2824,38 @@ export async function main(args = process.argv.slice(2)) {
2549
2824
  }
2550
2825
  if (parsed.command === "claude")
2551
2826
  return await runClaude(config, parsed.args);
2827
+ if (parsed.command === "claude-config")
2828
+ return await runClaudeConfig(config, parsed.configPath, {
2829
+ models: parsed.models,
2830
+ pick: parsed.pick,
2831
+ reset: parsed.reset,
2832
+ interactive: parsed.interactive,
2833
+ });
2834
+ if (parsed.command === "codex-config")
2835
+ return await runCodexConfig(config, parsed.configPath, {
2836
+ v2Off: parsed.v2Off,
2837
+ v2Models: parsed.v2Models,
2838
+ maxContext: parsed.maxContext,
2839
+ reset: parsed.reset,
2840
+ interactive: parsed.interactive,
2841
+ });
2552
2842
  if (parsed.command === "opencode")
2553
2843
  return await runOpencode(config, parsed.args);
2844
+ if (parsed.command === "codex") {
2845
+ let launcherConfig = config;
2846
+ if (parsed.v2Models) {
2847
+ const picked = await pickSpawnModels(config);
2848
+ saveSpawnModels(parsed.configPath, picked);
2849
+ console.log(`spawn_models saved to ${parsed.configPath}: ${picked.join(", ")}`);
2850
+ launcherConfig = { ...config, spawn_models: picked };
2851
+ }
2852
+ return await runTargetLauncher(launcherConfig, "codex", parsed.args, "codex", { v2Off: parsed.v2Off, maxContext: parsed.maxContext });
2853
+ }
2854
+ if (parsed.command === "kimi" ||
2855
+ parsed.command === "grok" ||
2856
+ parsed.command === "pi") {
2857
+ return await runTargetLauncher(config, parsed.command, parsed.args);
2858
+ }
2554
2859
  if (parsed.command === "claude-models")
2555
2860
  return await runClaudeModels(parsed);
2556
2861
  if (parsed.command === "proxy")
@@ -2565,8 +2870,6 @@ export async function main(args = process.argv.slice(2)) {
2565
2870
  }
2566
2871
  await inject(injectConfig, parsed.v2Off, parsed.maxContext);
2567
2872
  }
2568
- else if (parsed.command === "restore")
2569
- await restore(config);
2570
2873
  else
2571
2874
  return await status(config);
2572
2875
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yhong91/cpac",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
5
5
  "type": "module",
6
6
  "bin": {