@yhong91/cpac 0.2.7 → 0.2.9

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
  ### 引导和帮助
package/dist/agents.js CHANGED
@@ -3,8 +3,9 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { MAX_SPAWN_MODELS, fetchCatalog, loadCatalogSlugs, pickSpawnModels, saveCodexSetup, saveSpawnModels, } from "./config.js";
7
- import { markModelsApplied, refreshModelIndex } from "./models.js";
6
+ import { MAX_SPAWN_MODELS, fetchCatalog, loadCatalogSlugs, pickSpawnModels, readState, saveCodexSetup, saveSpawnModels, saveState, stateProxy, } from "./config.js";
7
+ import { ensureLoopbackProxy } from "./proxy.js";
8
+ import { diffModelSlugs, markModelsApplied, readModelIndex, refreshModelIndex, } from "./models.js";
8
9
  import { MANAGED_MARKER, inject, restore } from "./targets/codex.js";
9
10
  import { grokConfigPath, grokHome, installGrokConfig, isGrokConfigInstalled, uninstallGrokConfig, } from "./targets/grok.js";
10
11
  import { installKimiConfig, isKimiConfigInstalled, kimiConfigPath, uninstallKimiConfig, } from "./targets/kimi.js";
@@ -12,7 +13,7 @@ import { installPiConfig, isPiConfigInstalled, piAgentDir, piModelsPath, uninsta
12
13
  import { installZedConfig, isZedConfigInstalled, uninstallZedConfig, zedConfigPath, zedHome, } from "./targets/zed.js";
13
14
  import { hermesConfigPaths, hermesHome, installHermesConfig, isHermesConfigInstalled, uninstallHermesConfig, } from "./targets/hermes.js";
14
15
  import { codebuddyHome, codebuddyModelsPath, installCodebuddyConfig, isCodebuddyConfigInstalled, uninstallCodebuddyConfig, } from "./targets/codebuddy.js";
15
- import { CPACError, atomicWrite, checkboxPicker, compareVersions, expandUserPath, objectValue, resolveApiKey, tabWizard, } from "./util.js";
16
+ import { CPACError, atomicWrite, checkboxPicker, compareVersions, expandUserPath, isWindows, objectValue, resolveApiKey, spawnAgent, spawnAgentSync, tabWizard, } from "./util.js";
16
17
  const CODEX_CTX_STD = "Standard (default input budget, e.g. 200K~272K)";
17
18
  const CODEX_CTX_MAX = "Max Context Window (lift to full upper bound, e.g. 921K~1M, 90% auto-compact limit)";
18
19
  const CODEX_V2_ON = "Enabled (Default)";
@@ -135,11 +136,12 @@ export async function runTargetLauncher(config, targetId, args, executable = tar
135
136
  await target.install(config, false, options.v2Off, options.maxContext);
136
137
  }
137
138
  }
139
+ await ensureLauncherProxy(config, target, options);
138
140
  return await new Promise((resolvePromise, rejectPromise) => {
139
141
  const env = targetId === "zed"
140
142
  ? { ...process.env, CPAC_API_KEY: process.env.CPAC_API_KEY || "cpac" }
141
143
  : undefined;
142
- const child = spawn(executable, args, { stdio: "inherit", env });
144
+ const child = spawnAgent(executable, args, { stdio: "inherit", env });
143
145
  child.once("error", (error) => {
144
146
  rejectPromise(new CPACError(error instanceof Error && "code" in error && error.code === "ENOENT"
145
147
  ? `${executable} not found`
@@ -163,7 +165,7 @@ export function detectClientVersion(binary) {
163
165
  if (!/^[\w.-]+$/.test(binary))
164
166
  return undefined;
165
167
  try {
166
- const res = spawnSync(binary, ["--version"], {
168
+ const res = spawnAgentSync(binary, ["--version"], {
167
169
  encoding: "utf8",
168
170
  timeout: 2000,
169
171
  stdio: ["ignore", "pipe", "ignore"],
@@ -178,10 +180,40 @@ export function detectClientVersion(binary) {
178
180
  }
179
181
  return undefined;
180
182
  }
183
+ async function ensureLauncherProxy(config, target, options) {
184
+ const apiKey = await resolveApiKey(config.api_key_env);
185
+ const before = readState(config.state_dir);
186
+ const previous = before ? stateProxy(before) : null;
187
+ if (!previous) {
188
+ await target.install(config, false, options.v2Off, options.maxContext);
189
+ return;
190
+ }
191
+ const { proxy, fingerprint, started } = await ensureLoopbackProxy(config, apiKey);
192
+ if (!started)
193
+ return;
194
+ saveState(config.state_dir, {
195
+ ...before,
196
+ proxy_id: proxy.id,
197
+ proxy_pid: proxy.pid,
198
+ proxy_port: proxy.port,
199
+ proxy_fingerprint: fingerprint,
200
+ });
201
+ if (previous.port === proxy.port)
202
+ return;
203
+ await target.install(config, false, options.v2Off, options.maxContext);
204
+ }
181
205
  function binaryOnPath(name) {
182
206
  if (!/^[\w.-]+$/.test(name))
183
207
  return false;
184
208
  try {
209
+ if (isWindows) {
210
+ // `command -v` is a POSIX shell builtin; cmd.exe only has `where`.
211
+ return (spawnSync("where", [name], {
212
+ stdio: "ignore",
213
+ timeout: 3_000,
214
+ windowsHide: true,
215
+ }).status === 0);
216
+ }
185
217
  return (spawnSync(`command -v ${name}`, { stdio: "ignore", shell: true }).status ===
186
218
  0);
187
219
  }
@@ -419,17 +451,21 @@ function syncTargetIds(config, requested, all) {
419
451
  }
420
452
  return selected.map((target) => target.id);
421
453
  }
422
- function formatModelVersion(iso) {
423
- const date = new Date(iso);
424
- if (Number.isNaN(date.getTime()))
425
- return iso;
426
- const pad = (value) => String(value).padStart(2, "0");
427
- const clock = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
428
- // ponytail: minute display; the stored ISO still separates same-minute updates
429
- const monthDay = `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
430
- return date.getFullYear() === new Date().getFullYear()
431
- ? `${monthDay} ${clock}`
432
- : `${date.getFullYear()}-${monthDay} ${clock}`;
454
+ function logModelChanges(previous, next) {
455
+ const { added, removed } = diffModelSlugs(previous.map((model) => model.slug), next.map((model) => model.slug));
456
+ const addedSet = new Set(added);
457
+ const previousJson = new Map(previous.map((model) => [model.slug, JSON.stringify(model)]));
458
+ for (const slug of added)
459
+ console.log(`+ ${slug}`);
460
+ for (const slug of removed)
461
+ console.log(`- ${slug}`);
462
+ for (const model of next) {
463
+ if (addedSet.has(model.slug))
464
+ continue;
465
+ const before = previousJson.get(model.slug);
466
+ if (before !== undefined && before !== JSON.stringify(model))
467
+ console.log(`~ ${model.slug}`);
468
+ }
433
469
  }
434
470
  export async function runSync(config, requested, options) {
435
471
  const ids = syncTargetIds(config, requested, options.all);
@@ -438,16 +474,11 @@ export async function runSync(config, requested, options) {
438
474
  }
439
475
  const apiKey = await resolveApiKey(config.api_key_env);
440
476
  const catalog = await fetchCatalog(config.cpa_url, apiKey);
477
+ const previous = readModelIndex(config.state_dir);
441
478
  const { index, changed } = refreshModelIndex(config.state_dir, catalog.bytes, new Date().toISOString(), !options.dryRun);
442
- console.log(`models ${formatModelVersion(index.updated_at)}${changed ? (options.dryRun ? " would update" : " updated") : " unchanged"}`);
479
+ if (changed)
480
+ logModelChanges(previous?.models ?? [], index.models);
443
481
  const pending = ids.filter((id) => index.applied[id] !== index.updated_at);
444
- for (const id of ids) {
445
- const applied = index.applied[id];
446
- if (applied === index.updated_at)
447
- console.log(` ${id}: unchanged`);
448
- else if (applied)
449
- console.log(` ${id}: ${formatModelVersion(applied)}`);
450
- }
451
482
  if (pending.length === 0)
452
483
  return 0;
453
484
  // ponytail: version is the model-list timestamp only. spawn_models, v2_off,
@@ -512,7 +543,7 @@ export async function runUninstall(config, options) {
512
543
  return 0;
513
544
  }
514
545
  console.log("Uninstalling @yhong91/cpac");
515
- const result = spawnSync("npm", ["uninstall", "-g", "@yhong91/cpac"], {
546
+ const result = spawnAgentSync("npm", ["uninstall", "-g", "@yhong91/cpac"], {
516
547
  stdio: "inherit",
517
548
  });
518
549
  if (result.status !== 0) {
@@ -573,7 +604,7 @@ export async function runUpgrade(config, checkOnly) {
573
604
  const args = ["install", "-g", `@yhong91/cpac@${latest}`];
574
605
  if (prefix)
575
606
  args.splice(2, 0, `--prefix=${prefix}`);
576
- const result = spawnSync("npm", args, { stdio: "inherit" });
607
+ const result = spawnAgentSync("npm", args, { stdio: "inherit" });
577
608
  if (result.status !== 0) {
578
609
  console.error("npm install failed");
579
610
  return result.status ?? 1;
package/dist/cpac.js CHANGED
@@ -158,9 +158,15 @@ async function guide(config) {
158
158
  if (process.env[config.api_key_env]?.trim())
159
159
  return 0;
160
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
+ }
161
168
  const profile = shellProfile();
162
169
  saveApiKeyExport(profile, config.api_key_env, apiKey);
163
- process.env[config.api_key_env] = apiKey;
164
170
  console.log(`\nSaved ${config.api_key_env} to ${profile}. Open a new terminal or run:\n source ${profile}`);
165
171
  return 0;
166
172
  }
@@ -674,8 +680,11 @@ export async function main(args = process.argv.slice(2)) {
674
680
  const parsed = parseArgs(args);
675
681
  if (!parsed)
676
682
  return 0;
677
- if ("home" in parsed && parsed.home)
683
+ if ("home" in parsed && parsed.home) {
678
684
  process.env.HOME = parsed.home;
685
+ if (process.platform === "win32")
686
+ process.env.USERPROFILE = parsed.home;
687
+ }
679
688
  const config = loadConfig(parsed.configPath, true);
680
689
  if (parsed.command === "guide")
681
690
  return await guide(config);
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)) {
@@ -321,6 +346,9 @@ export async function ensureLoopbackProxy(config, apiKey) {
321
346
  if (existing)
322
347
  await stopProxyProcess(existing);
323
348
  const proxy = await startProxyProcess(config, apiKey);
349
+ if (!existing) {
350
+ console.log(`loopback proxy listening on http://127.0.0.1:${proxy.port}/v1`);
351
+ }
324
352
  return { proxy, fingerprint, started: true };
325
353
  }
326
354
  export async function runProxy(config) {
@@ -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.7",
3
+ "version": "0.2.9",
4
4
  "description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
5
5
  "type": "module",
6
6
  "bin": {