@yhong91/cpac 0.1.25 → 0.1.26

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/dist/cpac.js CHANGED
@@ -1,1396 +1,20 @@
1
1
  #!/usr/bin/env node
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
- import { createHash, randomBytes } from "node:crypto";
5
- import { createServer, request as httpRequest, } from "node:http";
6
- import { request as httpsRequest } from "node:https";
7
- import { homedir, tmpdir } from "node:os";
8
- import { dirname, isAbsolute, join, parse, relative, resolve } from "node:path";
9
- import { fileURLToPath, pathToFileURL } from "node:url";
10
- import { createInterface } from "node:readline/promises";
11
- import { Writable } from "node:stream";
12
- export const PROVIDER = "cpac_cpa";
13
- const DEFAULT_CPA_URL = "http://124.223.178.52:8317";
14
- const DEFAULT_CODEX_PROXY_PORT = 10101;
15
- const MAX_CATALOG_BYTES = 16 * 1024 * 1024;
16
- const CONFIG_KEYS = new Set([
17
- "cpa_url",
18
- "api_key_env",
19
- "codex_config",
20
- "codex_proxy_port",
21
- "state_dir",
22
- "claude_models",
23
- "spawn_models",
24
- ]);
25
- // Codex advertises only the first 5 picker-visible catalog models as
26
- // spawn_agent overrides (codex-rs MAX_SPAWN_AGENT_MODEL_OVERRIDES).
27
- const MAX_SPAWN_MODELS = 5;
28
- const CLAUDE_SLOT_KEYS = ["opus", "sonnet", "haiku"];
29
- const REQUIRED_STATE_KEYS = new Set([
30
- "config_path",
31
- "config_existed",
32
- "config_mode",
33
- ]);
34
- const STATE_KEYS = new Set([
35
- ...REQUIRED_STATE_KEYS,
36
- "proxy_id",
37
- "proxy_fingerprint",
38
- "proxy_pid",
39
- "proxy_port",
40
- ]);
41
- const STATE_FILES = ["state.json", "config.toml.backup", "codex-models.json"];
42
- const MODEL_PROVIDER_KEY = /^\s*(?:model_provider|"model_provider"|'model_provider')\s*=/;
43
- const MODEL_CATALOG_KEY = /^\s*(?:model_catalog_json|"model_catalog_json"|'model_catalog_json')\s*=/;
44
- const OPENAI_BASE_URL_KEY = /^\s*(?:openai_base_url|"openai_base_url"|'openai_base_url')\s*=/;
45
- const ROOT_KEY = new RegExp(`(?:${MODEL_PROVIDER_KEY.source}|${MODEL_CATALOG_KEY.source}|${OPENAI_BASE_URL_KEY.source})`);
46
- const MANAGED_MARKER = "# CPAC managed; run CPAC 'restore' to restore the original file.";
47
- let atomicSequence = 0;
48
- export class CPACError extends Error {
49
- }
50
- function objectValue(value) {
51
- return typeof value === "object" && value !== null && !Array.isArray(value);
52
- }
53
- function expandUserPath(value) {
54
- if (value === "~")
55
- return homedir();
56
- if (value.startsWith("~/") || value.startsWith("~\\"))
57
- return join(homedir(), value.slice(2));
58
- return value;
59
- }
60
- export function defaultConfigPath() {
61
- return resolve(expandUserPath(process.env.CPAC_CONFIG || "~/.config/cpac/config.json"));
62
- }
63
- function normalizeCpaUrl(value) {
64
- if (typeof value !== "string" || !value.trim()) {
65
- throw new CPACError("cpa_url must be a non-empty string");
66
- }
67
- let parsed;
68
- try {
69
- parsed = new URL(value);
70
- }
71
- catch {
72
- throw new CPACError("cpa_url must be an absolute http(s) URL");
73
- }
74
- if (!["http:", "https:"].includes(parsed.protocol) ||
75
- !parsed.hostname ||
76
- parsed.username ||
77
- parsed.password ||
78
- parsed.search ||
79
- parsed.hash) {
80
- throw new CPACError("cpa_url must be an absolute http(s) URL without credentials, query, or fragment");
81
- }
82
- return value.trim().replace(/\/+$/, "");
83
- }
84
- export function loadConfig(path, useDefaultsIfMissing = false) {
85
- let value;
86
- try {
87
- value = JSON.parse(readFileSync(path, "utf8"));
88
- }
89
- catch (error) {
90
- if (error.code === "ENOENT" &&
91
- useDefaultsIfMissing) {
92
- value = {};
93
- }
94
- else if (error.code === "ENOENT") {
95
- throw new CPACError(`config not found: ${path}`);
96
- }
97
- else {
98
- throw new CPACError(`cannot read config: ${error instanceof Error ? error.message : String(error)}`);
99
- }
100
- }
101
- if (!objectValue(value))
102
- throw new CPACError("config must be a JSON object");
103
- const unknown = Object.keys(value)
104
- .filter((key) => !CONFIG_KEYS.has(key))
105
- .sort();
106
- if (unknown.length)
107
- throw new CPACError(`unknown config keys: ${unknown.join(", ")}`);
108
- const cpaUrl = normalizeCpaUrl(value.cpa_url ?? process.env.CPA_BASE_URL?.trim() ?? DEFAULT_CPA_URL);
109
- const apiKeyEnv = value.api_key_env ?? "CPA_API_KEY";
110
- if (typeof apiKeyEnv !== "string" ||
111
- !/^[A-Za-z_][A-Za-z0-9_]*$/.test(apiKeyEnv)) {
112
- throw new CPACError("api_key_env must be an environment variable name");
113
- }
114
- if (value.codex_config !== undefined &&
115
- (typeof value.codex_config !== "string" || !value.codex_config.trim())) {
116
- throw new CPACError("codex_config must be a non-empty string");
117
- }
118
- if (value.state_dir !== undefined &&
119
- (typeof value.state_dir !== "string" || !value.state_dir.trim())) {
120
- throw new CPACError("state_dir must be a non-empty string");
121
- }
122
- const codexProxyPort = value.codex_proxy_port ?? DEFAULT_CODEX_PROXY_PORT;
123
- if (!Number.isInteger(codexProxyPort) ||
124
- codexProxyPort < 0 ||
125
- codexProxyPort > 65535) {
126
- throw new CPACError("codex_proxy_port must be an integer from 0 to 65535");
127
- }
128
- const claudeModelsRaw = value.claude_models;
129
- let claudeModels;
130
- if (claudeModelsRaw !== undefined) {
131
- if (!objectValue(claudeModelsRaw))
132
- throw new CPACError("claude_models must be an object");
133
- const unknownSlots = Object.keys(claudeModelsRaw)
134
- // "classifier" was a shipped slot; its env var turned out unread by
135
- // Claude Code, so the key is dropped on load instead of erroring.
136
- .filter((key) => key !== "classifier" && !CLAUDE_SLOT_KEYS.includes(key))
137
- .sort();
138
- if (unknownSlots.length)
139
- throw new CPACError(`unknown claude_models slots: ${unknownSlots.join(", ")}`);
140
- claudeModels = {};
141
- for (const slot of CLAUDE_SLOT_KEYS) {
142
- const model = claudeModelsRaw[slot];
143
- if (model === undefined)
144
- continue;
145
- if (typeof model !== "string" || !model.trim())
146
- throw new CPACError(`claude_models.${slot} must be a non-empty string`);
147
- claudeModels[slot] = model.trim();
148
- }
149
- }
150
- const spawnModelsRaw = value.spawn_models;
151
- let spawnModels;
152
- if (spawnModelsRaw !== undefined) {
153
- if (!Array.isArray(spawnModelsRaw))
154
- throw new CPACError("spawn_models must be an array of model slugs");
155
- if (spawnModelsRaw.length > MAX_SPAWN_MODELS)
156
- throw new CPACError(`spawn_models accepts at most ${MAX_SPAWN_MODELS} models`);
157
- spawnModels = spawnModelsRaw.map((model, index) => {
158
- if (typeof model !== "string" || !model.trim())
159
- throw new CPACError(`spawn_models[${index}] must be a non-empty string`);
160
- return model.trim();
161
- });
162
- }
163
- const codexHome = expandUserPath(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
164
- const codexConfig = resolve(expandUserPath(value.codex_config || join(codexHome, "config.toml")));
165
- const stateDir = resolve(expandUserPath(value.state_dir || join(homedir(), ".cpac")));
166
- const stateToConfig = relative(stateDir, codexConfig);
167
- if ([parse(stateDir).root, resolve(homedir()), resolve(tmpdir())].includes(stateDir) ||
168
- stateToConfig === "" ||
169
- (!stateToConfig.startsWith("..") && !isAbsolute(stateToConfig))) {
170
- throw new CPACError("state_dir must not be a filesystem root, home/temp directory, or contain codex_config");
171
- }
172
- return {
173
- cpa_url: cpaUrl,
174
- api_key_env: apiKeyEnv,
175
- codex_config: codexConfig,
176
- codex_proxy_port: codexProxyPort,
177
- state_dir: stateDir,
178
- claude_models: claudeModels,
179
- spawn_models: spawnModels,
180
- };
181
- }
182
- export function apiBase(cpaUrl) {
183
- try {
184
- const url = new URL(cpaUrl);
185
- return url.pathname.replace(/\/+$/, "").endsWith("/v1")
186
- ? cpaUrl
187
- : `${cpaUrl}/v1`;
188
- }
189
- catch {
190
- throw new CPACError("cpa_url must be an absolute http(s) URL");
191
- }
192
- }
193
- export async function fetchCatalog(cpaUrl, apiKey) {
194
- let response;
195
- try {
196
- response = await fetch(`${apiBase(cpaUrl)}/models?client_version=1`, {
197
- headers: {
198
- Authorization: `Bearer ${apiKey}`,
199
- Accept: "application/json",
200
- },
201
- signal: AbortSignal.timeout(20_000),
202
- });
203
- }
204
- catch {
205
- throw new CPACError("CPA catalog request failed");
206
- }
207
- if (!response.ok)
208
- throw new CPACError(`CPA catalog request failed: HTTP ${response.status}`);
209
- const declaredLength = response.headers.get("content-length");
210
- if (declaredLength &&
211
- /^\d+$/.test(declaredLength) &&
212
- Number(declaredLength) > MAX_CATALOG_BYTES) {
213
- await response.body?.cancel();
214
- throw new CPACError("CPA catalog exceeds 16 MiB");
215
- }
216
- const chunks = [];
217
- let length = 0;
218
- const reader = response.body?.getReader();
219
- if (reader) {
220
- while (true) {
221
- let result;
222
- try {
223
- result = await reader.read();
224
- }
225
- catch {
226
- throw new CPACError("CPA catalog request failed");
227
- }
228
- if (result.done)
229
- break;
230
- length += result.value.byteLength;
231
- if (length > MAX_CATALOG_BYTES) {
232
- await reader.cancel();
233
- throw new CPACError("CPA catalog exceeds 16 MiB");
234
- }
235
- chunks.push(result.value);
236
- }
237
- }
238
- const body = Buffer.concat(chunks, length);
239
- let document;
240
- try {
241
- document = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
242
- }
243
- catch {
244
- throw new CPACError("CPA catalog is not valid JSON");
245
- }
246
- const models = objectValue(document) ? document.models : undefined;
247
- if (!Array.isArray(models) || models.length === 0) {
248
- throw new CPACError("CPA rich catalog must contain a non-empty models array");
249
- }
250
- if (models.some((model) => !objectValue(model) || typeof model.slug !== "string" || !model.slug.trim())) {
251
- throw new CPACError("CPA catalog contains a model without a slug");
252
- }
253
- return {
254
- bytes: Buffer.from(`${JSON.stringify(document, null, 2)}\n`),
255
- modelCount: models.length,
256
- };
257
- }
258
- // Codex treats a catalog model's context_window as its input budget, not a
259
- // display label; upstream keeps it a conservative operating cap while
260
- // max_context_window holds the real ceiling (gpt-5.6: 272k vs ~921k, measured
261
- // by opencodex). Opt-in lift: raise context_window to max_context_window and
262
- // set auto_compact_token_limit at 90% (Codex's own convention), keeping
263
- // compaction ahead of the hard ceiling.
264
- export function liftContextWindows(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
- }
272
- for (const model of document.models) {
273
- const maxWindow = typeof model.max_context_window === "number" && model.max_context_window > 0
274
- ? Math.floor(model.max_context_window)
275
- : 0;
276
- const window = typeof model.context_window === "number"
277
- ? Math.floor(model.context_window)
278
- : 0;
279
- if (maxWindow <= window)
280
- continue;
281
- model.context_window = maxWindow;
282
- model.auto_compact_token_limit = Math.floor(maxWindow * 0.9);
283
- }
284
- return Buffer.from(`${JSON.stringify(document, null, 2)}\n`);
285
- }
286
- // Move `order` slugs to the front of the rich catalog (stable for the rest),
287
- // so the Codex client's first-5 spawn_agent advertisement picks them.
288
- export function reorderCatalog(bytes, order) {
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
- }
296
- const rank = new Map(order.map((slug, index) => [slug, index]));
297
- const models = document.models.map((model, index) => ({ model, index }));
298
- models.sort((left, right) => {
299
- const leftRank = rank.get(String(left.model.slug));
300
- const rightRank = rank.get(String(right.model.slug));
301
- if (leftRank === undefined && rightRank === undefined)
302
- return left.index - right.index;
303
- if (leftRank === undefined)
304
- return 1;
305
- if (rightRank === undefined)
306
- return -1;
307
- return leftRank - rightRank;
308
- });
309
- document.models = models.map((entry) => entry.model);
310
- return Buffer.from(`${JSON.stringify(document, null, 2)}\n`);
311
- }
312
- export function saveSpawnModels(configPath, models) {
313
- let document = {};
314
- if (existsSync(configPath)) {
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
- }
322
- if (!objectValue(parsed))
323
- throw new CPACError("config must be a JSON object");
324
- document = parsed;
325
- }
326
- document.spawn_models = models;
327
- atomicWrite(configPath, Buffer.from(`${JSON.stringify(document, null, 2)}\n`));
328
- }
329
- // Arrow-key checkbox picker on raw stdin; resolves with the checked labels in
330
- // check order (which becomes the spawn advertisement ranking).
331
- async function checkboxPicker(title, items, max) {
332
- if (!process.stdin.isTTY || !process.stderr.isTTY) {
333
- throw new CPACError("model selection requires an interactive terminal");
334
- }
335
- const stdin = process.stdin;
336
- const out = process.stderr;
337
- const selected = new Set();
338
- let cursor = 0;
339
- const lines = items.length + 1;
340
- const render = () => {
341
- out.write(`${title} [space: toggle, up/down: move, enter: confirm, q: cancel, max ${max}]\n`);
342
- items.forEach((item, index) => {
343
- const box = selected.has(index) ? "[x]" : "[ ]";
344
- out.write(`${index === cursor ? ">" : " "} ${box} ${item}\n`);
345
- });
346
- };
347
- const redraw = () => {
348
- out.write(`\x1b[${lines}F`);
349
- for (let line = 0; line < lines; line += 1)
350
- out.write("\x1b[2K\x1b[1E");
351
- out.write(`\x1b[${lines}F`);
352
- render();
353
- };
354
- return await new Promise((resolvePromise, rejectPromise) => {
355
- const finish = (error) => {
356
- stdin.removeListener("data", onData);
357
- try {
358
- stdin.setRawMode(false);
359
- }
360
- catch {
361
- // Terminal already gone; nothing to restore.
362
- }
363
- stdin.pause();
364
- out.write("\n");
365
- if (error)
366
- rejectPromise(error);
367
- else
368
- resolvePromise([...selected].map((index) => items[index]));
369
- };
370
- const onData = (chunk) => {
371
- const key = chunk.toString("utf8");
372
- if (key === "\x03" || key === "q" || key === "\x1b") {
373
- finish(new CPACError("model selection cancelled"));
374
- return;
375
- }
376
- if (key === "\r" || key === "\n") {
377
- if (max === 1 && selected.size === 0) {
378
- selected.add(cursor);
379
- }
380
- finish();
381
- return;
382
- }
383
- if (key === "\x1b[A")
384
- cursor = (cursor + items.length - 1) % items.length;
385
- else if (key === "\x1b[B")
386
- cursor = (cursor + 1) % items.length;
387
- else if (key === " ") {
388
- if (max === 1) {
389
- selected.clear();
390
- selected.add(cursor);
391
- finish();
392
- return;
393
- }
394
- if (selected.has(cursor))
395
- selected.delete(cursor);
396
- else if (selected.size < max)
397
- selected.add(cursor);
398
- }
399
- redraw();
400
- };
401
- stdin.setRawMode(true);
402
- stdin.resume();
403
- stdin.on("data", onData);
404
- render();
405
- });
406
- }
407
- export async function pickSpawnModels(config) {
408
- const apiKey = process.env[config.api_key_env]?.trim();
409
- if (!apiKey)
410
- throw new CPACError(`environment variable ${config.api_key_env} is not set`);
411
- const catalog = await fetchCatalog(config.cpa_url, apiKey);
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
- }
419
- const slugs = document.models.map((model) => model.slug);
420
- const picked = await checkboxPicker("Select spawn_agent models", slugs, MAX_SPAWN_MODELS);
421
- if (picked.length === 0)
422
- throw new CPACError("no models selected");
423
- return picked;
424
- }
425
- export function tomlString(value) {
426
- return JSON.stringify(value);
427
- }
428
- function tomlStringArray(values) {
429
- return `[ ${values.map(tomlString).join(", ")} ]`;
430
- }
431
- export function dominantEol(content) {
432
- const crlf = (content.match(/\r\n/g) ?? []).length;
433
- if (crlf === 0)
434
- return "\n";
435
- const bareLf = (content.match(/\n/g) ?? []).length - crlf;
436
- return crlf >= bareLf ? "\r\n" : "\n";
437
- }
438
- function providerPatterns(provider = PROVIDER) {
439
- const escaped = provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
440
- const modelProviders = `(?:model_providers|"model_providers"|'model_providers')`;
441
- const providerToken = `(?:${escaped}|"${escaped}"|'${escaped}')`;
442
- return {
443
- header: new RegExp(`^\\s*\\[\\s*${modelProviders}\\s*\\.\\s*${providerToken}\\s*\\]\\s*(?:#.*)?$`),
444
- arrayHeader: new RegExp(`^\\s*\\[\\[\\s*${modelProviders}\\s*\\.\\s*${providerToken}\\s*\\]\\]\\s*(?:#.*)?$`),
445
- parentHeader: new RegExp(`^\\s*\\[\\s*${modelProviders}\\s*\\]\\s*(?:#.*)?$`),
446
- inlineKey: new RegExp(`^\\s*${providerToken}\\s*=`),
447
- dottedKey: new RegExp(`^\\s*${modelProviders}\\s*\\.\\s*${providerToken}\\s*=`),
448
- };
449
- }
450
- function providerHeader(provider = PROVIDER) {
451
- return providerPatterns(provider).header;
452
- }
453
- export function hasProviderTable(content, provider = PROVIDER) {
454
- const patterns = providerPatterns(provider);
455
- let inRoot = true;
456
- let inModelProviders = false;
457
- for (const line of content.split(/\r?\n/)) {
458
- if (patterns.header.test(line) || patterns.arrayHeader.test(line))
459
- return true;
460
- if (/^\s*\[/.test(line)) {
461
- inRoot = false;
462
- inModelProviders = patterns.parentHeader.test(line);
463
- continue;
464
- }
465
- if ((inModelProviders && patterns.inlineKey.test(line)) ||
466
- (inRoot && patterns.dottedKey.test(line)))
467
- return true;
468
- }
469
- return false;
470
- }
471
- function stripManagedConfig(content) {
472
- let text;
473
- try {
474
- text = new TextDecoder("utf-8", { fatal: true }).decode(content);
475
- }
476
- catch {
477
- throw new CPACError("Codex config must be UTF-8");
478
- }
479
- const eol = dominantEol(text);
480
- const header = providerHeader();
481
- const lines = text.replace(/\r\n/g, "\n").split("\n");
482
- let inRoot = true;
483
- let inProvider = false;
484
- let managedRootKeys = 0;
485
- const kept = [];
486
- for (const line of lines) {
487
- if (inProvider && /^\s*\[/.test(line))
488
- inProvider = false;
489
- if (!inProvider && header.test(line)) {
490
- inProvider = true;
491
- continue;
492
- }
493
- if (inProvider)
494
- continue;
495
- if (/^\s*\[/.test(line))
496
- inRoot = false;
497
- if (inRoot && line === MANAGED_MARKER) {
498
- managedRootKeys = 2;
499
- continue;
500
- }
501
- if (inRoot && managedRootKeys > 0 && ROOT_KEY.test(line)) {
502
- managedRootKeys -= 1;
503
- continue;
504
- }
505
- kept.push(line);
506
- }
507
- const output = kept.join("\n");
508
- return Buffer.from(eol === "\n" ? output : output.replace(/\n/g, "\r\n"));
509
- }
510
- export function buildCodexConfig(original, proxyPort, catalogPath) {
511
- let text;
512
- try {
513
- text = new TextDecoder("utf-8", { fatal: true }).decode(original);
514
- }
515
- catch {
516
- throw new CPACError("Codex config must be UTF-8");
517
- }
518
- if (hasProviderTable(text)) {
519
- throw new CPACError(`Codex config already contains [model_providers.${PROVIDER}]`);
520
- }
521
- const normalizedLines = text.replace(/\r\n/g, "\n").split("\n");
522
- const firstTable = normalizedLines.findIndex((line) => /^\s*\[/.test(line));
523
- const rootLines = normalizedLines.slice(0, firstTable === -1 ? undefined : firstTable);
524
- const providerLine = rootLines.find((line) => MODEL_PROVIDER_KEY.test(line));
525
- if (providerLine) {
526
- const match = /=\s*["']([^"']+)["']/.exec(providerLine);
527
- if (!match || match[1] !== "openai") {
528
- throw new CPACError("Codex config selects an external model_provider; restore it to openai before injecting CPAC");
529
- }
530
- }
531
- if (rootLines.some((line) => OPENAI_BASE_URL_KEY.test(line))) {
532
- throw new CPACError("Codex config already contains openai_base_url; remove that user-owned override before injecting CPAC");
533
- }
534
- const eol = dominantEol(text);
535
- const lines = text.replace(/\r\n/g, "\n").split("\n");
536
- let inRoot = true;
537
- const kept = lines.filter((line) => {
538
- if (/^\s*\[/.test(line))
539
- inRoot = false;
540
- return !(inRoot && MODEL_CATALOG_KEY.test(line));
541
- });
542
- let body = kept.join("\n");
543
- if (body && !body.endsWith("\n"))
544
- body += "\n";
545
- const root = [
546
- MANAGED_MARKER,
547
- `model_catalog_json = ${tomlString(catalogPath)}`,
548
- `openai_base_url = ${tomlString(`http://127.0.0.1:${proxyPort}/v1`)}`,
549
- "",
550
- ].join("\n");
551
- const output = root + body;
552
- return Buffer.from(eol === "\n" ? output : output.replace(/\n/g, "\r\n"));
553
- }
554
- // Codex multi-agent v2 (features.multi_agent_v2). Accepts the same TOML
555
- // shapes codex-rs does: a [features.multi_agent_v2] table, an inline
556
- // `multi_agent_v2 = ...` under [features], or dotted root keys.
557
- const V2_TABLE_HEADER = /^\s*\[\s*(?:features|"features"|'features')\s*\.\s*(?:multi_agent_v2|"multi_agent_v2"|'multi_agent_v2')\s*\]\s*(?:#.*)?$/;
558
- const FEATURES_TABLE_HEADER = /^\s*\[\s*(?:features|"features"|'features')\s*\]\s*(?:#.*)?$/;
559
- const V2_KEY = /^\s*(?:multi_agent_v2|"multi_agent_v2"|'multi_agent_v2')\s*=/;
560
- const V2_DOTTED_KEY = /^\s*(?:features|"features"|'features')\s*\.\s*(?:multi_agent_v2|"multi_agent_v2"|'multi_agent_v2')(?:\s*\.\s*(?:enabled|"enabled"|'enabled'))?\s*=/;
561
- function v2LineValue(line) {
562
- const inline = line.match(/\{[^}]*\benabled\s*=\s*(true|false)/);
563
- if (inline)
564
- return inline[1] === "true";
565
- const plain = line.match(/=\s*(true|false)(?![A-Za-z0-9_])/);
566
- if (plain)
567
- return plain[1] === "true";
568
- return undefined;
569
- }
570
- export function multiAgentV2Enabled(content) {
571
- let table = "root";
572
- for (const line of content.replace(/\r\n/g, "\n").split("\n")) {
573
- if (V2_TABLE_HEADER.test(line)) {
574
- table = "v2";
575
- continue;
576
- }
577
- if (FEATURES_TABLE_HEADER.test(line)) {
578
- table = "features";
579
- continue;
580
- }
581
- if (/^\s*\[/.test(line)) {
582
- table = "other";
583
- continue;
584
- }
585
- if (table === "v2" && /^\s*(?:enabled|"enabled"|'enabled')\s*=/.test(line)) {
586
- return v2LineValue(line) ?? false;
587
- }
588
- if (table === "features" && V2_KEY.test(line))
589
- return v2LineValue(line) ?? false;
590
- if (table === "root" && V2_DOTTED_KEY.test(line))
591
- return v2LineValue(line) ?? false;
592
- }
593
- return false;
594
- }
595
- function stripMultiAgentV2(content) {
596
- const lines = content.replace(/\r\n/g, "\n").split("\n");
597
- const kept = [];
598
- let skipTable = false;
599
- let table = "root";
600
- for (const line of lines) {
601
- if (/^\s*\[/.test(line)) {
602
- skipTable = V2_TABLE_HEADER.test(line);
603
- if (skipTable) {
604
- table = "other";
605
- continue;
606
- }
607
- table = FEATURES_TABLE_HEADER.test(line) ? "features" : "other";
608
- }
609
- if (skipTable)
610
- continue;
611
- if (table === "features" && V2_KEY.test(line))
612
- continue;
613
- if (table === "root" && V2_DOTTED_KEY.test(line))
614
- continue;
615
- kept.push(line);
616
- }
617
- return kept.join("\n");
618
- }
619
- function hasAgentsMaxThreads(content) {
620
- let table = "root";
621
- for (const line of content.replace(/\r\n/g, "\n").split("\n")) {
622
- if (/^\s*\[/.test(line)) {
623
- table = /^\s*\[\s*(?:agents|"agents"|'agents')\s*\]\s*(?:#.*)?$/.test(line)
624
- ? "agents"
625
- : "other";
626
- continue;
627
- }
628
- if (table === "agents" && /^\s*max_threads\s*=/.test(line))
629
- return true;
630
- if (table === "root" &&
631
- /^\s*(?:agents|"agents"|'agents')\s*\.\s*max_threads\s*=/.test(line))
632
- return true;
633
- }
634
- return false;
635
- }
636
- export function withMultiAgentV2(content, enabled, eol) {
637
- let body = stripMultiAgentV2(content);
638
- if (body && !body.endsWith("\n"))
639
- body += "\n";
640
- const output = body + `\n[features.multi_agent_v2]\nenabled = ${enabled}\n`;
641
- return eol === "\n" ? output : output.replace(/\n/g, "\r\n");
642
- }
643
- export function atomicWrite(path, data, mode = 0o600) {
644
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
645
- const temporary = `${path}.cpac.${process.pid}.${Date.now()}.${++atomicSequence}.tmp`;
646
- let descriptor;
647
- try {
648
- descriptor = openSync(temporary, "wx", 0o600);
649
- writeFileSync(descriptor, data);
650
- fsyncSync(descriptor);
651
- closeSync(descriptor);
652
- descriptor = undefined;
653
- chmodSync(temporary, mode);
654
- renameSync(temporary, path);
655
- }
656
- catch (error) {
657
- if (descriptor !== undefined) {
658
- try {
659
- closeSync(descriptor);
660
- }
661
- catch (closeError) {
662
- void closeError;
663
- }
664
- }
665
- try {
666
- unlinkSync(temporary);
667
- }
668
- catch (unlinkError) {
669
- void unlinkError;
670
- }
671
- throw error;
672
- }
673
- }
674
- const HOP_BY_HOP_HEADERS = new Set([
675
- "connection",
676
- "keep-alive",
677
- "proxy-authenticate",
678
- "proxy-authorization",
679
- "proxy-connection",
680
- "te",
681
- "trailer",
682
- "transfer-encoding",
683
- "upgrade",
684
- ]);
685
- const CLIENT_CREDENTIAL_HEADERS = new Set([
686
- "authorization",
687
- "chatgpt-account-id",
688
- "cookie",
689
- "openai-organization",
690
- "openai-project",
691
- "x-api-key",
692
- "x-opencodex-api-key",
693
- ]);
694
- function localBrowserOrigin(value) {
695
- if (!value)
696
- return true;
697
- try {
698
- const hostname = new URL(value).hostname.toLowerCase();
699
- return (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1");
700
- }
701
- catch {
702
- return false;
703
- }
704
- }
705
- function proxyHeaders(headers, apiKey) {
706
- const forwarded = {};
707
- for (const [name, value] of Object.entries(headers)) {
708
- if (value === undefined ||
709
- name === "host" ||
710
- CLIENT_CREDENTIAL_HEADERS.has(name) ||
711
- HOP_BY_HOP_HEADERS.has(name)) {
712
- continue;
713
- }
714
- forwarded[name] = value;
715
- }
716
- forwarded.authorization = `Bearer ${apiKey}`;
717
- return forwarded;
718
- }
719
- function responseHeaders(headers) {
720
- const forwarded = {};
721
- for (const [name, value] of Object.entries(headers)) {
722
- if (value === undefined || HOP_BY_HOP_HEADERS.has(name))
723
- continue;
724
- forwarded[name] = value;
725
- }
726
- return forwarded;
727
- }
728
- function upstreamUrl(cpaUrl, requestUrl) {
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
- }
741
- }
742
- export async function createLoopbackProxy(cpaUrl, apiKey, proxyId, port) {
743
- const server = createServer((request, response) => {
744
- const requestUrl = request.url ?? "/";
745
- if (requestUrl === "/_cpac/health") {
746
- if (request.headers["x-cpac-proxy-id"] !== proxyId) {
747
- response.writeHead(404).end();
748
- return;
749
- }
750
- response.writeHead(204).end();
751
- return;
752
- }
753
- if (requestUrl === "/_cpac/shutdown" && request.method === "POST") {
754
- if (request.headers["x-cpac-proxy-id"] !== proxyId) {
755
- response.writeHead(404).end();
756
- return;
757
- }
758
- response.writeHead(204).end();
759
- setImmediate(() => {
760
- server.closeAllConnections?.();
761
- server.close();
762
- });
763
- return;
764
- }
765
- if (!localBrowserOrigin(request.headers.origin)) {
766
- response.writeHead(403, { "content-type": "application/json" });
767
- response.end(JSON.stringify({ error: "non-local origin rejected" }));
768
- return;
769
- }
770
- let target;
771
- try {
772
- target = upstreamUrl(cpaUrl, requestUrl);
773
- }
774
- catch {
775
- response.writeHead(400).end("invalid request URL");
776
- return;
777
- }
778
- const send = target.protocol === "https:" ? httpsRequest : httpRequest;
779
- const upstream = send(target, {
780
- method: request.method,
781
- headers: proxyHeaders(request.headers, apiKey),
782
- }, (upstreamResponse) => {
783
- response.writeHead(upstreamResponse.statusCode ?? 502, responseHeaders(upstreamResponse.headers));
784
- upstreamResponse.pipe(response);
785
- });
786
- upstream.once("error", () => {
787
- if (!response.headersSent) {
788
- response.writeHead(502, { "content-type": "application/json" });
789
- }
790
- if (!response.writableEnded)
791
- response.end(JSON.stringify({ error: "CPA upstream request failed" }));
792
- });
793
- request.once("aborted", () => upstream.destroy());
794
- response.once("close", () => {
795
- if (!response.writableEnded)
796
- upstream.destroy();
797
- });
798
- request.pipe(upstream);
799
- });
800
- server.on("upgrade", (_request, socket) => {
801
- socket.end("HTTP/1.1 426 Upgrade Required\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
802
- });
803
- server.on("clientError", (_error, socket) => socket.destroy());
804
- await new Promise((resolveListen, rejectListen) => {
805
- server.once("error", rejectListen);
806
- server.listen(port, "127.0.0.1", () => {
807
- server.off("error", rejectListen);
808
- resolveListen();
809
- });
810
- });
811
- const address = server.address();
812
- if (!address || typeof address === "string") {
813
- server.close();
814
- throw new CPACError("cannot determine loopback proxy port");
815
- }
816
- return { server, port: address.port };
817
- }
818
- const CLAUDE_ALIAS_PREFIX = "claude-cpac--";
819
- // Claude Code accepts CLAUDE_CODE_AUTO_COMPACT_WINDOW in 100K–1M (binary-verified).
820
- // A single global window cannot be per-model; 350K is opencodex's user-approved
821
- // default — high enough to mark mid-size models (372K/500K), low enough that
822
- // marking never outgrows a model's real window.
823
- const ONE_MILLION = 1_000_000;
824
- const COMPACT_WINDOW_DEFAULT = 350_000;
825
- function claudeCompactWindow(maxContext) {
826
- return maxContext > 200_000 ? COMPACT_WINDOW_DEFAULT : undefined;
827
- }
828
- // [1m] marking predicate (opencodex rule): native claude models manage their own
829
- // marker; aliases mark when the real window is >= 1M, or when it clears 200K and
830
- // can host the compact window (marking below it would trip API limits mid-session).
831
- function shouldMarkOneMillion(id, window, compactWindow) {
832
- if (id.startsWith("claude"))
833
- return false;
834
- if (window >= ONE_MILLION)
835
- return true;
836
- return (compactWindow !== undefined && window > 200_000 && window >= compactWindow);
837
- }
838
- // Auto tier defaults. Within the claude-family pool (whole catalog when no
839
- // claude model exists), opus matches its family name or stays unset; sonnet
840
- // and haiku match their family name first, then fall back through
841
- // gemini → grok → luna → first catalog row.
842
- function defaultClaudeSlots(models) {
843
- if (!models.length)
844
- return undefined;
845
- const claudeRows = models.filter((model) => model.id.startsWith("claude"));
846
- const pool = claudeRows.length ? claudeRows : models;
847
- const family = (keyword) => pool.find((model) => model.id.includes(keyword));
848
- const fallback = (keyword) => family(keyword) ??
849
- models.find((model) => model.id.includes("gemini")) ??
850
- models.find((model) => model.id.includes("grok")) ??
851
- models.find((model) => model.id.includes("luna")) ??
852
- models[0];
853
- const slots = {
854
- sonnet: fallback("sonnet").id,
855
- haiku: fallback("haiku").id,
856
- };
857
- const opus = family("opus");
858
- if (opus)
859
- slots.opus = opus.id;
860
- return slots;
861
- }
862
- // Resolve the tier-model env values: config overrides win over catalog
863
- // defaults; values are [1m]-marked per the predicate above (Claude Code
864
- // strips the marker before the request leaves).
865
- export function claudeTierSlots(override, models, compactWindow) {
866
- const defaults = defaultClaudeSlots(models);
867
- if (!defaults)
868
- return undefined;
869
- const windowById = new Map(models.map((model) => [model.id, model.window]));
870
- const mark = (id) => !/\[1m\]$/i.test(id) &&
871
- shouldMarkOneMillion(id, windowById.get(id) ?? 0, compactWindow)
872
- ? `${id}[1m]`
873
- : id;
874
- const slots = {
875
- sonnet: mark(override?.sonnet ?? defaults.sonnet),
876
- haiku: mark(override?.haiku ?? defaults.haiku),
877
- };
878
- const opus = override?.opus ?? defaults.opus;
879
- if (opus)
880
- slots.opus = mark(opus);
881
- return slots;
882
- }
883
- function catalogModelRows(document) {
884
- if (!objectValue(document))
885
- return undefined;
886
- const source = Array.isArray(document.models)
887
- ? document.models
888
- : Array.isArray(document.data)
889
- ? document.data
890
- : undefined;
891
- if (!source)
892
- return undefined;
893
- return source.filter(objectValue);
894
- }
895
- function catalogModelId(model) {
896
- if (typeof model.slug === "string" && model.slug.trim())
897
- return model.slug.trim();
898
- if (typeof model.id === "string" && model.id.trim())
899
- return model.id.trim();
900
- return undefined;
901
- }
902
- async function claudeModelList(cpaUrl, apiKey, response) {
903
- let rows;
904
- try {
905
- const catalog = await fetch(`${apiBase(cpaUrl)}/models?client_version=1`, {
906
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
907
- signal: AbortSignal.timeout(20_000),
908
- });
909
- if (catalog.ok)
910
- rows = catalogModelRows(await catalog.json());
911
- }
912
- catch {
913
- rows = undefined;
914
- }
915
- if (!rows) {
916
- response.writeHead(502, { "content-type": "application/json" });
917
- response.end(JSON.stringify({ error: "CPA catalog request failed" }));
918
- return;
919
- }
920
- // Claude Code's /model picker only lists ids starting with claude/anthropic;
921
- // expose every other catalog model as claude-cpac--<id>. Append the [1m]
922
- // marker when the model can host the auto-compact window — that marker is
923
- // the only signal that lifts context accounting above the 200K default
924
- // (Claude Code strips it before the request reaches the proxy).
925
- const rowsWithWindow = [];
926
- let maxContext = 0;
927
- for (const model of rows) {
928
- const id = catalogModelId(model);
929
- if (!id)
930
- continue;
931
- const window = typeof model.context_window === "number" && model.context_window > 0
932
- ? Math.floor(model.context_window)
933
- : 0;
934
- if (window > maxContext)
935
- maxContext = window;
936
- rowsWithWindow.push({
937
- id,
938
- window,
939
- alias: id.startsWith("claude") ? id : `${CLAUDE_ALIAS_PREFIX}${id}`,
940
- display: typeof model.display_name === "string" && model.display_name.trim()
941
- ? model.display_name
942
- : id,
943
- });
944
- }
945
- const compactWindow = claudeCompactWindow(maxContext);
946
- const data = rowsWithWindow.map((row) => ({
947
- type: "model",
948
- id: shouldMarkOneMillion(row.id, row.window, compactWindow)
949
- ? `${row.alias}[1m]`
950
- : row.alias,
951
- display_name: row.display,
952
- }));
953
- response.writeHead(200, { "content-type": "application/json" });
954
- response.end(JSON.stringify({
955
- data,
956
- has_more: false,
957
- first_id: data.length > 0 ? data[0].id : null,
958
- last_id: data.length > 0 ? data[data.length - 1].id : null,
959
- }));
960
- }
961
- export async function createClaudeProxy(cpaUrl, apiKey) {
962
- const server = createServer((request, response) => {
963
- const requestUrl = request.url ?? "/";
964
- if (request.method === "GET" && requestUrl.startsWith("/v1/models")) {
965
- void claudeModelList(cpaUrl, apiKey, response);
966
- return;
967
- }
968
- let target;
969
- try {
970
- target = upstreamUrl(cpaUrl, requestUrl);
971
- }
972
- catch {
973
- response.writeHead(400).end("invalid request URL");
974
- return;
975
- }
976
- const chunks = [];
977
- request.on("data", (chunk) => chunks.push(chunk));
978
- request.once("error", () => response.destroy());
979
- request.once("end", () => {
980
- let body = Buffer.concat(chunks);
981
- if (body.length > 0) {
982
- try {
983
- const parsed = JSON.parse(body.toString("utf8"));
984
- if (objectValue(parsed) &&
985
- typeof parsed.model === "string" &&
986
- parsed.model.startsWith(CLAUDE_ALIAS_PREFIX)) {
987
- // Claude Code strips [1m] itself; keep the strip as a guard.
988
- parsed.model = parsed.model
989
- .slice(CLAUDE_ALIAS_PREFIX.length)
990
- .replace(/\[(1|2)m\]$/i, "");
991
- body = Buffer.from(JSON.stringify(parsed));
992
- }
993
- }
994
- catch {
995
- // not JSON; forward unchanged
996
- }
997
- }
998
- const headers = proxyHeaders(request.headers, apiKey);
999
- headers["content-length"] = String(body.length);
1000
- const send = target.protocol === "https:" ? httpsRequest : httpRequest;
1001
- const upstream = send(target, { method: request.method, headers }, (upstreamResponse) => {
1002
- response.writeHead(upstreamResponse.statusCode ?? 502, responseHeaders(upstreamResponse.headers));
1003
- upstreamResponse.pipe(response);
1004
- });
1005
- upstream.once("error", () => {
1006
- if (!response.headersSent) {
1007
- response.writeHead(502, { "content-type": "application/json" });
1008
- }
1009
- if (!response.writableEnded)
1010
- response.end(JSON.stringify({ error: "CPA upstream request failed" }));
1011
- });
1012
- request.once("aborted", () => upstream.destroy());
1013
- response.once("close", () => {
1014
- if (!response.writableEnded)
1015
- upstream.destroy();
1016
- });
1017
- upstream.end(body);
1018
- });
1019
- });
1020
- server.on("clientError", (_error, socket) => socket.destroy());
1021
- await new Promise((resolveListen, rejectListen) => {
1022
- server.once("error", rejectListen);
1023
- server.listen(0, "127.0.0.1", () => {
1024
- server.off("error", rejectListen);
1025
- resolveListen();
1026
- });
1027
- });
1028
- const address = server.address();
1029
- if (!address || typeof address === "string") {
1030
- server.close();
1031
- throw new CPACError("cannot determine Claude proxy port");
1032
- }
1033
- return { server, port: address.port };
1034
- }
1035
- async function runProxyChild(port) {
1036
- const cpaUrl = process.env.CPAC_PROXY_UPSTREAM;
1037
- const apiKey = process.env.CPAC_PROXY_API_KEY;
1038
- const proxyId = process.env.CPAC_PROXY_ID;
1039
- if (!cpaUrl || !apiKey || !proxyId)
1040
- return 1;
1041
- try {
1042
- const proxy = await createLoopbackProxy(cpaUrl, apiKey, proxyId, port);
1043
- process.send?.({ ready: true, port: proxy.port, pid: process.pid });
1044
- const close = () => {
1045
- proxy.server.closeAllConnections?.();
1046
- proxy.server.close();
1047
- };
1048
- process.once("SIGINT", close);
1049
- process.once("SIGTERM", close);
1050
- await new Promise((resolveClose) => proxy.server.once("close", resolveClose));
1051
- return 0;
1052
- }
1053
- catch (error) {
1054
- process.send?.({
1055
- ready: false,
1056
- error: error instanceof Error ? error.message : String(error),
1057
- });
1058
- return 1;
1059
- }
1060
- }
1061
- async function proxyIsHealthy(proxy) {
1062
- try {
1063
- const response = await fetch(`http://127.0.0.1:${proxy.port}/_cpac/health`, {
1064
- headers: { "x-cpac-proxy-id": proxy.id },
1065
- signal: AbortSignal.timeout(1_000),
1066
- });
1067
- return response.status === 204;
1068
- }
1069
- catch {
1070
- return false;
1071
- }
1072
- }
1073
- async function startProxyProcess(config, apiKey) {
1074
- const id = randomBytes(24).toString("hex");
1075
- const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "_proxy", String(config.codex_proxy_port)], {
1076
- detached: true,
1077
- env: {
1078
- ...process.env,
1079
- CPAC_PROXY_API_KEY: apiKey,
1080
- CPAC_PROXY_ID: id,
1081
- CPAC_PROXY_UPSTREAM: config.cpa_url,
1082
- },
1083
- stdio: ["ignore", "ignore", "ignore", "ipc"],
1084
- });
1085
- return await new Promise((resolveStart, rejectStart) => {
1086
- let settled = false;
1087
- let timeout;
1088
- const finish = (error, value) => {
1089
- if (settled)
1090
- return;
1091
- settled = true;
1092
- clearTimeout(timeout);
1093
- child.removeAllListeners();
1094
- if (child.connected)
1095
- child.disconnect();
1096
- child.unref();
1097
- if (error)
1098
- rejectStart(error);
1099
- else
1100
- resolveStart(value);
1101
- };
1102
- timeout = setTimeout(() => {
1103
- child.kill();
1104
- finish(new CPACError("loopback proxy did not become ready"));
1105
- }, 5_000);
1106
- child.once("error", (error) => finish(new CPACError(`cannot start loopback proxy: ${error.message}`)));
1107
- child.once("exit", (code) => finish(new CPACError(`loopback proxy exited before ready (${code ?? 1})`)));
1108
- child.once("message", (message) => {
1109
- if (objectValue(message) &&
1110
- message.ready === true &&
1111
- typeof message.pid === "number" &&
1112
- typeof message.port === "number") {
1113
- finish(undefined, { id, pid: message.pid, port: message.port });
1114
- return;
1115
- }
1116
- const detail = objectValue(message) && typeof message.error === "string"
1117
- ? `: ${message.error}`
1118
- : "";
1119
- finish(new CPACError(`cannot start loopback proxy${detail}`));
1120
- });
1121
- });
1122
- }
1123
- export async function stopProxyProcess(proxy) {
1124
- if (!(await proxyIsHealthy(proxy)))
1125
- return;
1126
- try {
1127
- await fetch(`http://127.0.0.1:${proxy.port}/_cpac/shutdown`, {
1128
- method: "POST",
1129
- headers: { "x-cpac-proxy-id": proxy.id },
1130
- signal: AbortSignal.timeout(1_000),
1131
- });
1132
- }
1133
- catch {
1134
- // The server may close the connection while shutting down.
1135
- }
1136
- }
1137
- export function readState(stateDir) {
1138
- const path = join(stateDir, "state.json");
1139
- if (!existsSync(path))
1140
- return null;
1141
- let value;
1142
- try {
1143
- value = JSON.parse(readFileSync(path, "utf8"));
1144
- }
1145
- catch (error) {
1146
- throw new CPACError(`cannot read state: ${error instanceof Error ? error.message : String(error)}`);
1147
- }
1148
- if (!objectValue(value) ||
1149
- Object.keys(value).some((key) => !STATE_KEYS.has(key)) ||
1150
- [...REQUIRED_STATE_KEYS].some((key) => !(key in value))) {
1151
- throw new CPACError("invalid state file");
1152
- }
1153
- if (typeof value.config_path !== "string" || !isAbsolute(value.config_path)) {
1154
- throw new CPACError("invalid config path in state");
1155
- }
1156
- if (typeof value.config_existed !== "boolean")
1157
- throw new CPACError("invalid config_existed value in state");
1158
- if (!Number.isInteger(value.config_mode) ||
1159
- value.config_mode < 0 ||
1160
- value.config_mode > 0o7777) {
1161
- throw new CPACError("invalid config_mode value in state");
1162
- }
1163
- const proxyKeys = ["proxy_id", "proxy_pid", "proxy_port"];
1164
- const proxyKeyCount = proxyKeys.filter((key) => key in value).length;
1165
- if (proxyKeyCount !== 0 &&
1166
- (proxyKeyCount !== proxyKeys.length ||
1167
- typeof value.proxy_id !== "string" ||
1168
- !/^[a-f0-9]{48}$/.test(value.proxy_id) ||
1169
- !Number.isInteger(value.proxy_pid) ||
1170
- value.proxy_pid <= 0 ||
1171
- !Number.isInteger(value.proxy_port) ||
1172
- value.proxy_port <= 0 ||
1173
- value.proxy_port > 65535)) {
1174
- throw new CPACError("invalid loopback proxy state");
1175
- }
1176
- if (value.proxy_fingerprint !== undefined &&
1177
- (typeof value.proxy_fingerprint !== "string" ||
1178
- !/^[a-f0-9]{64}$/.test(value.proxy_fingerprint))) {
1179
- throw new CPACError("invalid loopback proxy fingerprint");
1180
- }
1181
- return value;
1182
- }
1183
- export function stateProxy(state) {
1184
- return state.proxy_id && state.proxy_pid && state.proxy_port
1185
- ? { id: state.proxy_id, pid: state.proxy_pid, port: state.proxy_port }
1186
- : null;
1187
- }
1188
- function stateBytes(config, existed, mode, proxy, proxyFingerprint) {
1189
- return Buffer.from(`${JSON.stringify({
1190
- config_path: config.codex_config,
1191
- config_existed: existed,
1192
- config_mode: mode,
1193
- proxy_id: proxy.id,
1194
- proxy_fingerprint: proxyFingerprint,
1195
- proxy_pid: proxy.pid,
1196
- proxy_port: proxy.port,
1197
- }, null, 2)}\n`);
1198
- }
1199
- function originalBytes(stateDir, state, expectedPath) {
1200
- if (state.config_path !== expectedPath) {
1201
- throw new CPACError(`active injection belongs to ${state.config_path}; restore it first`);
1202
- }
1203
- if (!state.config_existed)
1204
- return new Uint8Array();
1205
- try {
1206
- return readFileSync(join(stateDir, "config.toml.backup"));
1207
- }
1208
- catch (error) {
1209
- throw new CPACError(`cannot read original config backup: ${error instanceof Error ? error.message : String(error)}`);
1210
- }
1211
- }
1212
- function cleanupStateFiles(stateDir) {
1213
- for (const name of STATE_FILES)
1214
- rmSync(join(stateDir, name), { force: true });
1215
- try {
1216
- rmdirSync(stateDir);
1217
- }
1218
- catch (error) {
1219
- const code = error.code;
1220
- if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST")
1221
- throw error;
1222
- }
1223
- }
1224
- function proxyFingerprint(config, apiKey) {
1225
- return createHash("sha256")
1226
- .update(config.cpa_url)
1227
- .update("\0")
1228
- .update(apiKey)
1229
- .digest("hex");
1230
- }
1231
- export async function inject(config, v2Off = false, maxContext = false) {
1232
- const apiKey = process.env[config.api_key_env]?.trim();
1233
- if (!apiKey)
1234
- throw new CPACError(`environment variable ${config.api_key_env} is not set`);
1235
- const state = readState(config.state_dir);
1236
- let existed;
1237
- let original;
1238
- let originalMode;
1239
- if (state) {
1240
- const backup = originalBytes(config.state_dir, state, config.codex_config);
1241
- try {
1242
- original = existsSync(config.codex_config)
1243
- ? stripManagedConfig(readFileSync(config.codex_config))
1244
- : backup;
1245
- }
1246
- catch (error) {
1247
- if (error instanceof CPACError)
1248
- throw error;
1249
- throw new CPACError(`cannot read Codex config: ${error instanceof Error ? error.message : String(error)}`);
1250
- }
1251
- existed = state.config_existed;
1252
- originalMode = state.config_mode;
1253
- }
1254
- else {
1255
- existed = existsSync(config.codex_config);
1256
- try {
1257
- original = existed ? readFileSync(config.codex_config) : new Uint8Array();
1258
- originalMode = existed ? statSync(config.codex_config).mode & 0o7777 : 0o600;
1259
- }
1260
- catch (error) {
1261
- throw new CPACError(`cannot read Codex config: ${error instanceof Error ? error.message : String(error)}`);
1262
- }
1263
- }
1264
- const catalog = await fetchCatalog(config.cpa_url, apiKey);
1265
- if (config.spawn_models?.length)
1266
- catalog.bytes = reorderCatalog(catalog.bytes, config.spawn_models);
1267
- if (maxContext)
1268
- catalog.bytes = liftContextWindows(catalog.bytes);
1269
- const stateDirExisted = existsSync(config.state_dir);
1270
- if (!state &&
1271
- STATE_FILES.some((name) => existsSync(join(config.state_dir, name)))) {
1272
- throw new CPACError("state_dir contains CPAC files without a valid state; refusing to overwrite them");
1273
- }
1274
- const fingerprint = proxyFingerprint(config, apiKey);
1275
- const previousProxy = state ? stateProxy(state) : null;
1276
- let proxy;
1277
- let startedProxy = false;
1278
- if (previousProxy &&
1279
- state?.proxy_fingerprint === fingerprint &&
1280
- (config.codex_proxy_port === 0 ||
1281
- config.codex_proxy_port === previousProxy.port) &&
1282
- (await proxyIsHealthy(previousProxy))) {
1283
- proxy = previousProxy;
1284
- }
1285
- else {
1286
- if (previousProxy)
1287
- await stopProxyProcess(previousProxy);
1288
- try {
1289
- proxy = await startProxyProcess(config, apiKey);
1290
- startedProxy = true;
1291
- }
1292
- catch (error) {
1293
- throw new CPACError(`${error instanceof Error ? error.message : String(error)}; choose another codex_proxy_port if the port is occupied`);
1294
- }
1295
- }
1296
- const catalogPath = join(config.state_dir, "codex-models.json");
1297
- let injected;
1298
- try {
1299
- injected = buildCodexConfig(original, proxy.port, catalogPath);
1300
- const injectedText = new TextDecoder("utf-8").decode(injected);
1301
- injected = Buffer.from(withMultiAgentV2(injectedText, !v2Off, dominantEol(injectedText)));
1302
- if (!v2Off && hasAgentsMaxThreads(injectedText)) {
1303
- console.warn("warning: [agents] max_threads is set; codex refuses to start with multi_agent_v2 enabled, remove it");
1304
- }
1305
- }
1306
- catch (error) {
1307
- if (startedProxy)
1308
- await stopProxyProcess(proxy);
1309
- throw error;
1310
- }
1311
- mkdirSync(config.state_dir, { recursive: true, mode: 0o700 });
1312
- if (!stateDirExisted)
1313
- chmodSync(config.state_dir, 0o700);
1314
- if (state) {
1315
- try {
1316
- atomicWrite(catalogPath, catalog.bytes);
1317
- atomicWrite(config.codex_config, injected, originalMode);
1318
- atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint));
1319
- }
1320
- catch (error) {
1321
- if (startedProxy)
1322
- await stopProxyProcess(proxy);
1323
- throw error;
1324
- }
1325
- }
1326
- else {
1327
- let configWritten = false;
1328
- try {
1329
- if (existed)
1330
- atomicWrite(join(config.state_dir, "config.toml.backup"), original);
1331
- atomicWrite(catalogPath, catalog.bytes);
1332
- atomicWrite(config.codex_config, injected, originalMode);
1333
- configWritten = true;
1334
- atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint));
1335
- }
1336
- catch (error) {
1337
- if (configWritten) {
1338
- try {
1339
- if (existed)
1340
- atomicWrite(config.codex_config, original, originalMode);
1341
- else
1342
- rmSync(config.codex_config, { force: true });
1343
- }
1344
- catch (rollbackError) {
1345
- throw new CPACError(`injection failed and config rollback failed; backup retained in ${config.state_dir}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
1346
- }
1347
- }
1348
- let cleanupError;
1349
- try {
1350
- cleanupStateFiles(config.state_dir);
1351
- }
1352
- catch (caught) {
1353
- cleanupError = caught;
1354
- }
1355
- if (startedProxy)
1356
- await stopProxyProcess(proxy);
1357
- if (cleanupError) {
1358
- throw new CPACError(`injection failed and state cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
1359
- }
1360
- throw error;
1361
- }
1362
- }
1363
- try {
1364
- rmSync(join(dirname(config.codex_config), "models_cache.json"), {
1365
- force: true,
1366
- });
1367
- }
1368
- catch {
1369
- console.warn("CPAC could not invalidate Codex models_cache.json; restart Codex App if its model list is stale.");
1370
- }
1371
- console.log(`Injected ${catalog.modelCount} CPA models into ${config.codex_config} via http://127.0.0.1:${proxy.port}/v1 (multi-agent v2: ${v2Off ? "off" : "on"})`);
1372
- console.log("Restart Codex App if its running app-server still shows the old model list.");
1373
- }
1374
- export async function restore(config) {
1375
- const state = readState(config.state_dir);
1376
- if (!state)
1377
- throw new CPACError("no active CPAC injection");
1378
- const original = originalBytes(config.state_dir, state, config.codex_config);
1379
- if (state.config_existed)
1380
- atomicWrite(config.codex_config, original, state.config_mode);
1381
- else
1382
- rmSync(config.codex_config, { force: true });
1383
- const proxy = stateProxy(state);
1384
- if (proxy)
1385
- await stopProxyProcess(proxy);
1386
- try {
1387
- cleanupStateFiles(config.state_dir);
1388
- }
1389
- catch (error) {
1390
- throw new CPACError(`config restored, but state cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
1391
- }
1392
- console.log(`Restored ${config.codex_config}`);
1393
- }
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { CPAC_VERSION, isGrokConfigInstalled, isKimiConfigInstalled, isPiExtensionInstalled, runCodexConfig, runDetect, runInstall, runOpencode, runRestore, runSync, runTargetLauncher, runUpgrade, } from "./agents.js";
7
+ import { runClaude, runClaudeConfig, runClaudeModels } from "./claude.js";
8
+ import { inject, multiAgentV2Enabled } from "./codex.js";
9
+ import { defaultConfigPath, loadConfig, originalBytes, pickSpawnModels, readState, saveSpawnModels, stateProxy, } from "./config.js";
10
+ import { proxyIsHealthy, runProxy, runProxyChild } from "./proxy.js";
11
+ import { CPACError, expandUserPath, promptSecret, saveApiKeyExport, shellProfile, } from "./util.js";
12
+ export { PROVIDER, loadConfig, saveSpawnModels, liftContextWindows, reorderCatalog, readState, stateProxy, } from "./config.js";
13
+ export { saveApiKeyExport } from "./util.js";
14
+ export { stopProxyProcess } from "./proxy.js";
15
+ export { buildCodexConfig, hasProviderTable, inject, multiAgentV2Enabled, restore, withMultiAgentV2, } from "./codex.js";
16
+ export { claudeTierSlots, createClaudeProxy, runClaude } from "./claude.js";
17
+ export { detectClientVersion, detectTargets, installGrokConfig, installKimiConfig, installPiExtension, isGrokConfigInstalled, isKimiConfigInstalled, isPiExtensionInstalled, runInstall, runOpencode, runRestore, runSync, runTargetLauncher, uninstallGrokConfig, uninstallKimiConfig, uninstallPiExtension, } from "./agents.js";
1394
18
  export async function status(config) {
1395
19
  const apiKey = process.env[config.api_key_env]?.trim();
1396
20
  const keyConfigured = !!apiKey;
@@ -1437,60 +61,6 @@ export async function status(config) {
1437
61
  return 2;
1438
62
  return 0;
1439
63
  }
1440
- async function promptSecret(name) {
1441
- if (!process.stdin.isTTY || !process.stderr.isTTY) {
1442
- throw new CPACError(`environment variable ${name} is not set; run: export ${name}="..."`);
1443
- }
1444
- process.stderr.write(`Enter ${name}: `);
1445
- const silent = new Writable({
1446
- write(_chunk, _encoding, callback) {
1447
- callback();
1448
- },
1449
- });
1450
- const prompt = createInterface({
1451
- input: process.stdin,
1452
- output: silent,
1453
- terminal: true,
1454
- });
1455
- try {
1456
- const value = (await prompt.question("")).trim();
1457
- process.stderr.write("\n");
1458
- if (!value)
1459
- throw new CPACError(`${name} must not be empty`);
1460
- return value;
1461
- }
1462
- finally {
1463
- prompt.close();
1464
- }
1465
- }
1466
- async function resolveApiKey(name) {
1467
- return process.env[name]?.trim() || (await promptSecret(name));
1468
- }
1469
- function shellProfile() {
1470
- const shell = parse(process.env.SHELL || "").base;
1471
- if (shell === "zsh")
1472
- return join(homedir(), ".zshrc");
1473
- if (shell === "bash")
1474
- return join(homedir(), process.platform === "darwin" ? ".bash_profile" : ".bashrc");
1475
- if (["sh", "dash", "ksh"].includes(shell))
1476
- return join(homedir(), ".profile");
1477
- throw new CPACError(`unsupported shell ${shell || "unknown"}; run: export CPA_API_KEY="..."`);
1478
- }
1479
- function shellQuote(value) {
1480
- return `'${value.replaceAll("'", `'"'"'`)}'`;
1481
- }
1482
- export function saveApiKeyExport(profile, name, apiKey) {
1483
- const start = `# >>> CPAC ${name} >>>`;
1484
- const end = `# <<< CPAC ${name} <<<`;
1485
- const block = `${start}\nexport ${name}=${shellQuote(apiKey)}\n${end}`;
1486
- let content = existsSync(profile) ? readFileSync(profile, "utf8") : "";
1487
- const managed = new RegExp(`${start.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
1488
- content = managed.test(content)
1489
- ? content.replace(managed, block)
1490
- : `${content}${content && !content.endsWith("\n") ? "\n" : ""}${block}\n`;
1491
- const mode = existsSync(profile) ? statSync(profile).mode & 0o7777 : 0o600;
1492
- atomicWrite(profile, Buffer.from(content), mode);
1493
- }
1494
64
  async function guide(config) {
1495
65
  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`);
1496
66
  if (process.env[config.api_key_env]?.trim())
@@ -1504,974 +74,6 @@ async function guide(config) {
1504
74
  }
1505
75
  // opencode's config schema rejects a limit block with context but no output;
1506
76
  // the catalog has no output field, so a safe budget stands in, clamped to the
1507
- // context window (same scheme as opencodex's SCHEMA_REQUIRED_OUTPUT_BUDGET).
1508
- const OPENCODE_OUTPUT_BUDGET = 32_000;
1509
- // Ephemeral takeover: inline a CPA provider via OPENCODE_CONFIG_CONTENT so no
1510
- // opencode config file is touched; the session ends with zero residue.
1511
- export async function runOpencode(config, args, executable = "opencode") {
1512
- const apiKey = process.env[config.api_key_env]?.trim();
1513
- if (!apiKey)
1514
- throw new CPACError(`environment variable ${config.api_key_env} is not set`);
1515
- const catalog = await fetchCatalog(config.cpa_url, apiKey);
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
- }
1523
- const models = {};
1524
- for (const row of document.models) {
1525
- const id = catalogModelId(row);
1526
- if (!id)
1527
- continue;
1528
- const window = typeof row.context_window === "number" && row.context_window > 0
1529
- ? Math.floor(row.context_window)
1530
- : 0;
1531
- models[id] =
1532
- window > 0
1533
- ? {
1534
- limit: {
1535
- context: window,
1536
- output: Math.min(OPENCODE_OUTPUT_BUDGET, window),
1537
- },
1538
- }
1539
- : {};
1540
- }
1541
- const content = JSON.stringify({
1542
- provider: {
1543
- cpac: {
1544
- npm: "@ai-sdk/openai-compatible",
1545
- options: { baseURL: apiBase(config.cpa_url), apiKey },
1546
- models,
1547
- },
1548
- },
1549
- });
1550
- const env = {
1551
- ...process.env,
1552
- OPENCODE_CONFIG_CONTENT: content,
1553
- };
1554
- return await new Promise((resolve, reject) => {
1555
- const child = spawn(executable, args, { env, stdio: "inherit" });
1556
- child.once("error", (error) => {
1557
- reject(new CPACError(error instanceof Error && "code" in error && error.code === "ENOENT"
1558
- ? `${executable} not found`
1559
- : `cannot start ${executable}: ${error.message}`));
1560
- });
1561
- child.once("close", (code) => resolve(code ?? 1));
1562
- });
1563
- }
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);
1654
- const catalog = await fetchCatalog(config.cpa_url, apiKey);
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
- }
1674
- }
1675
- }
1676
- if (!Object.keys(models).length && !shouldPick) {
1677
- console.log(config.claude_models
1678
- ? JSON.stringify(config.claude_models, null, 2)
1679
- : "claude_models not set; cpac claude auto-picks tier models from the CPA catalog");
1680
- return 0;
1681
- }
1682
- let raw = {};
1683
- try {
1684
- const file = JSON.parse(readFileSync(configPath, "utf8"));
1685
- if (!objectValue(file))
1686
- throw new CPACError("config must be a JSON object");
1687
- raw = file;
1688
- }
1689
- catch (error) {
1690
- if (error.code !== "ENOENT") {
1691
- if (error instanceof CPACError)
1692
- throw error;
1693
- throw new CPACError(`cannot read config: ${error instanceof Error ? error.message : String(error)}`);
1694
- }
1695
- }
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)}`);
1704
- return 0;
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
- }
1715
- export async function runClaude(config, args, executable = "claude") {
1716
- const apiKey = await resolveApiKey(config.api_key_env);
1717
- const proxy = await createClaudeProxy(config.cpa_url, apiKey);
1718
- const stopProxy = () => {
1719
- proxy.server.closeAllConnections?.();
1720
- proxy.server.close();
1721
- };
1722
- // Gateway discovery only carries {id, display_name}; Claude Code accounts
1723
- // claude-prefixed unknown models at 200K and ignores MAX_CONTEXT_TOKENS for
1724
- // them. The [1m] marker on the alias (added by the models listing) plus the
1725
- // auto-compact window is opencodex's working mechanism for bigger windows.
1726
- let maxContext = 0;
1727
- const catalogWindows = [];
1728
- try {
1729
- const catalog = await fetchCatalog(config.cpa_url, apiKey);
1730
- const rows = catalogModelRows(JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes))) ?? [];
1731
- for (const row of rows) {
1732
- const id = catalogModelId(row);
1733
- const window = typeof row.context_window === "number" && row.context_window > 0
1734
- ? Math.floor(row.context_window)
1735
- : 0;
1736
- if (id)
1737
- catalogWindows.push({ id, window });
1738
- if (window > maxContext)
1739
- maxContext = window;
1740
- }
1741
- }
1742
- catch {
1743
- // keep the default window when the catalog is unreachable
1744
- }
1745
- const compactWindow = claudeCompactWindow(maxContext);
1746
- const slots = claudeTierSlots(config.claude_models, catalogWindows, compactWindow);
1747
- const env = {
1748
- ...process.env,
1749
- ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxy.port}`,
1750
- ANTHROPIC_AUTH_TOKEN: apiKey,
1751
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
1752
- CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1",
1753
- };
1754
- if (compactWindow !== undefined &&
1755
- !(Number(process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW) > 0)) {
1756
- env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(compactWindow);
1757
- }
1758
- const setSlot = (name, value) => {
1759
- if (process.env[name] === undefined)
1760
- env[name] = value;
1761
- };
1762
- if (slots) {
1763
- // Route haiku/subagent tier traffic at catalog models instead of the
1764
- // stock claude-haiku-* ids the gateway does not carry. The opus slot
1765
- // stays unset when the catalog has no opus-family model.
1766
- if (slots.opus)
1767
- setSlot("ANTHROPIC_DEFAULT_OPUS_MODEL", slots.opus);
1768
- setSlot("ANTHROPIC_DEFAULT_SONNET_MODEL", slots.sonnet);
1769
- setSlot("ANTHROPIC_DEFAULT_HAIKU_MODEL", slots.haiku);
1770
- setSlot("ANTHROPIC_SMALL_FAST_MODEL", slots.haiku);
1771
- }
1772
- delete env.ANTHROPIC_API_KEY;
1773
- delete env.CLAUDE_CODE_USE_ANTHROPIC_AWS;
1774
- delete env.CLAUDE_CODE_USE_BEDROCK;
1775
- delete env.CLAUDE_CODE_USE_FOUNDRY;
1776
- delete env.CLAUDE_CODE_USE_VERTEX;
1777
- return await new Promise((resolve, reject) => {
1778
- const child = spawn(executable, args, { env, stdio: "inherit" });
1779
- child.once("error", (error) => {
1780
- stopProxy();
1781
- reject(new CPACError(error instanceof Error && "code" in error && error.code === "ENOENT"
1782
- ? `${executable} not found`
1783
- : `cannot start ${executable}: ${error.message}`));
1784
- });
1785
- child.once("close", (code) => {
1786
- stopProxy();
1787
- resolve(code ?? 1);
1788
- });
1789
- });
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
- }
1838
- export async function runProxy(config) {
1839
- const apiKey = await resolveApiKey(config.api_key_env);
1840
- const state = readState(config.state_dir);
1841
- if (!state)
1842
- throw new CPACError("no active CPAC injection; run cpac inject first");
1843
- originalBytes(config.state_dir, state, config.codex_config);
1844
- const recorded = stateProxy(state);
1845
- if (!recorded) {
1846
- throw new CPACError("injection has no loopback proxy state; run cpac inject to migrate it");
1847
- }
1848
- if (await proxyIsHealthy(recorded)) {
1849
- throw new CPACError(`loopback proxy is already running on 127.0.0.1:${recorded.port}`);
1850
- }
1851
- const proxy = await createLoopbackProxy(config.cpa_url, apiKey, recorded.id, recorded.port);
1852
- try {
1853
- atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, state.config_existed, state.config_mode, { id: recorded.id, pid: process.pid, port: proxy.port }, proxyFingerprint(config, apiKey)));
1854
- }
1855
- catch (error) {
1856
- proxy.server.closeAllConnections?.();
1857
- proxy.server.close();
1858
- throw error;
1859
- }
1860
- console.log(`CPAC loopback proxy listening on http://127.0.0.1:${proxy.port}/v1`);
1861
- const close = () => {
1862
- proxy.server.closeAllConnections?.();
1863
- proxy.server.close();
1864
- };
1865
- process.once("SIGINT", close);
1866
- process.once("SIGTERM", close);
1867
- await new Promise((resolveClose) => proxy.server.once("close", resolveClose));
1868
- return 0;
1869
- }
1870
- function piExtensionsDir() {
1871
- return join(expandUserPath(process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent")), "extensions");
1872
- }
1873
- export function isPiExtensionInstalled() {
1874
- return existsSync(join(piExtensionsDir(), "cpac.ts"));
1875
- }
1876
- function piTemplatePath() {
1877
- return join(dirname(fileURLToPath(import.meta.url)), "pi-extension.template");
1878
- }
1879
- function piExtensionContent(cpaUrl) {
1880
- return readFileSync(piTemplatePath(), "utf8").replace("__CPA_URL__", cpaUrl);
1881
- }
1882
- export async function installPiExtension(config) {
1883
- const dir = piExtensionsDir();
1884
- mkdirSync(dir, { recursive: true, mode: 0o700 });
1885
- const target = join(dir, "cpac.ts");
1886
- ensureCpacBackup(target);
1887
- atomicWrite(target, Buffer.from(piExtensionContent(config.cpa_url)), 0o644);
1888
- console.log(`Installed Pi extension: ${target}`);
1889
- }
1890
- export async function uninstallPiExtension() {
1891
- const target = join(piExtensionsDir(), "cpac.ts");
1892
- if (!existsSync(target))
1893
- throw new CPACError("Pi extension is not installed");
1894
- unlinkSync(target);
1895
- console.log(`Removed Pi extension: ${target}`);
1896
- }
1897
- // Pre-write snapshot kept at <target>.cpac-backup (first version only) so a
1898
- // damaged config can be restored by hand even if uninstall cannot parse it.
1899
- function ensureCpacBackup(target) {
1900
- if (!existsSync(target))
1901
- return;
1902
- const backup = `${target}.cpac-backup`;
1903
- if (existsSync(backup))
1904
- return;
1905
- copyFileSync(target, backup);
1906
- }
1907
- function kimiConfigPath() {
1908
- const home = process.env.KIMI_CODE_HOME?.trim() || join(homedir(), ".kimi-code");
1909
- return join(expandUserPath(home), "config.toml");
1910
- }
1911
- const KIMI_BLOCK_START = "# >>> CPAC Kimi >>>";
1912
- const KIMI_BLOCK_END = "# <<< CPAC Kimi <<<";
1913
- const kimiBlockRegex = new RegExp(`${KIMI_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${KIMI_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`);
1914
- export function isKimiConfigInstalled() {
1915
- try {
1916
- const content = readFileSync(kimiConfigPath(), "utf8");
1917
- return (kimiBlockRegex.test(content) || /^\s*\[providers\.cpac\]\s*$/m.test(content));
1918
- }
1919
- catch {
1920
- return false;
1921
- }
1922
- }
1923
- function isCpacTableHeader(line) {
1924
- const header = line.match(/^\[([^\]]+)\]\s*$/);
1925
- if (!header)
1926
- return false;
1927
- const name = header[1];
1928
- return (name === "providers.cpac" ||
1929
- name.startsWith("providers.cpac.") ||
1930
- /^models\.(?:"cpac\/|'cpac\/)/.test(name));
1931
- }
1932
- // Kimi may rewrite config.toml and drop our comment markers, leaving the
1933
- // [providers.cpac] / [models."cpac/..."] tables behind. A later install would
1934
- // then append a second copy and make the file invalid TOML.
1935
- function stripOrphanCpacTables(content) {
1936
- const eol = content.includes("\r\n") ? "\r\n" : "\n";
1937
- const out = [];
1938
- let skipping = false;
1939
- for (const line of content.split(/\r?\n/)) {
1940
- if (/^\[[^\]]+\]\s*$/.test(line))
1941
- skipping = isCpacTableHeader(line);
1942
- if (!skipping)
1943
- out.push(line);
1944
- }
1945
- return out.join(eol).replace(/(?:\r?\n){3,}/g, `${eol}${eol}`);
1946
- }
1947
- function writeKimiBlock(path, block) {
1948
- let content = existsSync(path) ? readFileSync(path, "utf8") : "";
1949
- content = content.replace(kimiBlockRegex, "");
1950
- // A truncated managed block (start marker without the end marker) leaves its
1951
- // tables behind; appending again would duplicate [providers.cpac], make the
1952
- // file invalid TOML, and block `kimi login`. The block is always appended
1953
- // last, so dropping from an orphaned start marker to EOF is safe.
1954
- const orphan = content.indexOf(KIMI_BLOCK_START);
1955
- if (orphan !== -1)
1956
- content = content.slice(0, orphan);
1957
- content = stripOrphanCpacTables(content);
1958
- if (block) {
1959
- content = `${content}${content && !content.endsWith("\n") ? "\n" : ""}${block}\n`;
1960
- }
1961
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1962
- const mode = existsSync(path) ? statSync(path).mode & 0o7777 : 0o600;
1963
- atomicWrite(path, Buffer.from(content), mode);
1964
- }
1965
- const KIMI_REASONING_EFFORTS = new Set([
1966
- "minimal",
1967
- "low",
1968
- "medium",
1969
- "high",
1970
- "xhigh",
1971
- "max",
1972
- "ultra",
1973
- ]);
1974
- function kimiSupportEfforts(row) {
1975
- const levels = row.supported_reasoning_levels ??
1976
- row.reasoning_effort_levels ??
1977
- row.reasoning_levels;
1978
- if (!Array.isArray(levels))
1979
- return [];
1980
- const seen = new Set();
1981
- const efforts = [];
1982
- for (const level of levels) {
1983
- const effort = typeof level === "string"
1984
- ? level.toLowerCase()
1985
- : objectValue(level) && typeof level.effort === "string"
1986
- ? level.effort.toLowerCase()
1987
- : undefined;
1988
- if (!effort || !KIMI_REASONING_EFFORTS.has(effort) || seen.has(effort))
1989
- continue;
1990
- seen.add(effort);
1991
- efforts.push(effort);
1992
- }
1993
- return efforts;
1994
- }
1995
- function kimiInputHasImage(row) {
1996
- const modalities = row.input_modalities;
1997
- if (!Array.isArray(modalities))
1998
- return true;
1999
- return modalities.some((value) => value === "image");
2000
- }
2001
- export async function installKimiConfig(config) {
2002
- const apiKey = await resolveApiKey(config.api_key_env);
2003
- const catalog = await fetchCatalog(config.cpa_url, apiKey);
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
- }
2011
- const rows = catalogModelRows(document) ?? [];
2012
- if (rows.length === 0)
2013
- throw new CPACError("CPA catalog contains no models");
2014
- const state = readState(config.state_dir);
2015
- const recorded = state ? stateProxy(state) : null;
2016
- const port = recorded?.port ?? config.codex_proxy_port;
2017
- if (!port) {
2018
- throw new CPACError("loopback proxy port unknown; run cpac inject first");
2019
- }
2020
- const lines = [
2021
- KIMI_BLOCK_START,
2022
- "[providers.cpac]",
2023
- 'type = "openai"',
2024
- `base_url = "http://127.0.0.1:${port}/v1"`,
2025
- '# Placeholder: the CPAC loopback proxy replaces it with the CPA key; run "cpac inject" to start the proxy.',
2026
- 'api_key = "cpac-loopback"',
2027
- ];
2028
- for (const row of rows) {
2029
- const slug = catalogModelId(row);
2030
- if (!slug)
2031
- continue;
2032
- // ponytail: Kimi requires max_context_size; default 200000 when the catalog omits it.
2033
- const context = typeof row.context_window === "number" && row.context_window > 0
2034
- ? Math.floor(row.context_window)
2035
- : 200000;
2036
- const efforts = kimiSupportEfforts(row);
2037
- const capabilities = [
2038
- ...(efforts.length > 0 ? ["thinking"] : []),
2039
- "tool_use",
2040
- ...(kimiInputHasImage(row) ? ["image_in"] : []),
2041
- ];
2042
- lines.push("", `[models."cpac/${slug}"]`, 'provider = "cpac"', `model = ${tomlString(slug)}`, `max_context_size = ${context}`, `capabilities = ${tomlStringArray(capabilities)}`);
2043
- if (typeof row.display_name === "string" && row.display_name.trim()) {
2044
- lines.push(`display_name = ${tomlString(row.display_name)}`);
2045
- }
2046
- if (efforts.length > 0) {
2047
- lines.push(`support_efforts = ${tomlStringArray(efforts)}`);
2048
- const rawDefaultEffort = (typeof row.default_reasoning_level === "string" &&
2049
- row.default_reasoning_level) ||
2050
- (typeof row.default_reasoning_effort === "string" &&
2051
- row.default_reasoning_effort) ||
2052
- (typeof row.default_effort === "string" && row.default_effort);
2053
- const defaultEffort = rawDefaultEffort && efforts.includes(rawDefaultEffort.toLowerCase())
2054
- ? rawDefaultEffort.toLowerCase()
2055
- : undefined;
2056
- if (defaultEffort)
2057
- lines.push(`default_effort = ${tomlString(defaultEffort)}`);
2058
- }
2059
- }
2060
- lines.push(KIMI_BLOCK_END);
2061
- const target = kimiConfigPath();
2062
- ensureCpacBackup(target);
2063
- writeKimiBlock(target, lines.join("\n"));
2064
- console.log(`Installed Kimi Code provider config: ${target}`);
2065
- if (!recorded || !(await proxyIsHealthy(recorded))) {
2066
- console.log("Loopback proxy is not running; run: cpac inject");
2067
- }
2068
- }
2069
- export async function uninstallKimiConfig() {
2070
- const target = kimiConfigPath();
2071
- if (!isKimiConfigInstalled())
2072
- throw new CPACError("Kimi Code config is not installed");
2073
- writeKimiBlock(target, null);
2074
- console.log(`Removed Kimi Code provider config: ${target}`);
2075
- }
2076
- function grokHome() {
2077
- const home = process.env.GROK_HOME?.trim() || join(homedir(), ".grok");
2078
- return expandUserPath(home);
2079
- }
2080
- function grokConfigPath() {
2081
- return join(grokHome(), "config.toml");
2082
- }
2083
- const GROK_BLOCK_START = "# >>> CPAC Grok >>>";
2084
- const GROK_BLOCK_END = "# <<< CPAC Grok <<<";
2085
- const grokBlockRegex = new RegExp(`${GROK_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${GROK_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`);
2086
- export function isGrokConfigInstalled() {
2087
- try {
2088
- const content = readFileSync(grokConfigPath(), "utf8");
2089
- return (grokBlockRegex.test(content) ||
2090
- /^\s*\[model\.(?:"cpac\/|'cpac\/|cpac-)/m.test(content));
2091
- }
2092
- catch {
2093
- return false;
2094
- }
2095
- }
2096
- function isCpacGrokTableHeader(line) {
2097
- const header = line.match(/^\[([^\]]+)\]\s*$/);
2098
- if (!header)
2099
- return false;
2100
- const name = header[1];
2101
- return /^model\.(?:"cpac\/|'cpac\/|cpac-)/.test(name);
2102
- }
2103
- function stripOrphanCpacGrokTables(content) {
2104
- const eol = content.includes("\r\n") ? "\r\n" : "\n";
2105
- const out = [];
2106
- let skipping = false;
2107
- for (const line of content.split(/\r?\n/)) {
2108
- if (/^\[[^\]]+\]\s*$/.test(line))
2109
- skipping = isCpacGrokTableHeader(line);
2110
- if (!skipping)
2111
- out.push(line);
2112
- }
2113
- return out.join(eol).replace(/(?:\r?\n){3,}/g, `${eol}${eol}`);
2114
- }
2115
- function writeGrokBlock(path, block) {
2116
- let content = existsSync(path) ? readFileSync(path, "utf8") : "";
2117
- content = content.replace(grokBlockRegex, "");
2118
- const orphan = content.indexOf(GROK_BLOCK_START);
2119
- if (orphan !== -1)
2120
- content = content.slice(0, orphan);
2121
- content = stripOrphanCpacGrokTables(content);
2122
- if (block) {
2123
- content = `${content}${content && !content.endsWith("\n") ? "\n" : ""}${block}\n`;
2124
- }
2125
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
2126
- const mode = existsSync(path) ? statSync(path).mode & 0o7777 : 0o600;
2127
- atomicWrite(path, Buffer.from(content), mode);
2128
- }
2129
- export async function installGrokConfig(config) {
2130
- const apiKey = await resolveApiKey(config.api_key_env);
2131
- const catalog = await fetchCatalog(config.cpa_url, apiKey);
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
- }
2139
- const rows = catalogModelRows(document) ?? [];
2140
- if (rows.length === 0)
2141
- throw new CPACError("CPA catalog contains no models");
2142
- const state = readState(config.state_dir);
2143
- const recorded = state ? stateProxy(state) : null;
2144
- const port = recorded?.port ?? config.codex_proxy_port;
2145
- if (!port) {
2146
- throw new CPACError("loopback proxy port unknown; run cpac inject first");
2147
- }
2148
- const lines = [GROK_BLOCK_START];
2149
- for (const row of rows) {
2150
- const slug = catalogModelId(row);
2151
- if (!slug)
2152
- continue;
2153
- const context = typeof row.context_window === "number" && row.context_window > 0
2154
- ? Math.floor(row.context_window)
2155
- : 200000;
2156
- const name = typeof row.display_name === "string" && row.display_name.trim()
2157
- ? `${row.display_name.trim()} (CPAC)`
2158
- : `CPAC ${slug}`;
2159
- lines.push(`[model."cpac/${slug}"]`, `model = ${tomlString(slug)}`, `base_url = "http://127.0.0.1:${port}/v1"`, 'api_backend = "responses"', '# Placeholder: the CPAC loopback proxy replaces it with the CPA key; run "cpac inject" to start the proxy.', 'api_key = "cpac-loopback"', `name = ${tomlString(name)}`, `context_window = ${context}`, "");
2160
- }
2161
- if (lines[lines.length - 1] === "")
2162
- lines.pop();
2163
- lines.push(GROK_BLOCK_END);
2164
- const target = grokConfigPath();
2165
- ensureCpacBackup(target);
2166
- writeGrokBlock(target, lines.join("\n"));
2167
- console.log(`Installed Grok Build provider config: ${target}`);
2168
- if (!recorded || !(await proxyIsHealthy(recorded))) {
2169
- console.log("Loopback proxy is not running; run: cpac inject");
2170
- }
2171
- }
2172
- export async function uninstallGrokConfig() {
2173
- const target = grokConfigPath();
2174
- if (!isGrokConfigInstalled())
2175
- throw new CPACError("Grok Build config is not installed");
2176
- writeGrokBlock(target, null);
2177
- console.log(`Removed Grok Build provider config: ${target}`);
2178
- }
2179
- function readPackageVersion() {
2180
- try {
2181
- const raw = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8");
2182
- const version = JSON.parse(raw).version;
2183
- return typeof version === "string" ? version : "0.0.0-dev";
2184
- }
2185
- catch {
2186
- return "0.0.0-dev";
2187
- }
2188
- }
2189
- const CPAC_VERSION = readPackageVersion();
2190
- export function detectClientVersion(binary) {
2191
- if (!/^[\w.-]+$/.test(binary))
2192
- return undefined;
2193
- try {
2194
- const res = spawnSync(binary, ["--version"], {
2195
- encoding: "utf8",
2196
- timeout: 2000,
2197
- stdio: ["ignore", "pipe", "ignore"],
2198
- });
2199
- if (res.status === 0 && typeof res.stdout === "string") {
2200
- const match = res.stdout.match(/v?(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
2201
- return match ? match[1] : res.stdout.trim().split(/\s+/)[0] || undefined;
2202
- }
2203
- }
2204
- catch {
2205
- // ignored
2206
- }
2207
- return undefined;
2208
- }
2209
- function binaryOnPath(name) {
2210
- if (!/^[\w.-]+$/.test(name))
2211
- return false;
2212
- try {
2213
- return (spawnSync(`command -v ${name}`, { stdio: "ignore", shell: true }).status ===
2214
- 0);
2215
- }
2216
- catch {
2217
- return false;
2218
- }
2219
- }
2220
- const TARGETS = [
2221
- {
2222
- id: "codex",
2223
- home: () => expandUserPath(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex")),
2224
- detected: function () {
2225
- return existsSync(this.home());
2226
- },
2227
- installed: (config) => {
2228
- try {
2229
- return readFileSync(config.codex_config, "utf8").includes(MANAGED_MARKER);
2230
- }
2231
- catch {
2232
- return false;
2233
- }
2234
- },
2235
- version: () => detectClientVersion("codex"),
2236
- install: async (config, dryRun, v2Off, maxContext) => {
2237
- if (dryRun) {
2238
- console.log("would inject CPA catalog and loopback proxy into Codex");
2239
- return;
2240
- }
2241
- await inject(config, v2Off, maxContext);
2242
- },
2243
- uninstall: async (config, dryRun) => {
2244
- if (dryRun) {
2245
- console.log("would restore the original Codex config");
2246
- return;
2247
- }
2248
- await restore(config);
2249
- },
2250
- },
2251
- {
2252
- id: "pi",
2253
- home: () => piExtensionsDir(),
2254
- detected: () => binaryOnPath("pi") || existsSync(piExtensionsDir()),
2255
- installed: () => isPiExtensionInstalled(),
2256
- version: () => detectClientVersion("pi"),
2257
- install: async (config, dryRun) => {
2258
- if (dryRun) {
2259
- console.log(`would write Pi extension: ${join(piExtensionsDir(), "cpac.ts")}`);
2260
- return;
2261
- }
2262
- await installPiExtension(config);
2263
- },
2264
- uninstall: async (_config, dryRun) => {
2265
- if (dryRun) {
2266
- console.log(`would remove Pi extension: ${join(piExtensionsDir(), "cpac.ts")}`);
2267
- return;
2268
- }
2269
- await uninstallPiExtension();
2270
- },
2271
- },
2272
- {
2273
- id: "kimi",
2274
- home: () => dirname(kimiConfigPath()),
2275
- detected: () => binaryOnPath("kimi") || existsSync(dirname(kimiConfigPath())),
2276
- installed: () => isKimiConfigInstalled(),
2277
- version: () => detectClientVersion("kimi"),
2278
- install: async (config, dryRun) => {
2279
- if (dryRun) {
2280
- console.log(`would write CPA provider block: ${kimiConfigPath()}`);
2281
- return;
2282
- }
2283
- await installKimiConfig(config);
2284
- },
2285
- uninstall: async (_config, dryRun) => {
2286
- if (dryRun) {
2287
- console.log(`would remove CPA provider block: ${kimiConfigPath()}`);
2288
- return;
2289
- }
2290
- await uninstallKimiConfig();
2291
- },
2292
- },
2293
- {
2294
- id: "grok",
2295
- home: () => grokHome(),
2296
- detected: () => binaryOnPath("grok") || existsSync(grokHome()),
2297
- installed: () => isGrokConfigInstalled(),
2298
- version: () => detectClientVersion("grok"),
2299
- install: async (config, dryRun) => {
2300
- if (dryRun) {
2301
- console.log(`would write CPA provider block: ${grokConfigPath()}`);
2302
- return;
2303
- }
2304
- await installGrokConfig(config);
2305
- },
2306
- uninstall: async (_config, dryRun) => {
2307
- if (dryRun) {
2308
- console.log(`would remove CPA provider block: ${grokConfigPath()}`);
2309
- return;
2310
- }
2311
- await uninstallGrokConfig();
2312
- },
2313
- },
2314
- ];
2315
- export async function detectTargets(config) {
2316
- return TARGETS.map((target) => ({
2317
- id: target.id,
2318
- detected: target.detected(),
2319
- installed: target.installed(config),
2320
- path: target.home(),
2321
- version: target.version?.(),
2322
- }));
2323
- }
2324
- export async function runDetect(config, asJson) {
2325
- const targets = await detectTargets(config);
2326
- if (asJson) {
2327
- console.log(JSON.stringify({ version: CPAC_VERSION, targets }, null, 2));
2328
- return 0;
2329
- }
2330
- for (const target of targets) {
2331
- console.log(`${target.id.padEnd(8)} ${(target.detected ? "detected" : "missing").padEnd(9)} ${(target.installed ? "installed" : "not installed").padEnd(14)} ${target.path}`);
2332
- }
2333
- return 0;
2334
- }
2335
- function selectTargets(requested, all, eligible) {
2336
- const unknown = requested.filter((id) => !TARGETS.some((target) => target.id === id));
2337
- if (unknown.length > 0) {
2338
- throw new CPACError(`unknown target(s): ${unknown.join(", ")}; use ${TARGETS.map((target) => target.id).join(",")}`);
2339
- }
2340
- const ids = requested.length > 0
2341
- ? requested
2342
- : all
2343
- ? TARGETS.map((target) => target.id)
2344
- : eligible;
2345
- return TARGETS.filter((target) => ids.includes(target.id));
2346
- }
2347
- export async function runInstall(config, requested, options) {
2348
- const eligible = TARGETS.filter((target) => target.detected()).map((target) => target.id);
2349
- const selected = selectTargets(requested, options.all, eligible);
2350
- if (selected.length === 0) {
2351
- throw new CPACError(`no supported targets detected; use --target ${TARGETS.map((target) => target.id).join(",")} or --all`);
2352
- }
2353
- // Catalog context lifting is Codex-specific; refuse to silently skip it on
2354
- // other targets so the flag never gains cross-agent meaning.
2355
- if (options.maxContext && selected.some((target) => target.id !== "codex")) {
2356
- throw new CPACError("--max_context is only valid with --target codex");
2357
- }
2358
- for (const target of selected) {
2359
- if (target.installed(config) && !options.force) {
2360
- console.log(`${target.id}: already installed; use --force to reinstall`);
2361
- continue;
2362
- }
2363
- await target.install(config, options.dryRun, options.v2Off, options.maxContext);
2364
- }
2365
- return 0;
2366
- }
2367
- export async function runSync(config, requested, options) {
2368
- const installed = TARGETS.filter((target) => target.installed(config)).map((target) => target.id);
2369
- if (requested.length === 0 && !options.all) {
2370
- if (installed.length === 0) {
2371
- throw new CPACError("no installed CPAC integrations found; run cpac install first");
2372
- }
2373
- return runInstall(config, installed, {
2374
- all: false,
2375
- dryRun: options.dryRun,
2376
- force: true,
2377
- v2Off: options.v2Off,
2378
- maxContext: options.maxContext,
2379
- });
2380
- }
2381
- return runInstall(config, requested, {
2382
- all: options.all,
2383
- dryRun: options.dryRun,
2384
- force: true,
2385
- v2Off: options.v2Off,
2386
- maxContext: options.maxContext,
2387
- });
2388
- }
2389
- export async function runUninstall(config, requested, options) {
2390
- const eligible = TARGETS.filter((target) => target.installed(config)).map((target) => target.id);
2391
- const selected = selectTargets(requested, options.all, eligible);
2392
- if (selected.length === 0) {
2393
- throw new CPACError("no installed CPAC integrations found; use --target <id> or --all");
2394
- }
2395
- for (const target of selected) {
2396
- if (!target.installed(config)) {
2397
- console.log(`${target.id}: not installed`);
2398
- continue;
2399
- }
2400
- await target.uninstall(config, options.dryRun);
2401
- }
2402
- return 0;
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
- }
2415
- function compareVersions(a, b) {
2416
- const left = a.split(".").map(Number);
2417
- const right = b.split(".").map(Number);
2418
- for (let index = 0; index < Math.max(left.length, right.length); index++) {
2419
- const l = left[index] ?? 0;
2420
- const r = right[index] ?? 0;
2421
- if (l !== r)
2422
- return l > r ? 1 : -1;
2423
- }
2424
- return 0;
2425
- }
2426
- export async function runUpgrade(config, checkOnly) {
2427
- console.log(`Current version: ${CPAC_VERSION}`);
2428
- let latest;
2429
- try {
2430
- const response = await fetch("https://registry.npmjs.org/@yhong91/cpac/latest", {
2431
- headers: { Accept: "application/json" },
2432
- signal: AbortSignal.timeout(10_000),
2433
- });
2434
- if (response.ok) {
2435
- const data = (await response.json());
2436
- if (typeof data.version === "string")
2437
- latest = data.version;
2438
- }
2439
- }
2440
- catch {
2441
- latest = undefined;
2442
- }
2443
- if (!latest) {
2444
- console.error("Could not fetch latest version from npm. Check your network connection.");
2445
- return 1;
2446
- }
2447
- console.log(`Latest version: ${latest}`);
2448
- const upToDate = compareVersions(CPAC_VERSION, latest) >= 0;
2449
- if (checkOnly) {
2450
- if (upToDate)
2451
- console.log("Already up to date.");
2452
- else
2453
- console.log("Update available. Run: cpac upgrade");
2454
- return 0;
2455
- }
2456
- if (upToDate) {
2457
- console.log("Already up to date.");
2458
- }
2459
- else {
2460
- console.log(`Installing @yhong91/cpac@${latest}`);
2461
- const result = spawnSync("npm", ["install", "-g", `@yhong91/cpac@${latest}`], { stdio: "inherit" });
2462
- if (result.status !== 0) {
2463
- console.error("npm install failed");
2464
- return result.status ?? 1;
2465
- }
2466
- }
2467
- const installed = TARGETS.filter((target) => target.installed(config)).map((target) => target.id);
2468
- if (installed.length === 0) {
2469
- console.log("No installed agents to sync.");
2470
- return 0;
2471
- }
2472
- console.log(`Syncing ${installed.join(", ")}`);
2473
- return runSync(config, [], { all: false, dryRun: false });
2474
- }
2475
77
  function usage() {
2476
78
  return [
2477
79
  "Usage: cpac",