@yhong91/cpac 0.1.26 → 0.1.28

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/config.js CHANGED
@@ -31,6 +31,9 @@ const STATE_KEYS = new Set([
31
31
  "proxy_fingerprint",
32
32
  "proxy_pid",
33
33
  "proxy_port",
34
+ "injected_config_hash",
35
+ "injected_openai_base_url",
36
+ "injected_catalog_path",
34
37
  ]);
35
38
  export const STATE_FILES = [
36
39
  "state.json",
@@ -388,6 +391,21 @@ export function readState(stateDir) {
388
391
  !/^[a-f0-9]{64}$/.test(value.proxy_fingerprint))) {
389
392
  throw new CPACError("invalid loopback proxy fingerprint");
390
393
  }
394
+ if (value.injected_config_hash !== undefined &&
395
+ (typeof value.injected_config_hash !== "string" ||
396
+ !/^[a-f0-9]{64}$/.test(value.injected_config_hash))) {
397
+ throw new CPACError("invalid injected_config_hash in state");
398
+ }
399
+ if (value.injected_openai_base_url !== undefined &&
400
+ (typeof value.injected_openai_base_url !== "string" ||
401
+ !value.injected_openai_base_url.trim())) {
402
+ throw new CPACError("invalid injected_openai_base_url in state");
403
+ }
404
+ if (value.injected_catalog_path !== undefined &&
405
+ (typeof value.injected_catalog_path !== "string" ||
406
+ !value.injected_catalog_path.trim())) {
407
+ throw new CPACError("invalid injected_catalog_path in state");
408
+ }
391
409
  return value;
392
410
  }
393
411
  export function stateProxy(state) {
@@ -395,7 +413,21 @@ export function stateProxy(state) {
395
413
  ? { id: state.proxy_id, pid: state.proxy_pid, port: state.proxy_port }
396
414
  : null;
397
415
  }
398
- export function stateBytes(config, existed, mode, proxy, proxyFingerprint) {
416
+ export function injectedOwnership(state) {
417
+ if (!state?.injected_config_hash ||
418
+ !state.injected_openai_base_url ||
419
+ !state.injected_catalog_path)
420
+ return undefined;
421
+ return {
422
+ hash: state.injected_config_hash,
423
+ openaiBaseUrl: state.injected_openai_base_url,
424
+ catalogPath: state.injected_catalog_path,
425
+ };
426
+ }
427
+ export function sha256Hex(bytes) {
428
+ return createHash("sha256").update(bytes).digest("hex");
429
+ }
430
+ export function stateBytes(config, existed, mode, proxy, proxyFingerprint, injected) {
399
431
  return Buffer.from(`${JSON.stringify({
400
432
  config_path: config.codex_config,
401
433
  config_existed: existed,
@@ -404,6 +436,13 @@ export function stateBytes(config, existed, mode, proxy, proxyFingerprint) {
404
436
  proxy_fingerprint: proxyFingerprint,
405
437
  proxy_pid: proxy.pid,
406
438
  proxy_port: proxy.port,
439
+ ...(injected
440
+ ? {
441
+ injected_config_hash: injected.hash,
442
+ injected_openai_base_url: injected.openaiBaseUrl,
443
+ injected_catalog_path: injected.catalogPath,
444
+ }
445
+ : {}),
407
446
  }, null, 2)}\n`);
408
447
  }
409
448
  export function originalBytes(stateDir, state, expectedPath) {
package/dist/cpac.js CHANGED
@@ -3,18 +3,26 @@ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
3
  import { createHash } from "node:crypto";
4
4
  import { resolve } from "node:path";
5
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";
6
+ import { CPAC_VERSION, runCodexConfig, runDetect, runInstall, runRestore, runSync, runTargetLauncher, runUninstall, runUpgrade, } from "./agents.js";
7
+ import { runClaude, runClaudeConfig, runClaudeModels, } from "./targets/claude.js";
8
+ import { inject, multiAgentV2Enabled } from "./targets/codex.js";
9
+ import { isGrokConfigInstalled } from "./targets/grok.js";
10
+ import { isKimiConfigInstalled } from "./targets/kimi.js";
11
+ import { runOpencode } from "./targets/opencode.js";
12
+ import { isPiExtensionInstalled } from "./targets/pi.js";
9
13
  import { defaultConfigPath, loadConfig, originalBytes, pickSpawnModels, readState, saveSpawnModels, stateProxy, } from "./config.js";
10
14
  import { proxyIsHealthy, runProxy, runProxyChild } from "./proxy.js";
11
15
  import { CPACError, expandUserPath, promptSecret, saveApiKeyExport, shellProfile, } from "./util.js";
12
16
  export { PROVIDER, loadConfig, saveSpawnModels, liftContextWindows, reorderCatalog, readState, stateProxy, } from "./config.js";
13
17
  export { saveApiKeyExport } from "./util.js";
14
18
  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";
19
+ export { buildCodexConfig, hasProviderTable, inject, multiAgentV2Enabled, restore, withMultiAgentV2, } from "./targets/codex.js";
20
+ export { claudeTierSlots, createClaudeProxy, runClaude, } from "./targets/claude.js";
21
+ export { detectClientVersion, detectTargets, runInstall, runRestore, runSync, runTargetLauncher, } from "./agents.js";
22
+ export { installGrokConfig, isGrokConfigInstalled, uninstallGrokConfig, } from "./targets/grok.js";
23
+ export { installKimiConfig, isKimiConfigInstalled, uninstallKimiConfig, } from "./targets/kimi.js";
24
+ export { runOpencode } from "./targets/opencode.js";
25
+ export { installPiExtension, isPiExtensionInstalled, uninstallPiExtension, } from "./targets/pi.js";
18
26
  export async function status(config) {
19
27
  const apiKey = process.env[config.api_key_env]?.trim();
20
28
  const keyConfigured = !!apiKey;
@@ -62,7 +70,11 @@ export async function status(config) {
62
70
  return 0;
63
71
  }
64
72
  async function guide(config) {
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`);
73
+ 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 --target <id> | --all Detach injected agents
74
+ cpac detect Show supported targets and install status
75
+ cpac install Install CPA integration into detected targets
76
+ cpac sync Refresh installed Codex/Pi/Kimi/Grok injections from CPA
77
+ cpac uninstall Detach all agents, then remove the cpac package (confirms first)\n cpac upgrade Update cpac from npm and sync installed agents\n cpac --help Show command usage`);
66
78
  if (process.env[config.api_key_env]?.trim())
67
79
  return 0;
68
80
  const apiKey = await promptSecret(config.api_key_env);
@@ -78,20 +90,21 @@ function usage() {
78
90
  return [
79
91
  "Usage: cpac",
80
92
  " cpac <codex|kimi|grok|pi|claude|opencode> [args...]",
81
- " cpac <codex|claude> setup",
93
+ " cpac <codex|claude> --setup [args...]",
82
94
  " cpac <codex|claude|kimi|grok|pi> clear",
83
95
  " cpac inject [--v2_off] [--v2_models] [--max_context] [--config PATH]",
84
- " cpac restore [--target codex,pi,kimi,grok] [--all] [--dry-run] [--config PATH]",
96
+ " cpac restore --target <id> | --all [--dry-run] [--config PATH]",
85
97
  " cpac status [--config PATH]",
86
98
  " cpac proxy [--config PATH]",
87
99
  " cpac detect [--json] [--home PATH]",
88
100
  " cpac install [--target codex,pi,kimi,grok] [--all] [--dry-run] [--force] [--v2_off] [--v2_models] [--max_context] [--home PATH]",
89
101
  " cpac sync [--target codex,pi,kimi,grok] [--all] [--dry-run] [--v2_off] [--v2_models] [--max_context] [--home PATH]",
102
+ " cpac uninstall [--dry-run]",
90
103
  " cpac upgrade [--check]",
91
- " cpac version",
104
+ " cpac -v | --version",
92
105
  "",
93
106
  "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`.",
107
+ "configure then launch with `cpac <codex|claude> --setup`; restore with `cpac <agent> clear`.",
95
108
  ].join("\n");
96
109
  }
97
110
  function parseArgs(args) {
@@ -103,8 +116,7 @@ function parseArgs(args) {
103
116
  args[0] === "sync" ||
104
117
  args[0] === "uninstall" ||
105
118
  args[0] === "restore" ||
106
- args[0] === "upgrade" ||
107
- args[0] === "version") {
119
+ args[0] === "upgrade") {
108
120
  const parsed = {
109
121
  command: args[0],
110
122
  configPath: defaultConfigPath(),
@@ -169,10 +181,34 @@ function parseArgs(args) {
169
181
  if (["codex", "kimi", "grok", "pi", "claude", "opencode"].includes(args[0])) {
170
182
  const targetId = args[0];
171
183
  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];
184
+ const raw = args.slice(1);
185
+ let setup = false;
186
+ let clear = false;
187
+ const rest = [];
188
+ for (let i = 0; i < raw.length; i++) {
189
+ const arg = raw[i];
190
+ if (i === 0 && arg === "setup") {
191
+ setup = true;
192
+ continue;
193
+ }
194
+ if (i === 0 && arg === "clear") {
195
+ clear = true;
196
+ continue;
197
+ }
198
+ if (arg === "--setup") {
199
+ setup = true;
200
+ continue;
201
+ }
202
+ if (arg === "--clear") {
203
+ clear = true;
204
+ continue;
205
+ }
206
+ rest.push(arg);
207
+ }
208
+ if (clear) {
209
+ for (let i = 0; i < rest.length; i++) {
210
+ if (rest[i] === "--config") {
211
+ const val = rest[++i];
176
212
  if (val)
177
213
  configPath = resolve(expandUserPath(val));
178
214
  }
@@ -201,13 +237,17 @@ function parseArgs(args) {
201
237
  maxContext: false,
202
238
  };
203
239
  }
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));
240
+ if (setup) {
241
+ const launchArgs = [];
242
+ for (let i = 0; i < rest.length; i++) {
243
+ if (rest[i] === "--config") {
244
+ const val = rest[++i];
245
+ if (!val)
246
+ throw new CPACError("--config requires a path");
247
+ configPath = resolve(expandUserPath(val));
248
+ continue;
210
249
  }
250
+ launchArgs.push(rest[i]);
211
251
  }
212
252
  if (targetId === "codex") {
213
253
  return {
@@ -218,6 +258,8 @@ function parseArgs(args) {
218
258
  maxContext: false,
219
259
  reset: false,
220
260
  interactive: true,
261
+ launch: true,
262
+ args: launchArgs,
221
263
  };
222
264
  }
223
265
  if (targetId === "claude") {
@@ -228,11 +270,13 @@ function parseArgs(args) {
228
270
  pick: [],
229
271
  reset: false,
230
272
  interactive: true,
273
+ launch: true,
274
+ args: launchArgs,
231
275
  };
232
276
  }
233
277
  throw new CPACError(`${targetId} does not require setup (all CPA models are auto-synced). Run 'cpac ${targetId}' or 'cpac install --target ${targetId}'.`);
234
278
  }
235
- const passArgs = args.slice(1);
279
+ const passArgs = rest;
236
280
  if (targetId === "codex") {
237
281
  return {
238
282
  command: "codex",
@@ -372,6 +416,10 @@ export async function main(args = process.argv.slice(2)) {
372
416
  return 1;
373
417
  return await runProxyChild(port);
374
418
  }
419
+ if (args[0] === "--version" || args[0] === "-v") {
420
+ console.log(CPAC_VERSION);
421
+ return 0;
422
+ }
375
423
  const parsed = parseArgs(args);
376
424
  if (!parsed)
377
425
  return 0;
@@ -413,34 +461,48 @@ export async function main(args = process.argv.slice(2)) {
413
461
  maxContext: parsed.maxContext,
414
462
  });
415
463
  }
416
- if (parsed.command === "uninstall" || parsed.command === "restore")
464
+ if (parsed.command === "restore")
417
465
  return await runRestore(config, parsed.targets, {
418
466
  all: parsed.all,
419
467
  dryRun: parsed.dryRun,
420
468
  });
469
+ if (parsed.command === "uninstall") {
470
+ if (parsed.targets.length || parsed.all) {
471
+ throw new CPACError("uninstall removes the cpac package; detach agents with: cpac restore --target <id> | --all");
472
+ }
473
+ return await runUninstall(config, { dryRun: parsed.dryRun });
474
+ }
421
475
  if (parsed.command === "upgrade")
422
476
  return await runUpgrade(config, parsed.check);
423
- if (parsed.command === "version") {
424
- console.log(CPAC_VERSION);
425
- return 0;
426
- }
427
477
  if (parsed.command === "claude")
428
478
  return await runClaude(config, parsed.args);
429
- if (parsed.command === "claude-config")
430
- return await runClaudeConfig(config, parsed.configPath, {
479
+ if (parsed.command === "claude-config") {
480
+ const code = await runClaudeConfig(config, parsed.configPath, {
431
481
  models: parsed.models,
432
482
  pick: parsed.pick,
433
483
  reset: parsed.reset,
434
484
  interactive: parsed.interactive,
435
485
  });
436
- if (parsed.command === "codex-config")
437
- return await runCodexConfig(config, parsed.configPath, {
486
+ if (code !== 0 || !parsed.launch)
487
+ return code;
488
+ if (!process.stdin.isTTY || !process.stderr.isTTY)
489
+ return code;
490
+ return await runClaude(loadConfig(parsed.configPath, true), parsed.args ?? []);
491
+ }
492
+ if (parsed.command === "codex-config") {
493
+ const code = await runCodexConfig(config, parsed.configPath, {
438
494
  v2Off: parsed.v2Off,
439
495
  v2Models: parsed.v2Models,
440
496
  maxContext: parsed.maxContext,
441
497
  reset: parsed.reset,
442
498
  interactive: parsed.interactive,
443
499
  });
500
+ if (code !== 0 || !parsed.launch)
501
+ return code;
502
+ if (!process.stdin.isTTY || !process.stderr.isTTY)
503
+ return code;
504
+ return await runTargetLauncher(loadConfig(parsed.configPath, true), "codex", parsed.args ?? [], "codex", { skipSync: true });
505
+ }
444
506
  if (parsed.command === "opencode")
445
507
  return await runOpencode(config, parsed.args);
446
508
  if (parsed.command === "codex") {
package/dist/proxy.js CHANGED
@@ -5,7 +5,7 @@ import { createServer, request as httpRequest, } from "node:http";
5
5
  import { request as httpsRequest } from "node:https";
6
6
  import { join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
- import { apiBase, originalBytes, proxyFingerprint, readState, stateBytes, stateProxy, } from "./config.js";
8
+ import { apiBase, injectedOwnership, originalBytes, proxyFingerprint, readState, stateBytes, stateProxy, } from "./config.js";
9
9
  import { CPACError, atomicWrite, objectValue, resolveApiKey } from "./util.js";
10
10
  const HOP_BY_HOP_HEADERS = new Set([
11
11
  "connection",
@@ -322,7 +322,7 @@ export async function runProxy(config) {
322
322
  }
323
323
  const proxy = await createLoopbackProxy(config.cpa_url, apiKey, recorded.id, recorded.port);
324
324
  try {
325
- 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)));
325
+ 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), injectedOwnership(state)));
326
326
  }
327
327
  catch (error) {
328
328
  proxy.server.closeAllConnections?.();
@@ -2,9 +2,9 @@ import { spawn } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { createServer, request as httpRequest, } from "node:http";
4
4
  import { request as httpsRequest } from "node:https";
5
- import { apiBase, catalogModelId, catalogModelRows, fetchCatalog, loadConfig, } from "./config.js";
6
- import { proxyHeaders, responseHeaders, upstreamUrl } from "./proxy.js";
7
- import { CPACError, atomicWrite, checkboxPicker, objectValue, resolveApiKey, } from "./util.js";
5
+ import { apiBase, catalogModelId, catalogModelRows, fetchCatalog, loadConfig, } from "../config.js";
6
+ import { proxyHeaders, responseHeaders, upstreamUrl } from "../proxy.js";
7
+ import { CPACError, atomicWrite, checkboxPicker, objectValue, resolveApiKey, } from "../util.js";
8
8
  const CLAUDE_ALIAS_PREFIX = "claude-cpac--";
9
9
  // Claude Code accepts CLAUDE_CODE_AUTO_COMPACT_WINDOW in 100K–1M (binary-verified).
10
10
  // A single global window cannot be per-model; 350K is opencodex's user-approved
@@ -1,13 +1,44 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { PROVIDER, STATE_FILES, cleanupStateFiles, fetchCatalog, liftContextWindows, originalBytes, proxyFingerprint, readState, reorderCatalog, stateBytes, stateProxy, } from "./config.js";
4
- import { proxyIsHealthy, startProxyProcess, stopProxyProcess, } from "./proxy.js";
5
- import { CPACError, atomicWrite, dominantEol, tomlString } from "./util.js";
3
+ import { PROVIDER, STATE_FILES, cleanupStateFiles, fetchCatalog, injectedOwnership, liftContextWindows, originalBytes, proxyFingerprint, readState, reorderCatalog, sha256Hex, stateBytes, stateProxy, } from "../config.js";
4
+ import { proxyIsHealthy, startProxyProcess, stopProxyProcess, } from "../proxy.js";
5
+ import { CPACError, atomicWrite, dominantEol, tomlString } from "../util.js";
6
6
  const MODEL_PROVIDER_KEY = /^\s*(?:model_provider|"model_provider"|'model_provider')\s*=/;
7
7
  const MODEL_CATALOG_KEY = /^\s*(?:model_catalog_json|"model_catalog_json"|'model_catalog_json')\s*=/;
8
8
  const OPENAI_BASE_URL_KEY = /^\s*(?:openai_base_url|"openai_base_url"|'openai_base_url')\s*=/;
9
9
  const ROOT_KEY = new RegExp(`(?:${MODEL_PROVIDER_KEY.source}|${MODEL_CATALOG_KEY.source}|${OPENAI_BASE_URL_KEY.source})`);
10
10
  export const MANAGED_MARKER = "# CPAC managed; run CPAC 'restore' to restore the original file.";
11
+ function tomlQuotedValue(line) {
12
+ const eq = line.indexOf("=");
13
+ if (eq === -1)
14
+ return undefined;
15
+ const rest = line.slice(eq + 1).trimStart();
16
+ if (rest.startsWith('"')) {
17
+ const match = /^(?:"(?:\\.|[^"\\])*")/.exec(rest);
18
+ if (!match)
19
+ return undefined;
20
+ try {
21
+ return JSON.parse(match[0]);
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ if (rest.startsWith("'")) {
28
+ const end = rest.indexOf("'", 1);
29
+ return end === -1 ? undefined : rest.slice(1, end);
30
+ }
31
+ return undefined;
32
+ }
33
+ function ownedRootLine(line, owned) {
34
+ if (!owned)
35
+ return false;
36
+ const value = tomlQuotedValue(line);
37
+ if (value === undefined)
38
+ return false;
39
+ return ((MODEL_CATALOG_KEY.test(line) && value === owned.catalogPath) ||
40
+ (OPENAI_BASE_URL_KEY.test(line) && value === owned.openaiBaseUrl));
41
+ }
11
42
  function providerPatterns(provider = PROVIDER) {
12
43
  const escaped = provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13
44
  const modelProviders = `(?:model_providers|"model_providers"|'model_providers')`;
@@ -41,7 +72,7 @@ export function hasProviderTable(content, provider = PROVIDER) {
41
72
  }
42
73
  return false;
43
74
  }
44
- function stripManagedConfig(content) {
75
+ function stripManagedConfig(content, owned) {
45
76
  let text;
46
77
  try {
47
78
  text = new TextDecoder("utf-8", { fatal: true }).decode(content);
@@ -75,6 +106,8 @@ function stripManagedConfig(content) {
75
106
  managedRootKeys -= 1;
76
107
  continue;
77
108
  }
109
+ if (inRoot && ownedRootLine(line, owned))
110
+ continue;
78
111
  kept.push(line);
79
112
  }
80
113
  const output = kept.join("\n");
@@ -225,7 +258,7 @@ export async function inject(config, v2Off = false, maxContext = false) {
225
258
  const backup = originalBytes(config.state_dir, state, config.codex_config);
226
259
  try {
227
260
  original = existsSync(config.codex_config)
228
- ? stripManagedConfig(readFileSync(config.codex_config))
261
+ ? stripManagedConfig(readFileSync(config.codex_config), injectedOwnership(state))
229
262
  : backup;
230
263
  }
231
264
  catch (error) {
@@ -293,6 +326,11 @@ export async function inject(config, v2Off = false, maxContext = false) {
293
326
  await stopProxyProcess(proxy);
294
327
  throw error;
295
328
  }
329
+ const ownership = {
330
+ hash: state?.injected_config_hash ?? sha256Hex(injected),
331
+ openaiBaseUrl: `http://127.0.0.1:${proxy.port}/v1`,
332
+ catalogPath,
333
+ };
296
334
  mkdirSync(config.state_dir, { recursive: true, mode: 0o700 });
297
335
  if (!stateDirExisted)
298
336
  chmodSync(config.state_dir, 0o700);
@@ -300,7 +338,7 @@ export async function inject(config, v2Off = false, maxContext = false) {
300
338
  try {
301
339
  atomicWrite(catalogPath, catalog.bytes);
302
340
  atomicWrite(config.codex_config, injected, originalMode);
303
- atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint));
341
+ atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint, ownership));
304
342
  }
305
343
  catch (error) {
306
344
  if (startedProxy)
@@ -316,7 +354,7 @@ export async function inject(config, v2Off = false, maxContext = false) {
316
354
  atomicWrite(catalogPath, catalog.bytes);
317
355
  atomicWrite(config.codex_config, injected, originalMode);
318
356
  configWritten = true;
319
- atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint));
357
+ atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint, ownership));
320
358
  }
321
359
  catch (error) {
322
360
  if (configWritten) {
@@ -361,10 +399,25 @@ export async function restore(config) {
361
399
  if (!state)
362
400
  throw new CPACError("no active CPAC injection");
363
401
  const original = originalBytes(config.state_dir, state, config.codex_config);
364
- if (state.config_existed)
365
- atomicWrite(config.codex_config, original, state.config_mode);
366
- else
367
- rmSync(config.codex_config, { force: true });
402
+ const current = existsSync(config.codex_config)
403
+ ? readFileSync(config.codex_config)
404
+ : null;
405
+ const unchanged = !state.injected_config_hash ||
406
+ (current !== null && sha256Hex(current) === state.injected_config_hash);
407
+ if (!current || unchanged) {
408
+ if (state.config_existed)
409
+ atomicWrite(config.codex_config, original, state.config_mode);
410
+ else
411
+ rmSync(config.codex_config, { force: true });
412
+ }
413
+ else {
414
+ const stripped = stripManagedConfig(current, injectedOwnership(state));
415
+ const empty = new TextDecoder("utf-8").decode(stripped).trim() === "";
416
+ if (!state.config_existed && empty)
417
+ rmSync(config.codex_config, { force: true });
418
+ else
419
+ atomicWrite(config.codex_config, stripped, state.config_mode);
420
+ }
368
421
  const proxy = stateProxy(state);
369
422
  if (proxy)
370
423
  await stopProxyProcess(proxy);
@@ -0,0 +1,109 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { catalogModelId, catalogModelRows, fetchCatalog, readState, stateProxy, } from "../config.js";
5
+ import { proxyIsHealthy } from "../proxy.js";
6
+ import { CPACError, atomicWrite, ensureCpacBackup, expandUserPath, resolveApiKey, tomlString, } from "../util.js";
7
+ export function grokHome() {
8
+ const home = process.env.GROK_HOME?.trim() || join(homedir(), ".grok");
9
+ return expandUserPath(home);
10
+ }
11
+ export function grokConfigPath() {
12
+ return join(grokHome(), "config.toml");
13
+ }
14
+ const GROK_BLOCK_START = "# >>> CPAC Grok >>>";
15
+ const GROK_BLOCK_END = "# <<< CPAC Grok <<<";
16
+ const grokBlockRegex = new RegExp(`${GROK_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${GROK_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`);
17
+ export function isGrokConfigInstalled() {
18
+ try {
19
+ const content = readFileSync(grokConfigPath(), "utf8");
20
+ return (grokBlockRegex.test(content) ||
21
+ /^\s*\[model\.(?:"cpac\/|'cpac\/|cpac-)/m.test(content));
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ function isCpacGrokTableHeader(line) {
28
+ const header = line.match(/^\[([^\]]+)\]\s*$/);
29
+ if (!header)
30
+ return false;
31
+ const name = header[1];
32
+ return /^model\.(?:"cpac\/|'cpac\/|cpac-)/.test(name);
33
+ }
34
+ function stripOrphanCpacGrokTables(content) {
35
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
36
+ const out = [];
37
+ let skipping = false;
38
+ for (const line of content.split(/\r?\n/)) {
39
+ if (/^\[[^\]]+\]\s*$/.test(line))
40
+ skipping = isCpacGrokTableHeader(line);
41
+ if (!skipping)
42
+ out.push(line);
43
+ }
44
+ return out.join(eol).replace(/(?:\r?\n){3,}/g, `${eol}${eol}`);
45
+ }
46
+ function writeGrokBlock(path, block) {
47
+ let content = existsSync(path) ? readFileSync(path, "utf8") : "";
48
+ content = content.replace(grokBlockRegex, "");
49
+ const orphan = content.indexOf(GROK_BLOCK_START);
50
+ if (orphan !== -1)
51
+ content = content.slice(0, orphan);
52
+ content = stripOrphanCpacGrokTables(content);
53
+ if (block) {
54
+ content = `${content}${content && !content.endsWith("\n") ? "\n" : ""}${block}\n`;
55
+ }
56
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
57
+ const mode = existsSync(path) ? statSync(path).mode & 0o7777 : 0o600;
58
+ atomicWrite(path, Buffer.from(content), mode);
59
+ }
60
+ export async function installGrokConfig(config) {
61
+ const apiKey = await resolveApiKey(config.api_key_env);
62
+ const catalog = await fetchCatalog(config.cpa_url, apiKey);
63
+ let document;
64
+ try {
65
+ document = JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes));
66
+ }
67
+ catch {
68
+ throw new CPACError("invalid CPA catalog");
69
+ }
70
+ const rows = catalogModelRows(document) ?? [];
71
+ if (rows.length === 0)
72
+ throw new CPACError("CPA catalog contains no models");
73
+ const state = readState(config.state_dir);
74
+ const recorded = state ? stateProxy(state) : null;
75
+ const port = recorded?.port ?? config.codex_proxy_port;
76
+ if (!port) {
77
+ throw new CPACError("loopback proxy port unknown; run cpac inject first");
78
+ }
79
+ const lines = [GROK_BLOCK_START];
80
+ for (const row of rows) {
81
+ const slug = catalogModelId(row);
82
+ if (!slug)
83
+ continue;
84
+ const context = typeof row.context_window === "number" && row.context_window > 0
85
+ ? Math.floor(row.context_window)
86
+ : 200000;
87
+ const name = typeof row.display_name === "string" && row.display_name.trim()
88
+ ? `${row.display_name.trim()} (CPAC)`
89
+ : `CPAC ${slug}`;
90
+ 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}`, "");
91
+ }
92
+ if (lines[lines.length - 1] === "")
93
+ lines.pop();
94
+ lines.push(GROK_BLOCK_END);
95
+ const target = grokConfigPath();
96
+ ensureCpacBackup(target);
97
+ writeGrokBlock(target, lines.join("\n"));
98
+ console.log(`Installed Grok Build provider config: ${target}`);
99
+ if (!recorded || !(await proxyIsHealthy(recorded))) {
100
+ console.log("Loopback proxy is not running; run: cpac inject");
101
+ }
102
+ }
103
+ export async function uninstallGrokConfig() {
104
+ const target = grokConfigPath();
105
+ if (!isGrokConfigInstalled())
106
+ throw new CPACError("Grok Build config is not installed");
107
+ writeGrokBlock(target, null);
108
+ console.log(`Removed Grok Build provider config: ${target}`);
109
+ }