@yhong91/cpac 0.1.3 → 0.1.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
@@ -91,6 +91,24 @@ cpac
91
91
  cpac --help
92
92
  ```
93
93
 
94
+ ### 统一管理(detect / install / uninstall / upgrade)
95
+
96
+ 参考 vibetime 的目标式命令,对 `codex`、`claude`、`pi`、`kimi` 四个目标统一管理:
97
+
98
+ ```bash
99
+ cpac detect [--json] [--home PATH]
100
+ cpac install [--target codex,claude,pi,kimi] [--all] [--dry-run] [--force] [--home PATH]
101
+ cpac uninstall [--target codex,claude,pi,kimi] [--all] [--dry-run] [--home PATH]
102
+ cpac upgrade [--check]
103
+ cpac version
104
+ ```
105
+
106
+ - `detect` 列出每个目标是否被检测到、是否已接入 CPAC;`--json` 输出机器可读结果。
107
+ - `install` 默认作用于检测到的目标;`--target` 指定目标、`--all` 全部。各目标映射到既有逻辑:`codex` → `cpac inject`,`pi` → `cpac pi install`,`kimi` → `cpac kimi install`;`claude` 无持久化安装(用 `cpac claude` 启动)。`--dry-run` 只打印将要做的事,`--force` 允许重装已安装目标。
108
+ - `uninstall` 默认作用于已安装目标,映射到 `cpac restore` / `cpac pi uninstall` / `cpac kimi uninstall`。
109
+ - `--home PATH` 临时把 `HOME` 指向给定目录,便于在隔离环境中试验。
110
+ - `upgrade` 检查 npm 上是否有新版本,`--check` 只报告不提示安装命令。
111
+
94
112
  ### Claude Code
95
113
 
96
114
  通过 CPA 启动 Claude Code,后续参数原样传递:
@@ -101,16 +119,20 @@ cpac claude --model claude-sonnet-4-5
101
119
  cpac claude -- -p "检查当前项目"
102
120
  ```
103
121
 
104
- `cpac claude` 会为 Claude Code 子进程设置:
122
+ `cpac claude` 会在本地起一个临时 loopback 代理,并为 Claude Code 子进程设置:
105
123
 
106
124
  ```text
107
- ANTHROPIC_BASE_URL=<CPA 地址>
125
+ ANTHROPIC_BASE_URL=http://127.0.0.1:<临时端口>
108
126
  ANTHROPIC_AUTH_TOKEN=<CPA_API_KEY>
127
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
128
+ CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1
109
129
  ```
110
130
 
111
131
  同时清除可能覆盖网关选择的 `ANTHROPIC_API_KEY` 和 Claude Code 云 provider 环境变量。它不会修改 `~/.claude/`,并原样返回 Claude Code 的退出码。
112
132
 
113
- > CPA 服务端必须支持 Claude Code 使用的 Anthropic Messages API(`/v1/messages`)。Claude 路径直接连接远端 CPA;CPAC 不负责协议转换。
133
+ 代理开启 Claude Code gateway model discovery:`/model` 选择器会列出 CPA 目录里的全部模型。claude 系模型直接使用原 id;其它模型以 `claude-cpac--<模型名>` 别名出现(例如 `claude-cpac--gpt-5.6-sol`),请求发出时由代理还原为真实模型名。
134
+
135
+ > CPA 服务端必须支持 Claude Code 使用的 Anthropic Messages API(`/v1/messages`)。
114
136
 
115
137
  ### Codex
116
138
 
@@ -192,6 +214,23 @@ cpac pi status
192
214
 
193
215
  Pi 扩展尊重 `PI_CODING_AGENT_DIR` 环境变量。
194
216
 
217
+ ### Kimi Code
218
+
219
+ 写入 CPA provider 配置到 Kimi Code:
220
+
221
+ ```bash
222
+ cpac kimi install
223
+ ```
224
+
225
+ 它在 `~/.kimi-code/config.toml` 追加一个由 CPAC 管理的块,注册 `cpac` OpenAI 兼容 provider,并把 CPA 目录里的每个模型映射为一个 `cpac/<模型名>` 条目。`base_url` 指向 Codex 注入使用的 loopback 代理,因此密钥不落盘;代理未运行时 install 会提示先执行 `cpac inject`。卸载与状态:
226
+
227
+ ```bash
228
+ cpac kimi uninstall
229
+ cpac kimi status
230
+ ```
231
+
232
+ Kimi Code 配置目录尊重 `KIMI_CODE_HOME` 环境变量。首次写入前若 `config.toml` 已存在,会在同目录保留一份原始快照 `config.toml.cpac-backup`(只存最早版本),万一文件损坏可手动复制回去。
233
+
195
234
  ## 环境变量
196
235
 
197
236
  | 变量 | 默认值 | 作用 |
@@ -199,6 +238,7 @@ Pi 扩展尊重 `PI_CODING_AGENT_DIR` 环境变量。
199
238
  | `CPA_API_KEY` | 无 | CPA Bearer key;可由 `cpac` 首次引导写入 shell 启动文件 |
200
239
  | `CPA_BASE_URL` | `https://cpa.vibetime.cc` | 覆盖内置 CPA 地址 |
201
240
  | `PI_CODING_AGENT_DIR` | `~/.pi/agent` | 覆盖 Pi agent 目录(影响 `cpac pi install` 写入位置) |
241
+ | `KIMI_CODE_HOME` | `~/.kimi-code` | 覆盖 Kimi Code 目录(影响 `cpac kimi install` 写入位置) |
202
242
  | `CPAC_CONFIG` | `~/.config/cpac/config.json` | 指定可选 JSON 配置路径 |
203
243
  | `CODEX_HOME` | `~/.codex` | Codex home 目录 |
204
244
 
package/dist/cpac.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
- import { spawn } from "node:child_process";
2
+ import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { spawn, spawnSync } from "node:child_process";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
5
  import { createServer, request as httpRequest, } from "node:http";
6
6
  import { request as httpsRequest } from "node:https";
@@ -507,6 +507,134 @@ export async function createLoopbackProxy(cpaUrl, apiKey, proxyId, port) {
507
507
  }
508
508
  return { server, port: address.port };
509
509
  }
510
+ const CLAUDE_ALIAS_PREFIX = "claude-cpac--";
511
+ function catalogModelRows(document) {
512
+ if (!objectValue(document))
513
+ return undefined;
514
+ const source = Array.isArray(document.models)
515
+ ? document.models
516
+ : Array.isArray(document.data)
517
+ ? document.data
518
+ : undefined;
519
+ if (!source)
520
+ return undefined;
521
+ return source.filter(objectValue);
522
+ }
523
+ function catalogModelId(model) {
524
+ if (typeof model.slug === "string" && model.slug.trim())
525
+ return model.slug.trim();
526
+ if (typeof model.id === "string" && model.id.trim())
527
+ return model.id.trim();
528
+ return undefined;
529
+ }
530
+ async function claudeModelList(cpaUrl, apiKey, response) {
531
+ let rows;
532
+ try {
533
+ const catalog = await fetch(`${apiBase(cpaUrl)}/models?client_version=1`, {
534
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
535
+ signal: AbortSignal.timeout(20_000),
536
+ });
537
+ if (catalog.ok)
538
+ rows = catalogModelRows(await catalog.json());
539
+ }
540
+ catch {
541
+ rows = undefined;
542
+ }
543
+ if (!rows) {
544
+ response.writeHead(502, { "content-type": "application/json" });
545
+ response.end(JSON.stringify({ error: "CPA catalog request failed" }));
546
+ return;
547
+ }
548
+ // Claude Code's /model picker only lists ids starting with claude/anthropic;
549
+ // expose every other catalog model as claude-cpac--<id>.
550
+ const data = [];
551
+ for (const model of rows) {
552
+ const id = catalogModelId(model);
553
+ if (!id)
554
+ continue;
555
+ const alias = id.startsWith("claude") ? id : `${CLAUDE_ALIAS_PREFIX}${id}`;
556
+ data.push({
557
+ type: "model",
558
+ id: alias,
559
+ display_name: typeof model.display_name === "string" && model.display_name.trim()
560
+ ? model.display_name
561
+ : id,
562
+ });
563
+ }
564
+ response.writeHead(200, { "content-type": "application/json" });
565
+ response.end(JSON.stringify({
566
+ data,
567
+ has_more: false,
568
+ first_id: data.length > 0 ? data[0].id : null,
569
+ last_id: data.length > 0 ? data[data.length - 1].id : null,
570
+ }));
571
+ }
572
+ export async function createClaudeProxy(cpaUrl, apiKey) {
573
+ const server = createServer((request, response) => {
574
+ const requestUrl = request.url ?? "/";
575
+ if (request.method === "GET" && requestUrl.startsWith("/v1/models")) {
576
+ void claudeModelList(cpaUrl, apiKey, response);
577
+ return;
578
+ }
579
+ let target;
580
+ try {
581
+ target = upstreamUrl(cpaUrl, requestUrl);
582
+ }
583
+ catch {
584
+ response.writeHead(400).end("invalid request URL");
585
+ return;
586
+ }
587
+ const chunks = [];
588
+ request.on("data", (chunk) => chunks.push(chunk));
589
+ request.once("error", () => response.destroy());
590
+ request.once("end", () => {
591
+ let body = Buffer.concat(chunks);
592
+ if (body.length > 0) {
593
+ try {
594
+ const parsed = JSON.parse(body.toString("utf8"));
595
+ if (objectValue(parsed) &&
596
+ typeof parsed.model === "string" &&
597
+ parsed.model.startsWith(CLAUDE_ALIAS_PREFIX)) {
598
+ parsed.model = parsed.model.slice(CLAUDE_ALIAS_PREFIX.length);
599
+ body = Buffer.from(JSON.stringify(parsed));
600
+ }
601
+ }
602
+ catch {
603
+ // not JSON; forward unchanged
604
+ }
605
+ }
606
+ const headers = proxyHeaders(request.headers, apiKey);
607
+ headers["content-length"] = String(body.length);
608
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
609
+ const upstream = send(target, { method: request.method, headers }, (upstreamResponse) => {
610
+ response.writeHead(upstreamResponse.statusCode ?? 502, responseHeaders(upstreamResponse.headers));
611
+ upstreamResponse.pipe(response);
612
+ });
613
+ upstream.once("error", () => {
614
+ if (!response.headersSent) {
615
+ response.writeHead(502, { "content-type": "application/json" });
616
+ }
617
+ if (!response.writableEnded)
618
+ response.end(JSON.stringify({ error: "CPA upstream request failed" }));
619
+ });
620
+ upstream.end(body);
621
+ });
622
+ });
623
+ server.on("clientError", (_error, socket) => socket.destroy());
624
+ await new Promise((resolveListen, rejectListen) => {
625
+ server.once("error", rejectListen);
626
+ server.listen(0, "127.0.0.1", () => {
627
+ server.off("error", rejectListen);
628
+ resolveListen();
629
+ });
630
+ });
631
+ const address = server.address();
632
+ if (!address || typeof address === "string") {
633
+ server.close();
634
+ throw new CPACError("cannot determine Claude proxy port");
635
+ }
636
+ return { server, port: address.port };
637
+ }
510
638
  async function runProxyChild(port) {
511
639
  const cpaUrl = process.env.CPAC_PROXY_UPSTREAM;
512
640
  const apiKey = process.env.CPAC_PROXY_API_KEY;
@@ -888,6 +1016,8 @@ export async function status(config) {
888
1016
  console.log(`claude: ${keyConfigured ? "ready" : "not configured"}`);
889
1017
  const piInstalled = isPiExtensionInstalled();
890
1018
  console.log(`pi: ${keyConfigured ? (piInstalled ? "ready" : "extension not installed; run: cpac pi install") : "not configured"}`);
1019
+ const kimiInstalled = isKimiConfigInstalled();
1020
+ console.log(`kimi: ${keyConfigured ? (kimiInstalled ? "ready" : "config not installed; run: cpac kimi install") : "not configured"}`);
891
1021
  if (!state)
892
1022
  return 1;
893
1023
  const proxy = stateProxy(state);
@@ -895,9 +1025,6 @@ export async function status(config) {
895
1025
  return 2;
896
1026
  return 0;
897
1027
  }
898
- function claudeBaseUrl(cpaUrl) {
899
- return cpaUrl.replace(/\/v1$/, "");
900
- }
901
1028
  async function promptSecret(name) {
902
1029
  if (!process.stdin.isTTY || !process.stderr.isTTY) {
903
1030
  throw new CPACError(`environment variable ${name} is not set; run: export ${name}="..."`);
@@ -953,7 +1080,7 @@ export function saveApiKeyExport(profile, name, apiKey) {
953
1080
  atomicWrite(profile, Buffer.from(content), mode);
954
1081
  }
955
1082
  async function guide(config) {
956
- 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)\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 --help Show command usage`);
1083
+ 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 uninstall Remove CPA integration from installed targets\n cpac upgrade Check for cpac updates on npm\n cpac --help Show command usage`);
957
1084
  if (process.env[config.api_key_env]?.trim())
958
1085
  return 0;
959
1086
  const apiKey = await promptSecret(config.api_key_env);
@@ -965,10 +1092,17 @@ async function guide(config) {
965
1092
  }
966
1093
  export async function runClaude(config, args, executable = "claude") {
967
1094
  const apiKey = await resolveApiKey(config.api_key_env);
1095
+ const proxy = await createClaudeProxy(config.cpa_url, apiKey);
1096
+ const stopProxy = () => {
1097
+ proxy.server.closeAllConnections?.();
1098
+ proxy.server.close();
1099
+ };
968
1100
  const env = {
969
1101
  ...process.env,
970
- ANTHROPIC_BASE_URL: claudeBaseUrl(config.cpa_url),
1102
+ ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxy.port}`,
971
1103
  ANTHROPIC_AUTH_TOKEN: apiKey,
1104
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
1105
+ CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1",
972
1106
  };
973
1107
  delete env.ANTHROPIC_API_KEY;
974
1108
  delete env.CLAUDE_CODE_USE_ANTHROPIC_AWS;
@@ -978,11 +1112,15 @@ export async function runClaude(config, args, executable = "claude") {
978
1112
  return await new Promise((resolve, reject) => {
979
1113
  const child = spawn(executable, args, { env, stdio: "inherit" });
980
1114
  child.once("error", (error) => {
1115
+ stopProxy();
981
1116
  reject(new CPACError(error instanceof Error && "code" in error && error.code === "ENOENT"
982
1117
  ? `${executable} not found`
983
1118
  : `cannot start ${executable}: ${error.message}`));
984
1119
  });
985
- child.once("close", (code) => resolve(code ?? 1));
1120
+ child.once("close", (code) => {
1121
+ stopProxy();
1122
+ resolve(code ?? 1);
1123
+ });
986
1124
  });
987
1125
  }
988
1126
  export async function runProxy(config) {
@@ -1033,6 +1171,7 @@ export async function installPiExtension(config) {
1033
1171
  const dir = piExtensionsDir();
1034
1172
  mkdirSync(dir, { recursive: true, mode: 0o700 });
1035
1173
  const target = join(dir, "cpac.ts");
1174
+ ensureCpacBackup(target);
1036
1175
  atomicWrite(target, Buffer.from(piExtensionContent(config.cpa_url)), 0o644);
1037
1176
  console.log(`Installed Pi extension: ${target}`);
1038
1177
  }
@@ -1059,6 +1198,314 @@ export async function runPi(config, action) {
1059
1198
  }
1060
1199
  throw new CPACError(`unknown pi action: ${action}; use install, uninstall, or status`);
1061
1200
  }
1201
+ // Pre-write snapshot kept at <target>.cpac-backup (first version only) so a
1202
+ // damaged config can be restored by hand even if uninstall cannot parse it.
1203
+ function ensureCpacBackup(target) {
1204
+ if (!existsSync(target))
1205
+ return;
1206
+ const backup = `${target}.cpac-backup`;
1207
+ if (existsSync(backup))
1208
+ return;
1209
+ copyFileSync(target, backup);
1210
+ }
1211
+ function kimiConfigPath() {
1212
+ const home = process.env.KIMI_CODE_HOME?.trim() || join(homedir(), ".kimi-code");
1213
+ return join(expandUserPath(home), "config.toml");
1214
+ }
1215
+ const KIMI_BLOCK_START = "# >>> CPAC Kimi >>>";
1216
+ const KIMI_BLOCK_END = "# <<< CPAC Kimi <<<";
1217
+ const kimiBlockRegex = new RegExp(`${KIMI_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${KIMI_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`);
1218
+ export function isKimiConfigInstalled() {
1219
+ try {
1220
+ return kimiBlockRegex.test(readFileSync(kimiConfigPath(), "utf8"));
1221
+ }
1222
+ catch {
1223
+ return false;
1224
+ }
1225
+ }
1226
+ function writeKimiBlock(path, block) {
1227
+ let content = existsSync(path) ? readFileSync(path, "utf8") : "";
1228
+ content = content.replace(kimiBlockRegex, "");
1229
+ if (block) {
1230
+ content = `${content}${content && !content.endsWith("\n") ? "\n" : ""}${block}\n`;
1231
+ }
1232
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1233
+ const mode = existsSync(path) ? statSync(path).mode & 0o7777 : 0o600;
1234
+ atomicWrite(path, Buffer.from(content), mode);
1235
+ }
1236
+ export async function installKimiConfig(config) {
1237
+ const apiKey = await resolveApiKey(config.api_key_env);
1238
+ const catalog = await fetchCatalog(config.cpa_url, apiKey);
1239
+ const document = JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes));
1240
+ const rows = catalogModelRows(document) ?? [];
1241
+ if (rows.length === 0)
1242
+ throw new CPACError("CPA catalog contains no models");
1243
+ const state = readState(config.state_dir);
1244
+ const recorded = state ? stateProxy(state) : null;
1245
+ const port = recorded?.port ?? config.codex_proxy_port;
1246
+ if (!port) {
1247
+ throw new CPACError("loopback proxy port unknown; run cpac inject first");
1248
+ }
1249
+ const lines = [
1250
+ KIMI_BLOCK_START,
1251
+ "[providers.cpac]",
1252
+ 'type = "openai"',
1253
+ `base_url = "http://127.0.0.1:${port}/v1"`,
1254
+ '# Placeholder: the CPAC loopback proxy replaces it with the CPA key; run "cpac inject" to start the proxy.',
1255
+ 'api_key = "cpac-loopback"',
1256
+ ];
1257
+ for (const row of rows) {
1258
+ const slug = catalogModelId(row);
1259
+ if (!slug)
1260
+ continue;
1261
+ // ponytail: Kimi requires max_context_size; default 200000 when the catalog omits it.
1262
+ const context = typeof row.context_window === "number" && row.context_window > 0
1263
+ ? Math.floor(row.context_window)
1264
+ : 200000;
1265
+ lines.push("", `[models."cpac/${slug}"]`, 'provider = "cpac"', `model = ${tomlString(slug)}`, `max_context_size = ${context}`);
1266
+ if (typeof row.display_name === "string" && row.display_name.trim()) {
1267
+ lines.push(`display_name = ${tomlString(row.display_name)}`);
1268
+ }
1269
+ }
1270
+ lines.push(KIMI_BLOCK_END);
1271
+ const target = kimiConfigPath();
1272
+ ensureCpacBackup(target);
1273
+ writeKimiBlock(target, lines.join("\n"));
1274
+ console.log(`Installed Kimi Code provider config: ${target}`);
1275
+ if (!recorded || !(await proxyIsHealthy(recorded))) {
1276
+ console.log("Loopback proxy is not running; run: cpac inject");
1277
+ }
1278
+ }
1279
+ export async function uninstallKimiConfig() {
1280
+ const target = kimiConfigPath();
1281
+ if (!isKimiConfigInstalled())
1282
+ throw new CPACError("Kimi Code config is not installed");
1283
+ writeKimiBlock(target, null);
1284
+ console.log(`Removed Kimi Code provider config: ${target}`);
1285
+ }
1286
+ export async function runKimi(config, action) {
1287
+ if (action === "install") {
1288
+ await installKimiConfig(config);
1289
+ return 0;
1290
+ }
1291
+ if (action === "uninstall") {
1292
+ await uninstallKimiConfig();
1293
+ return 0;
1294
+ }
1295
+ if (action === "status") {
1296
+ const installed = isKimiConfigInstalled();
1297
+ console.log(`Kimi Code config: ${installed ? "installed" : "not installed"}`);
1298
+ return installed ? 0 : 1;
1299
+ }
1300
+ throw new CPACError(`unknown kimi action: ${action}; use install, uninstall, or status`);
1301
+ }
1302
+ function readPackageVersion() {
1303
+ try {
1304
+ const raw = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8");
1305
+ const version = JSON.parse(raw).version;
1306
+ return typeof version === "string" ? version : "0.0.0-dev";
1307
+ }
1308
+ catch {
1309
+ return "0.0.0-dev";
1310
+ }
1311
+ }
1312
+ const CPAC_VERSION = readPackageVersion();
1313
+ function binaryOnPath(name) {
1314
+ if (!/^[\w.-]+$/.test(name))
1315
+ return false;
1316
+ try {
1317
+ return spawnSync(`command -v ${name}`, { stdio: "ignore", shell: true }).status === 0;
1318
+ }
1319
+ catch {
1320
+ return false;
1321
+ }
1322
+ }
1323
+ const TARGETS = [
1324
+ {
1325
+ id: "codex",
1326
+ home: () => expandUserPath(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex")),
1327
+ detected: function () {
1328
+ return existsSync(this.home());
1329
+ },
1330
+ installed: (config) => {
1331
+ try {
1332
+ return hasProviderTable(readFileSync(config.codex_config, "utf8"));
1333
+ }
1334
+ catch {
1335
+ return false;
1336
+ }
1337
+ },
1338
+ install: async (config, dryRun) => {
1339
+ if (dryRun) {
1340
+ console.log("would inject CPA catalog and loopback proxy into Codex");
1341
+ return;
1342
+ }
1343
+ await inject(config);
1344
+ },
1345
+ uninstall: async (config, dryRun) => {
1346
+ if (dryRun) {
1347
+ console.log("would restore the original Codex config");
1348
+ return;
1349
+ }
1350
+ await restore(config);
1351
+ },
1352
+ },
1353
+ {
1354
+ id: "claude",
1355
+ home: () => expandUserPath(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude")),
1356
+ detected: () => binaryOnPath("claude") || existsSync(join(homedir(), ".claude")),
1357
+ installed: () => false,
1358
+ install: async (_config, dryRun) => {
1359
+ const message = "claude needs no install; launch it through CPA with: cpac claude";
1360
+ console.log(dryRun ? `would do nothing: ${message}` : message);
1361
+ },
1362
+ uninstall: async (_config, dryRun) => {
1363
+ const message = "claude keeps no persistent CPAC state; nothing to remove";
1364
+ console.log(dryRun ? `would do nothing: ${message}` : message);
1365
+ },
1366
+ },
1367
+ {
1368
+ id: "pi",
1369
+ home: () => piExtensionsDir(),
1370
+ detected: () => binaryOnPath("pi") || existsSync(piExtensionsDir()),
1371
+ installed: () => isPiExtensionInstalled(),
1372
+ install: async (config, dryRun) => {
1373
+ if (dryRun) {
1374
+ console.log(`would write Pi extension: ${join(piExtensionsDir(), "cpac.ts")}`);
1375
+ return;
1376
+ }
1377
+ await installPiExtension(config);
1378
+ },
1379
+ uninstall: async (_config, dryRun) => {
1380
+ if (dryRun) {
1381
+ console.log(`would remove Pi extension: ${join(piExtensionsDir(), "cpac.ts")}`);
1382
+ return;
1383
+ }
1384
+ await uninstallPiExtension();
1385
+ },
1386
+ },
1387
+ {
1388
+ id: "kimi",
1389
+ home: () => dirname(kimiConfigPath()),
1390
+ detected: () => binaryOnPath("kimi") || existsSync(dirname(kimiConfigPath())),
1391
+ installed: () => isKimiConfigInstalled(),
1392
+ install: async (config, dryRun) => {
1393
+ if (dryRun) {
1394
+ console.log(`would write CPA provider block: ${kimiConfigPath()}`);
1395
+ return;
1396
+ }
1397
+ await installKimiConfig(config);
1398
+ },
1399
+ uninstall: async (_config, dryRun) => {
1400
+ if (dryRun) {
1401
+ console.log(`would remove CPA provider block: ${kimiConfigPath()}`);
1402
+ return;
1403
+ }
1404
+ await uninstallKimiConfig();
1405
+ },
1406
+ },
1407
+ ];
1408
+ export async function detectTargets(config) {
1409
+ return TARGETS.map((target) => ({
1410
+ id: target.id,
1411
+ detected: target.detected(),
1412
+ installed: target.installed(config),
1413
+ path: target.home(),
1414
+ }));
1415
+ }
1416
+ export async function runDetect(config, asJson) {
1417
+ const targets = await detectTargets(config);
1418
+ if (asJson) {
1419
+ console.log(JSON.stringify({ version: CPAC_VERSION, targets }, null, 2));
1420
+ return 0;
1421
+ }
1422
+ for (const target of targets) {
1423
+ console.log(`${target.id.padEnd(8)} ${(target.detected ? "detected" : "missing").padEnd(9)} ${(target.installed ? "installed" : "not installed").padEnd(14)} ${target.path}`);
1424
+ }
1425
+ return 0;
1426
+ }
1427
+ function selectTargets(requested, all, eligible) {
1428
+ const unknown = requested.filter((id) => !TARGETS.some((target) => target.id === id));
1429
+ if (unknown.length > 0) {
1430
+ throw new CPACError(`unknown target(s): ${unknown.join(", ")}; use ${TARGETS.map((target) => target.id).join(",")}`);
1431
+ }
1432
+ const ids = requested.length > 0 ? requested : all ? TARGETS.map((target) => target.id) : eligible;
1433
+ return TARGETS.filter((target) => ids.includes(target.id));
1434
+ }
1435
+ export async function runInstall(config, requested, options) {
1436
+ const eligible = TARGETS.filter((target) => target.detected()).map((target) => target.id);
1437
+ const selected = selectTargets(requested, options.all, eligible);
1438
+ if (selected.length === 0) {
1439
+ throw new CPACError(`no supported targets detected; use --target ${TARGETS.map((target) => target.id).join(",")} or --all`);
1440
+ }
1441
+ for (const target of selected) {
1442
+ if (target.installed(config) && !options.force) {
1443
+ console.log(`${target.id}: already installed; use --force to reinstall`);
1444
+ continue;
1445
+ }
1446
+ await target.install(config, options.dryRun);
1447
+ }
1448
+ return 0;
1449
+ }
1450
+ export async function runUninstall(config, requested, options) {
1451
+ const eligible = TARGETS.filter((target) => target.installed(config)).map((target) => target.id);
1452
+ const selected = selectTargets(requested, options.all, eligible);
1453
+ if (selected.length === 0) {
1454
+ throw new CPACError("no installed CPAC integrations found; use --target <id> or --all");
1455
+ }
1456
+ for (const target of selected) {
1457
+ if (!target.installed(config)) {
1458
+ console.log(`${target.id}: not installed`);
1459
+ continue;
1460
+ }
1461
+ await target.uninstall(config, options.dryRun);
1462
+ }
1463
+ return 0;
1464
+ }
1465
+ function compareVersions(a, b) {
1466
+ const left = a.split(".").map(Number);
1467
+ const right = b.split(".").map(Number);
1468
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
1469
+ const l = left[index] ?? 0;
1470
+ const r = right[index] ?? 0;
1471
+ if (l !== r)
1472
+ return l > r ? 1 : -1;
1473
+ }
1474
+ return 0;
1475
+ }
1476
+ export async function runUpgrade(checkOnly) {
1477
+ console.log(`Current version: ${CPAC_VERSION}`);
1478
+ let latest;
1479
+ try {
1480
+ const response = await fetch("https://registry.npmjs.org/@yhong91/cpac/latest", {
1481
+ headers: { Accept: "application/json" },
1482
+ signal: AbortSignal.timeout(10_000),
1483
+ });
1484
+ if (response.ok) {
1485
+ const data = (await response.json());
1486
+ if (typeof data.version === "string")
1487
+ latest = data.version;
1488
+ }
1489
+ }
1490
+ catch {
1491
+ latest = undefined;
1492
+ }
1493
+ if (!latest) {
1494
+ console.error("Could not fetch latest version from npm. Check your network connection.");
1495
+ return 1;
1496
+ }
1497
+ console.log(`Latest version: ${latest}`);
1498
+ if (compareVersions(CPAC_VERSION, latest) >= 0) {
1499
+ console.log("Already up to date.");
1500
+ return 0;
1501
+ }
1502
+ if (checkOnly) {
1503
+ console.log(`Update available. Run: npm install -g @yhong91/cpac@latest`);
1504
+ return 0;
1505
+ }
1506
+ console.log(`Update available. Run: npm install -g @yhong91/cpac@latest`);
1507
+ return 0;
1508
+ }
1062
1509
  function usage() {
1063
1510
  return [
1064
1511
  "Usage: cpac",
@@ -1066,13 +1513,73 @@ function usage() {
1066
1513
  " cpac proxy [--config PATH]",
1067
1514
  " cpac claude [--config PATH] [--] [claude args...]",
1068
1515
  " cpac pi <install|uninstall|status> [--config PATH]",
1516
+ " cpac kimi <install|uninstall|status> [--config PATH]",
1517
+ " cpac detect [--json] [--home PATH]",
1518
+ " cpac install [--target codex,claude,pi,kimi] [--all] [--dry-run] [--force] [--home PATH]",
1519
+ " cpac uninstall [--target codex,claude,pi,kimi] [--all] [--dry-run] [--home PATH]",
1520
+ " cpac upgrade [--check]",
1521
+ " cpac version",
1069
1522
  ].join("\n");
1070
1523
  }
1071
1524
  function parseArgs(args) {
1072
1525
  if (args.length === 0) {
1073
1526
  return { command: "guide", configPath: defaultConfigPath() };
1074
1527
  }
1075
- if (args[0] === "pi") {
1528
+ if (args[0] === "detect" ||
1529
+ args[0] === "install" ||
1530
+ args[0] === "uninstall" ||
1531
+ args[0] === "upgrade" ||
1532
+ args[0] === "version") {
1533
+ const parsed = {
1534
+ command: args[0],
1535
+ configPath: defaultConfigPath(),
1536
+ json: false,
1537
+ targets: [],
1538
+ all: false,
1539
+ dryRun: false,
1540
+ force: false,
1541
+ check: false,
1542
+ };
1543
+ for (let index = 1; index < args.length; index++) {
1544
+ const arg = args[index];
1545
+ if (arg === "--json")
1546
+ parsed.json = true;
1547
+ else if (arg === "--all")
1548
+ parsed.all = true;
1549
+ else if (arg === "--dry-run")
1550
+ parsed.dryRun = true;
1551
+ else if (arg === "--force")
1552
+ parsed.force = true;
1553
+ else if (arg === "--check")
1554
+ parsed.check = true;
1555
+ else if (arg === "--target" || arg === "--targets") {
1556
+ const value = args[++index];
1557
+ if (!value)
1558
+ throw new CPACError(`${arg} requires a comma-separated list`);
1559
+ parsed.targets.push(...value
1560
+ .split(",")
1561
+ .map((entry) => entry.trim())
1562
+ .filter(Boolean));
1563
+ }
1564
+ else if (arg === "--home") {
1565
+ const value = args[++index];
1566
+ if (!value)
1567
+ throw new CPACError("--home requires a path");
1568
+ parsed.home = resolve(expandUserPath(value));
1569
+ }
1570
+ else if (arg === "--config") {
1571
+ const value = args[++index];
1572
+ if (!value)
1573
+ throw new CPACError("--config requires a path");
1574
+ parsed.configPath = resolve(expandUserPath(value));
1575
+ }
1576
+ else {
1577
+ throw new CPACError(`unknown option: ${arg}`);
1578
+ }
1579
+ }
1580
+ return parsed;
1581
+ }
1582
+ if (args[0] === "pi" || args[0] === "kimi") {
1076
1583
  let configPath = defaultConfigPath();
1077
1584
  let index = 1;
1078
1585
  if (args[index] === "--config") {
@@ -1083,7 +1590,7 @@ function parseArgs(args) {
1083
1590
  index += 1;
1084
1591
  }
1085
1592
  const action = args[index] || "status";
1086
- return { command: "pi", configPath, action };
1593
+ return { command: args[0], configPath, action };
1087
1594
  }
1088
1595
  if (args[0] === "claude") {
1089
1596
  let configPath = defaultConfigPath();
@@ -1138,15 +1645,38 @@ export async function main(args = process.argv.slice(2)) {
1138
1645
  const parsed = parseArgs(args);
1139
1646
  if (!parsed)
1140
1647
  return 0;
1648
+ if ("home" in parsed && parsed.home)
1649
+ process.env.HOME = parsed.home;
1141
1650
  const config = loadConfig(parsed.configPath, true);
1142
1651
  if (parsed.command === "guide")
1143
1652
  return await guide(config);
1653
+ if (parsed.command === "detect")
1654
+ return await runDetect(config, parsed.json);
1655
+ if (parsed.command === "install")
1656
+ return await runInstall(config, parsed.targets, {
1657
+ all: parsed.all,
1658
+ dryRun: parsed.dryRun,
1659
+ force: parsed.force,
1660
+ });
1661
+ if (parsed.command === "uninstall")
1662
+ return await runUninstall(config, parsed.targets, {
1663
+ all: parsed.all,
1664
+ dryRun: parsed.dryRun,
1665
+ });
1666
+ if (parsed.command === "upgrade")
1667
+ return await runUpgrade(parsed.check);
1668
+ if (parsed.command === "version") {
1669
+ console.log(CPAC_VERSION);
1670
+ return 0;
1671
+ }
1144
1672
  if (parsed.command === "claude")
1145
1673
  return await runClaude(config, parsed.args);
1146
1674
  if (parsed.command === "proxy")
1147
1675
  return await runProxy(config);
1148
1676
  if (parsed.command === "pi")
1149
1677
  return await runPi(config, parsed.action);
1678
+ if (parsed.command === "kimi")
1679
+ return await runKimi(config, parsed.action);
1150
1680
  if (parsed.command === "inject")
1151
1681
  await inject(config);
1152
1682
  else if (parsed.command === "restore")
@@ -16,7 +16,7 @@ const MAX_TOKENS = {
16
16
  "claude-opus-4-6-thinking": 128000, "claude-sonnet-4-6": 64000,
17
17
  "gemini-3.6-flash": 65536, "gemini-3.6-flash-high": 65536,
18
18
  "deepseek-v4-pro": 128000, "deepseek-v4-flash": 128000,
19
- "glm-5.2": 128000, "kimi-k2.7-code": 128000, "minimax-m3": 128000,
19
+ "glm-5.3": 128000, "kimi-k2.7-code": 128000, "minimax-m3": 128000,
20
20
  "mimo-v2.5": 131072, "mimo-v2.5-pro": 131072, "grok-4.5": 128000,
21
21
  "doubao-seed-2.0-lite": 128000, "doubao-seed-2.1-turbo": 128000,
22
22
  };
@@ -40,20 +40,44 @@ function intField(value) {
40
40
 
41
41
  export default async function (pi) {
42
42
  const apiKey = (process.env.CPA_API_KEY || "").trim();
43
- if (!apiKey) return;
43
+ if (!apiKey) {
44
+ console.error("[cpac] CPA_API_KEY not set; skipping CPA providers");
45
+ return;
46
+ }
44
47
  let payload;
45
48
  try {
46
49
  const res = await fetch(CPA + "/v1/models?client_version=1", {
47
50
  headers: { Authorization: "Bearer " + apiKey },
48
51
  signal: AbortSignal.timeout(10000),
49
52
  });
50
- if (!res.ok) return;
53
+ if (!res.ok) {
54
+ console.error("[cpac] CPA models request failed: HTTP " + res.status);
55
+ return;
56
+ }
51
57
  payload = await res.json();
52
- } catch { return; }
53
- if (!payload || typeof payload !== "object" || !Array.isArray(payload.models)) return;
54
- const rows = payload.models.filter(function (m) {
55
- return m && typeof m === "object" && typeof m.slug === "string";
56
- });
58
+ } catch (error) {
59
+ console.error("[cpac] CPA models request failed: " + (error && error.message || error));
60
+ return;
61
+ }
62
+ const source = payload && Array.isArray(payload.models)
63
+ ? payload.models
64
+ : payload && Array.isArray(payload.data) ? payload.data : null;
65
+ if (!source) {
66
+ console.error("[cpac] CPA models response has no models list");
67
+ return;
68
+ }
69
+ const rows = [];
70
+ for (const m of source) {
71
+ if (!m || typeof m !== "object") continue;
72
+ const slug = typeof m.slug === "string" && m.slug.trim()
73
+ ? m.slug.trim()
74
+ : typeof m.id === "string" && m.id.trim() ? m.id.trim() : null;
75
+ if (slug) rows.push({ slug: slug, display_name: m.display_name, supported_reasoning_levels: m.supported_reasoning_levels, context_window: m.context_window });
76
+ }
77
+ if (rows.length === 0) {
78
+ console.error("[cpac] CPA models response has no usable models");
79
+ return;
80
+ }
57
81
  const groups = new Map();
58
82
  for (const m of rows) {
59
83
  const name = groupName(vendorFor(m.slug));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yhong91/cpac",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
5
5
  "type": "module",
6
6
  "bin": {