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