@yhong91/cpac 0.2.6 → 0.2.8

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
@@ -151,6 +151,23 @@ source ~/.zshrc # 使用引导实际显示的文件
151
151
  export CPA_API_KEY='...'
152
152
  ```
153
153
 
154
+ ### Windows
155
+
156
+ CPAC 会按平台自动选择处理方式:
157
+
158
+ - 密钥引导不写 shell profile(Windows 没有 `source` 语义)。引导会提示改用 `setx CPA_API_KEY "<your-key>"` 或「编辑账户的环境变量」持久化;也可以在 PowerShell 里直接执行:
159
+
160
+ ```powershell
161
+ [Environment]::SetEnvironmentVariable("CPA_API_KEY", "<your-key>", "User")
162
+ ```
163
+
164
+ 设置后需要重开终端。若通过 npm 全局安装,PowerShell 首次运行需要允许本地脚本:`Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`。
165
+
166
+ - 检测 PATH 上的 agent 时使用 `where` 而非 `command -v`。
167
+ - 启动 agent 时自动解析 `.cmd` / `.bat` shim(npm 全局安装的 CLI),`.exe` 直接启动。
168
+ - `cpac upgrade` / `cpac uninstall` 内部调用 npm 时自动经 shell 启动 `npm.cmd`。
169
+ - 清理残留代理进程时通过 PowerShell CIM 读取进程命令行来确认身份,不会误杀无关进程。
170
+
154
171
  ## 命令
155
172
 
156
173
  ### 引导和帮助
@@ -166,7 +183,7 @@ cpac --help
166
183
 
167
184
  ```bash
168
185
  cpac detect [--json]
169
- cpac models [--json]
186
+ cpac models [--json] [--diff]
170
187
  cpac install [codex|pi|kimi|grok|zed|hermes|codebuddy...] [--all] [--dry-run] [--force] [--v2_off] [--v2_models] [--max_context]
171
188
  cpac sync [codex|pi|kimi|grok|zed|hermes|codebuddy...] [--all] [--dry-run] [--v2_off] [--v2_models] [--max_context]
172
189
  cpac restore <codex|pi|kimi|grok|zed|hermes|codebuddy...> | --all [--dry-run]
@@ -175,7 +192,7 @@ cpac upgrade [--check]
175
192
  cpac -v | --version
176
193
  ```
177
194
 
178
- - `models` 从远端 CPA 拉取 rich catalog,默认一行一个 slug;`--json` 输出 `cpa_url` 和模型字段(`slug` / `display_name` / `context_window`)。用来核对 catalog 里有没有某个模型(例如 `*-fast`),不写本地配置。
195
+ - `models` 从远端 CPA 拉取 rich catalog,默认一行一个 slug;`--json` 输出 `cpa_url` 和模型字段(`slug` / `display_name` / `context_window`)。用来核对 catalog 里有没有某个模型(例如 `*-fast`),不写本地配置。`--diff` 拿已保存的 `state_dir/models.json` 和当前 catalog 比 slug:`+` 是新增,`-` 是去掉;没有差别打印 `unchanged`。还没有保存列表时报错,先跑 `cpac sync`。
179
196
  - `detect` 列出每个目标是否被检测到、是否已接入 CPAC;`--json` 输出机器可读结果。
180
197
  - `install` 默认作用于检测到的目标;后面直接写 agent 名(`cpac install codex pi`),`--all` 全部。`codex` 写入 catalog 并拉起 loopback 代理;`pi`/`kimi`/`grok`/`zed`/`hermes`/`codebuddy` 写入各自配置。`--dry-run` 只打印将要做的事,`--force` 允许重装已安装目标。
181
198
  - `sync` 把 CPA 模型列表和更新时间写到 `state_dir/models.json`(默认 `~/.cpac/models.json`)。这个时间戳就是模型版本:列表没变则时间戳不变,目标上次同步到的版本相同就跳过,不同才重写。不带参数只看已安装目标;`--all` 还会装上检测到但还没接入的目标。`install` 仍总是重写。`spawn_models`、`v2_off`、`max_context` 和代理端口不改这个版本,要马上生效用 `cpac install`。`--max_context` 仍只对 `cpac install codex` 有效。
package/dist/agents.js CHANGED
@@ -12,7 +12,7 @@ import { installPiConfig, isPiConfigInstalled, piAgentDir, piModelsPath, uninsta
12
12
  import { installZedConfig, isZedConfigInstalled, uninstallZedConfig, zedConfigPath, zedHome, } from "./targets/zed.js";
13
13
  import { hermesConfigPaths, hermesHome, installHermesConfig, isHermesConfigInstalled, uninstallHermesConfig, } from "./targets/hermes.js";
14
14
  import { codebuddyHome, codebuddyModelsPath, installCodebuddyConfig, isCodebuddyConfigInstalled, uninstallCodebuddyConfig, } from "./targets/codebuddy.js";
15
- import { CPACError, atomicWrite, checkboxPicker, compareVersions, expandUserPath, objectValue, resolveApiKey, tabWizard, } from "./util.js";
15
+ import { CPACError, atomicWrite, checkboxPicker, compareVersions, expandUserPath, isWindows, objectValue, resolveApiKey, spawnAgent, spawnAgentSync, tabWizard, } from "./util.js";
16
16
  const CODEX_CTX_STD = "Standard (default input budget, e.g. 200K~272K)";
17
17
  const CODEX_CTX_MAX = "Max Context Window (lift to full upper bound, e.g. 921K~1M, 90% auto-compact limit)";
18
18
  const CODEX_V2_ON = "Enabled (Default)";
@@ -139,7 +139,7 @@ export async function runTargetLauncher(config, targetId, args, executable = tar
139
139
  const env = targetId === "zed"
140
140
  ? { ...process.env, CPAC_API_KEY: process.env.CPAC_API_KEY || "cpac" }
141
141
  : undefined;
142
- const child = spawn(executable, args, { stdio: "inherit", env });
142
+ const child = spawnAgent(executable, args, { stdio: "inherit", env });
143
143
  child.once("error", (error) => {
144
144
  rejectPromise(new CPACError(error instanceof Error && "code" in error && error.code === "ENOENT"
145
145
  ? `${executable} not found`
@@ -163,7 +163,7 @@ export function detectClientVersion(binary) {
163
163
  if (!/^[\w.-]+$/.test(binary))
164
164
  return undefined;
165
165
  try {
166
- const res = spawnSync(binary, ["--version"], {
166
+ const res = spawnAgentSync(binary, ["--version"], {
167
167
  encoding: "utf8",
168
168
  timeout: 2000,
169
169
  stdio: ["ignore", "pipe", "ignore"],
@@ -182,6 +182,14 @@ function binaryOnPath(name) {
182
182
  if (!/^[\w.-]+$/.test(name))
183
183
  return false;
184
184
  try {
185
+ if (isWindows) {
186
+ // `command -v` is a POSIX shell builtin; cmd.exe only has `where`.
187
+ return (spawnSync("where", [name], {
188
+ stdio: "ignore",
189
+ timeout: 3_000,
190
+ windowsHide: true,
191
+ }).status === 0);
192
+ }
185
193
  return (spawnSync(`command -v ${name}`, { stdio: "ignore", shell: true }).status ===
186
194
  0);
187
195
  }
@@ -512,7 +520,7 @@ export async function runUninstall(config, options) {
512
520
  return 0;
513
521
  }
514
522
  console.log("Uninstalling @yhong91/cpac");
515
- const result = spawnSync("npm", ["uninstall", "-g", "@yhong91/cpac"], {
523
+ const result = spawnAgentSync("npm", ["uninstall", "-g", "@yhong91/cpac"], {
516
524
  stdio: "inherit",
517
525
  });
518
526
  if (result.status !== 0) {
@@ -573,7 +581,7 @@ export async function runUpgrade(config, checkOnly) {
573
581
  const args = ["install", "-g", `@yhong91/cpac@${latest}`];
574
582
  if (prefix)
575
583
  args.splice(2, 0, `--prefix=${prefix}`);
576
- const result = spawnSync("npm", args, { stdio: "inherit" });
584
+ const result = spawnAgentSync("npm", args, { stdio: "inherit" });
577
585
  if (result.status !== 0) {
578
586
  console.error("npm install failed");
579
587
  return result.status ?? 1;
package/dist/cpac.js CHANGED
@@ -13,6 +13,7 @@ import { isZedConfigInstalled } from "./targets/zed.js";
13
13
  import { isHermesConfigInstalled } from "./targets/hermes.js";
14
14
  import { isCodebuddyConfigInstalled } from "./targets/codebuddy.js";
15
15
  import { defaultConfigPath, loadConfig, originalBytes, pickSpawnModels, readState, saveSpawnModels, stateProxy, catalogMaxContextLive, catalogModelId, catalogModelRows, catalogSlugs, fetchCatalog, } from "./config.js";
16
+ import { canonicalModels, diffModelSlugs, readModelIndex } from "./models.js";
16
17
  import { runPing } from "./ping.js";
17
18
  import { proxyIsHealthy, runProxyChild } from "./proxy.js";
18
19
  import { CPACError, expandUserPath, promptSecret, saveApiKeyExport, shellProfile, } from "./util.js";
@@ -93,11 +94,30 @@ export async function status(config) {
93
94
  return 2;
94
95
  return 0;
95
96
  }
96
- async function listModels(config, json) {
97
+ async function listModels(config, json, diff = false) {
97
98
  const apiKey = process.env[config.api_key_env]?.trim();
98
99
  if (!apiKey)
99
100
  throw new CPACError(`environment variable ${config.api_key_env} is not set`);
100
101
  const catalog = await fetchCatalog(config.cpa_url, apiKey);
102
+ if (diff) {
103
+ const index = readModelIndex(config.state_dir);
104
+ if (!index)
105
+ throw new CPACError("no saved model list; run cpac sync");
106
+ const { added, removed } = diffModelSlugs(index.models.map((model) => model.slug), canonicalModels(catalog.bytes).map((model) => model.slug));
107
+ if (json) {
108
+ console.log(JSON.stringify({ added, removed }, null, 2));
109
+ return 0;
110
+ }
111
+ if (added.length === 0 && removed.length === 0) {
112
+ console.log("unchanged");
113
+ return 0;
114
+ }
115
+ for (const slug of added)
116
+ console.log(`+ ${slug}`);
117
+ for (const slug of removed)
118
+ console.log(`- ${slug}`);
119
+ return 0;
120
+ }
101
121
  if (!json) {
102
122
  for (const slug of catalogSlugs(catalog.bytes))
103
123
  console.log(slug);
@@ -138,9 +158,15 @@ async function guide(config) {
138
158
  if (process.env[config.api_key_env]?.trim())
139
159
  return 0;
140
160
  const apiKey = await promptSecret(config.api_key_env);
161
+ process.env[config.api_key_env] = apiKey;
162
+ if (process.platform === "win32") {
163
+ // Windows has no shell profile to source; persist via setx instead of
164
+ // writing an export line no native shell would read.
165
+ console.log(`\nTo persist ${config.api_key_env} across terminals, run in a new terminal:\n setx ${config.api_key_env} "<your-key>"\n(or use Start > "Edit environment variables for your account").\nThe key is active for this session only until then.`);
166
+ return 0;
167
+ }
141
168
  const profile = shellProfile();
142
169
  saveApiKeyExport(profile, config.api_key_env, apiKey);
143
- process.env[config.api_key_env] = apiKey;
144
170
  console.log(`\nSaved ${config.api_key_env} to ${profile}. Open a new terminal or run:\n source ${profile}`);
145
171
  return 0;
146
172
  }
@@ -183,6 +209,7 @@ function usage() {
183
209
  "",
184
210
  "Command options:",
185
211
  opt("--json", "JSON output", "[models, detect]"),
212
+ opt("--diff", "models added or removed since the saved list", "[models]"),
186
213
  opt("--all", "every detected or installed agent", "[install, sync, restore]"),
187
214
  opt("--dry-run", "print actions without writing", "[install, sync, restore, uninstall]"),
188
215
  opt("--force", "allow reinstall of already-injected agents", "[install]"),
@@ -288,6 +315,11 @@ function parseArgs(args) {
288
315
  }
289
316
  if (arg === "--json")
290
317
  parsed.json = true;
318
+ else if (arg === "--diff") {
319
+ if (parsed.command !== "models")
320
+ throw new CPACError("--diff is only valid with models");
321
+ parsed.diff = true;
322
+ }
291
323
  else if (arg === "--all")
292
324
  parsed.all = true;
293
325
  else if (arg === "--dry-run")
@@ -648,13 +680,16 @@ export async function main(args = process.argv.slice(2)) {
648
680
  const parsed = parseArgs(args);
649
681
  if (!parsed)
650
682
  return 0;
651
- if ("home" in parsed && parsed.home)
683
+ if ("home" in parsed && parsed.home) {
652
684
  process.env.HOME = parsed.home;
685
+ if (process.platform === "win32")
686
+ process.env.USERPROFILE = parsed.home;
687
+ }
653
688
  const config = loadConfig(parsed.configPath, true);
654
689
  if (parsed.command === "guide")
655
690
  return await guide(config);
656
691
  if (parsed.command === "models")
657
- return await listModels(config, parsed.json);
692
+ return await listModels(config, parsed.json, parsed.diff === true);
658
693
  if (parsed.command === "detect")
659
694
  return await runDetect(config, parsed.json);
660
695
  if (parsed.command === "install") {
package/dist/models.js CHANGED
@@ -33,6 +33,14 @@ function stableValue(value) {
33
33
  stable[key] = stableValue(value[key]);
34
34
  return stable;
35
35
  }
36
+ export function diffModelSlugs(saved, live) {
37
+ const savedSet = new Set(saved);
38
+ const liveSet = new Set(live);
39
+ return {
40
+ added: live.filter((slug) => !savedSet.has(slug)),
41
+ removed: saved.filter((slug) => !liveSet.has(slug)),
42
+ };
43
+ }
36
44
  export function canonicalModels(bytes) {
37
45
  let document;
38
46
  try {
package/dist/proxy.js CHANGED
@@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { createServer, request as httpRequest, } from "node:http";
5
5
  import { request as httpsRequest } from "node:https";
6
+ import { join } from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { apiBase, proxyFingerprint, readState, saveState, stateProxy, } from "./config.js";
8
9
  import { CPACError, objectValue, resolveApiKey } from "./util.js";
@@ -246,6 +247,30 @@ export async function startProxyProcess(config, apiKey) {
246
247
  function processCommandArgs(pid) {
247
248
  if (!Number.isInteger(pid) || pid <= 0)
248
249
  return undefined;
250
+ if (process.platform === "win32") {
251
+ // No /proc and no ps on Windows; PowerShell CIM is the dependable way
252
+ // to read another process's command line (wmic is removed on newer
253
+ // Windows 11 builds). Only used to identify stale proxy processes.
254
+ const systemRoot = process.env.SystemRoot?.trim() || "C:\\Windows";
255
+ try {
256
+ const result = spawnSync(join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), [
257
+ "-NoProfile",
258
+ "-NonInteractive",
259
+ "-Command",
260
+ `(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine`,
261
+ ], {
262
+ encoding: "utf8",
263
+ timeout: 5_000,
264
+ stdio: ["ignore", "pipe", "ignore"],
265
+ windowsHide: true,
266
+ });
267
+ const line = result.stdout?.trim();
268
+ return line ? line.split(/\s+/) : undefined;
269
+ }
270
+ catch {
271
+ return undefined;
272
+ }
273
+ }
249
274
  try {
250
275
  const proc = `/proc/${pid}/cmdline`;
251
276
  if (existsSync(proc)) {
@@ -4,7 +4,7 @@ import { createServer, request as httpRequest, } from "node:http";
4
4
  import { request as httpsRequest } from "node:https";
5
5
  import { apiBase, catalogModelId, catalogModelRows, catalogSlugs, fetchCatalog, loadConfig, } from "../config.js";
6
6
  import { proxyHeaders, responseHeaders, upstreamUrl } from "../proxy.js";
7
- import { CPACError, atomicWrite, objectValue, resolveApiKey, tabWizard, } from "../util.js";
7
+ import { CPACError, atomicWrite, objectValue, resolveApiKey, spawnAgent, tabWizard, } from "../util.js";
8
8
  const CLAUDE_ALIAS_PREFIX = "claude-cpac--";
9
9
  // Claude Code accepts CLAUDE_CODE_AUTO_COMPACT_WINDOW in 100K–1M (binary-verified).
10
10
  // A single global window cannot be per-model; 350K is opencodex's user-approved
@@ -379,7 +379,7 @@ export async function runClaude(config, args, executable = "claude") {
379
379
  delete env.CLAUDE_CODE_USE_FOUNDRY;
380
380
  delete env.CLAUDE_CODE_USE_VERTEX;
381
381
  return await new Promise((resolve, reject) => {
382
- const child = spawn(executable, args, { env, stdio: "inherit" });
382
+ const child = spawnAgent(executable, args, { env, stdio: "inherit" });
383
383
  child.once("error", (error) => {
384
384
  stopProxy();
385
385
  reject(new CPACError(error instanceof Error && "code" in error && error.code === "ENOENT"
package/dist/util.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawn, spawnSync, } from "node:child_process";
1
2
  import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { basename, dirname, join, parse } from "node:path";
@@ -9,6 +10,79 @@ export class CPACError extends Error {
9
10
  export function objectValue(value) {
10
11
  return typeof value === "object" && value !== null && !Array.isArray(value);
11
12
  }
13
+ export const isWindows = process.platform === "win32";
14
+ // Windows process launching: npm-installed CLIs are .cmd/.bat shims, and
15
+ // spawn without a shell only resolves .exe (CreateProcess), so those need a
16
+ // shell plus a correctly quoted command line. Everything else spawns directly.
17
+ function resolveWindowsExecutable(executable) {
18
+ if (!/^[\w.-]+$/.test(executable))
19
+ return undefined;
20
+ try {
21
+ const result = spawnSync("where", [executable], {
22
+ encoding: "utf8",
23
+ timeout: 3_000,
24
+ stdio: ["ignore", "pipe", "ignore"],
25
+ windowsHide: true,
26
+ });
27
+ if (result.status !== 0)
28
+ return undefined;
29
+ const lines = result.stdout
30
+ .split(/\r?\n/)
31
+ .map((entry) => entry.trim())
32
+ .filter(Boolean);
33
+ return (lines.find((entry) => /\.(exe|com|cmd|bat)$/i.test(entry)) || lines[0]);
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ // Quotes a single argument for cmd.exe (MSDN rules): double backslashes that
40
+ // precede a quote, double trailing backslashes so they cannot escape the
41
+ // closing quote, and wrap tokens containing whitespace or quotes.
42
+ function cmdQuote(arg) {
43
+ if (arg !== "" && !/[\s"]/.test(arg))
44
+ return arg;
45
+ let out = "";
46
+ let backslashes = 0;
47
+ for (const ch of arg) {
48
+ if (ch === "\\") {
49
+ backslashes++;
50
+ continue;
51
+ }
52
+ if (ch === '"')
53
+ out += `${"\\".repeat(backslashes * 2 + 1)}"`;
54
+ else
55
+ out += `${"\\".repeat(backslashes)}${ch}`;
56
+ backslashes = 0;
57
+ }
58
+ return `"${out}${"\\".repeat(backslashes * 2)}"`;
59
+ }
60
+ function spawnAgentPlan(executable, args) {
61
+ if (!isWindows)
62
+ return { command: executable, args };
63
+ const resolved = resolveWindowsExecutable(executable);
64
+ if (!resolved)
65
+ return null; // let the plain spawn produce the ENOENT error
66
+ if (!/\.(cmd|bat)$/i.test(resolved))
67
+ return { command: resolved, args };
68
+ return { line: [resolved, ...args].map(cmdQuote).join(" ") };
69
+ }
70
+ export function spawnAgent(executable, args, options) {
71
+ const plan = spawnAgentPlan(executable, args);
72
+ if (plan === null)
73
+ return spawn(executable, args, options);
74
+ if ("line" in plan)
75
+ return spawn(plan.line, { ...options, shell: true });
76
+ return spawn(plan.command, plan.args, options);
77
+ }
78
+ export function spawnAgentSync(executable, args, options) {
79
+ const plan = spawnAgentPlan(executable, args);
80
+ if (plan === null)
81
+ return spawnSync(executable, args, options);
82
+ if ("line" in plan)
83
+ return spawnSync(plan.line, { ...options, shell: true });
84
+ return spawnSync(plan.command, plan.args, options);
85
+ }
12
86
  export function ensureCpacBackup(stateDir, agent, target, native = false) {
13
87
  const dest = join(stateDir, agent, basename(target));
14
88
  const sidecar = `${target}.cpac-backup`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yhong91/cpac",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
5
5
  "type": "module",
6
6
  "bin": {