@yhong91/cpac 0.1.25 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents.js +784 -0
- package/dist/claude.js +373 -0
- package/dist/codex.js +378 -0
- package/dist/config.js +440 -0
- package/dist/cpac.js +16 -2414
- package/dist/proxy.js +341 -0
- package/dist/util.js +192 -0
- package/package.json +2 -3
package/dist/agents.js
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { apiBase, catalogModelId, catalogModelRows, fetchCatalog, pickSpawnModels, proxyFingerprint, readState, saveSpawnModels, stateBytes, stateProxy, } from "./config.js";
|
|
7
|
+
import { MANAGED_MARKER, inject, restore } from "./codex.js";
|
|
8
|
+
import { proxyIsHealthy, startProxyProcess, stopProxyProcess, } from "./proxy.js";
|
|
9
|
+
import { CPACError, atomicWrite, checkboxPicker, expandUserPath, objectValue, resolveApiKey, tomlString, tomlStringArray, } from "./util.js";
|
|
10
|
+
const OPENCODE_OUTPUT_BUDGET = 32_000;
|
|
11
|
+
// Ephemeral takeover: inline a CPA provider via OPENCODE_CONFIG_CONTENT so no
|
|
12
|
+
// opencode config file is touched; the session ends with zero residue.
|
|
13
|
+
export async function runOpencode(config, args, executable = "opencode") {
|
|
14
|
+
const apiKey = process.env[config.api_key_env]?.trim();
|
|
15
|
+
if (!apiKey)
|
|
16
|
+
throw new CPACError(`environment variable ${config.api_key_env} is not set`);
|
|
17
|
+
const catalog = await fetchCatalog(config.cpa_url, apiKey);
|
|
18
|
+
let document;
|
|
19
|
+
try {
|
|
20
|
+
document = JSON.parse(new TextDecoder().decode(catalog.bytes));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new CPACError("invalid CPA catalog");
|
|
24
|
+
}
|
|
25
|
+
const models = {};
|
|
26
|
+
for (const row of document.models) {
|
|
27
|
+
const id = catalogModelId(row);
|
|
28
|
+
if (!id)
|
|
29
|
+
continue;
|
|
30
|
+
const window = typeof row.context_window === "number" && row.context_window > 0
|
|
31
|
+
? Math.floor(row.context_window)
|
|
32
|
+
: 0;
|
|
33
|
+
models[id] =
|
|
34
|
+
window > 0
|
|
35
|
+
? {
|
|
36
|
+
limit: {
|
|
37
|
+
context: window,
|
|
38
|
+
output: Math.min(OPENCODE_OUTPUT_BUDGET, window),
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
: {};
|
|
42
|
+
}
|
|
43
|
+
const content = JSON.stringify({
|
|
44
|
+
provider: {
|
|
45
|
+
cpac: {
|
|
46
|
+
npm: "@ai-sdk/openai-compatible",
|
|
47
|
+
options: { baseURL: apiBase(config.cpa_url), apiKey },
|
|
48
|
+
models,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const env = {
|
|
53
|
+
...process.env,
|
|
54
|
+
OPENCODE_CONFIG_CONTENT: content,
|
|
55
|
+
};
|
|
56
|
+
return await new Promise((resolve, reject) => {
|
|
57
|
+
const child = spawn(executable, args, { env, stdio: "inherit" });
|
|
58
|
+
child.once("error", (error) => {
|
|
59
|
+
reject(new CPACError(error instanceof Error && "code" in error && error.code === "ENOENT"
|
|
60
|
+
? `${executable} not found`
|
|
61
|
+
: `cannot start ${executable}: ${error.message}`));
|
|
62
|
+
});
|
|
63
|
+
child.once("close", (code) => resolve(code ?? 1));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export async function runCodexConfig(config, configPath, options = {}) {
|
|
67
|
+
if (options.reset) {
|
|
68
|
+
let raw = {};
|
|
69
|
+
try {
|
|
70
|
+
const file = JSON.parse(readFileSync(configPath, "utf8"));
|
|
71
|
+
if (!objectValue(file))
|
|
72
|
+
throw new CPACError("config must be a JSON object");
|
|
73
|
+
raw = file;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code !== "ENOENT") {
|
|
77
|
+
if (error instanceof CPACError)
|
|
78
|
+
throw error;
|
|
79
|
+
throw new CPACError(`cannot read config: ${error instanceof Error ? error.message : String(error)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
delete raw.spawn_models;
|
|
83
|
+
atomicWrite(configPath, Buffer.from(`${JSON.stringify(raw, null, 2)}\n`));
|
|
84
|
+
await runRestore(config, ["codex"], { all: false, dryRun: false });
|
|
85
|
+
console.log("Codex configuration reset to default");
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
if (options.interactive && (!process.stdin.isTTY || !process.stderr.isTTY)) {
|
|
89
|
+
console.log(config.spawn_models
|
|
90
|
+
? JSON.stringify(config.spawn_models, null, 2)
|
|
91
|
+
: "spawn_models not set");
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
let spawnModels = config.spawn_models;
|
|
95
|
+
let v2Off = options.v2Off ?? false;
|
|
96
|
+
let maxContext = options.maxContext ?? false;
|
|
97
|
+
if (options.interactive || options.v2Models) {
|
|
98
|
+
const picked = await pickSpawnModels(config);
|
|
99
|
+
if (picked.length > 0) {
|
|
100
|
+
spawnModels = picked;
|
|
101
|
+
saveSpawnModels(configPath, picked);
|
|
102
|
+
console.log(`spawn_models saved to ${configPath}: ${picked.join(", ")}`);
|
|
103
|
+
}
|
|
104
|
+
if (options.interactive) {
|
|
105
|
+
const [maxCtxChoice] = await checkboxPicker("Codex Context Window Size:", [
|
|
106
|
+
"Standard (default input budget, e.g. 200K~272K)",
|
|
107
|
+
"Max Context Window (lift to full upper bound, e.g. 921K~1M, 90% auto-compact limit)",
|
|
108
|
+
], 1);
|
|
109
|
+
maxContext = maxCtxChoice?.startsWith("Max Context") ?? false;
|
|
110
|
+
const [v2Choice] = await checkboxPicker("Codex Multi-Agent V2 mode:", ["Enabled (Default)", "Disabled (--v2_off)"], 1);
|
|
111
|
+
v2Off = v2Choice === "Disabled (--v2_off)";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const updatedConfig = { ...config, spawn_models: spawnModels };
|
|
115
|
+
await runInstall(updatedConfig, ["codex"], {
|
|
116
|
+
all: false,
|
|
117
|
+
dryRun: false,
|
|
118
|
+
force: true,
|
|
119
|
+
v2Off,
|
|
120
|
+
maxContext,
|
|
121
|
+
});
|
|
122
|
+
console.log("Codex configuration updated and synced.");
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
export async function runTargetLauncher(config, targetId, args, executable = targetId, options = {}) {
|
|
126
|
+
const target = TARGETS.find((t) => t.id === targetId);
|
|
127
|
+
if (!target)
|
|
128
|
+
throw new CPACError(`unknown target: ${targetId}`);
|
|
129
|
+
if (["kimi", "grok"].includes(targetId)) {
|
|
130
|
+
const state = readState(config.state_dir);
|
|
131
|
+
const proxy = state ? stateProxy(state) : null;
|
|
132
|
+
if (!proxy || !(await proxyIsHealthy(proxy))) {
|
|
133
|
+
if (proxy)
|
|
134
|
+
await stopProxyProcess(proxy);
|
|
135
|
+
const apiKey = await resolveApiKey(config.api_key_env);
|
|
136
|
+
const started = await startProxyProcess(config, apiKey);
|
|
137
|
+
try {
|
|
138
|
+
const fingerprint = proxyFingerprint(config, apiKey);
|
|
139
|
+
const catalog = await fetchCatalog(config.cpa_url, apiKey);
|
|
140
|
+
const catalogPath = join(config.state_dir, "codex-models.json");
|
|
141
|
+
mkdirSync(config.state_dir, { recursive: true, mode: 0o700 });
|
|
142
|
+
atomicWrite(catalogPath, catalog.bytes);
|
|
143
|
+
const originalMode = existsSync(config.codex_config)
|
|
144
|
+
? statSync(config.codex_config).mode & 0o7777
|
|
145
|
+
: 0o600;
|
|
146
|
+
atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, state ? state.config_existed : false, state ? state.config_mode : originalMode, started, fingerprint));
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
await stopProxyProcess(started);
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (target.installed(config)) {
|
|
155
|
+
try {
|
|
156
|
+
await runSync(config, [targetId], {
|
|
157
|
+
all: false,
|
|
158
|
+
dryRun: false,
|
|
159
|
+
v2Off: options.v2Off,
|
|
160
|
+
maxContext: options.maxContext,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
console.warn(`warning: auto-sync failed (${error instanceof Error ? error.message : String(error)}); launching with existing config`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
await target.install(config, false, options.v2Off, options.maxContext);
|
|
169
|
+
}
|
|
170
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
171
|
+
const child = spawn(executable, args, { stdio: "inherit" });
|
|
172
|
+
child.once("error", (error) => {
|
|
173
|
+
rejectPromise(new CPACError(error instanceof Error && "code" in error && error.code === "ENOENT"
|
|
174
|
+
? `${executable} not found`
|
|
175
|
+
: `cannot start ${executable}: ${error.message}`));
|
|
176
|
+
});
|
|
177
|
+
child.once("close", (code) => resolvePromise(code ?? 1));
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
function piExtensionsDir() {
|
|
181
|
+
return join(expandUserPath(process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent")), "extensions");
|
|
182
|
+
}
|
|
183
|
+
export function isPiExtensionInstalled() {
|
|
184
|
+
return existsSync(join(piExtensionsDir(), "cpac.ts"));
|
|
185
|
+
}
|
|
186
|
+
function piTemplatePath() {
|
|
187
|
+
return join(dirname(fileURLToPath(import.meta.url)), "pi-extension.template");
|
|
188
|
+
}
|
|
189
|
+
function piExtensionContent(cpaUrl) {
|
|
190
|
+
return readFileSync(piTemplatePath(), "utf8").replace("__CPA_URL__", cpaUrl);
|
|
191
|
+
}
|
|
192
|
+
export async function installPiExtension(config) {
|
|
193
|
+
const dir = piExtensionsDir();
|
|
194
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
195
|
+
const target = join(dir, "cpac.ts");
|
|
196
|
+
ensureCpacBackup(target);
|
|
197
|
+
atomicWrite(target, Buffer.from(piExtensionContent(config.cpa_url)), 0o644);
|
|
198
|
+
console.log(`Installed Pi extension: ${target}`);
|
|
199
|
+
}
|
|
200
|
+
export async function uninstallPiExtension() {
|
|
201
|
+
const target = join(piExtensionsDir(), "cpac.ts");
|
|
202
|
+
if (!existsSync(target))
|
|
203
|
+
throw new CPACError("Pi extension is not installed");
|
|
204
|
+
unlinkSync(target);
|
|
205
|
+
console.log(`Removed Pi extension: ${target}`);
|
|
206
|
+
}
|
|
207
|
+
// Pre-write snapshot kept at <target>.cpac-backup (first version only) so a
|
|
208
|
+
// damaged config can be restored by hand even if uninstall cannot parse it.
|
|
209
|
+
function ensureCpacBackup(target) {
|
|
210
|
+
if (!existsSync(target))
|
|
211
|
+
return;
|
|
212
|
+
const backup = `${target}.cpac-backup`;
|
|
213
|
+
if (existsSync(backup))
|
|
214
|
+
return;
|
|
215
|
+
copyFileSync(target, backup);
|
|
216
|
+
}
|
|
217
|
+
function kimiConfigPath() {
|
|
218
|
+
const home = process.env.KIMI_CODE_HOME?.trim() || join(homedir(), ".kimi-code");
|
|
219
|
+
return join(expandUserPath(home), "config.toml");
|
|
220
|
+
}
|
|
221
|
+
const KIMI_BLOCK_START = "# >>> CPAC Kimi >>>";
|
|
222
|
+
const KIMI_BLOCK_END = "# <<< CPAC Kimi <<<";
|
|
223
|
+
const kimiBlockRegex = new RegExp(`${KIMI_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${KIMI_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`);
|
|
224
|
+
export function isKimiConfigInstalled() {
|
|
225
|
+
try {
|
|
226
|
+
const content = readFileSync(kimiConfigPath(), "utf8");
|
|
227
|
+
return (kimiBlockRegex.test(content) || /^\s*\[providers\.cpac\]\s*$/m.test(content));
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function isCpacTableHeader(line) {
|
|
234
|
+
const header = line.match(/^\[([^\]]+)\]\s*$/);
|
|
235
|
+
if (!header)
|
|
236
|
+
return false;
|
|
237
|
+
const name = header[1];
|
|
238
|
+
return (name === "providers.cpac" ||
|
|
239
|
+
name.startsWith("providers.cpac.") ||
|
|
240
|
+
/^models\.(?:"cpac\/|'cpac\/)/.test(name));
|
|
241
|
+
}
|
|
242
|
+
// Kimi may rewrite config.toml and drop our comment markers, leaving the
|
|
243
|
+
// [providers.cpac] / [models."cpac/..."] tables behind. A later install would
|
|
244
|
+
// then append a second copy and make the file invalid TOML.
|
|
245
|
+
function stripOrphanCpacTables(content) {
|
|
246
|
+
const eol = content.includes("\r\n") ? "\r\n" : "\n";
|
|
247
|
+
const out = [];
|
|
248
|
+
let skipping = false;
|
|
249
|
+
for (const line of content.split(/\r?\n/)) {
|
|
250
|
+
if (/^\[[^\]]+\]\s*$/.test(line))
|
|
251
|
+
skipping = isCpacTableHeader(line);
|
|
252
|
+
if (!skipping)
|
|
253
|
+
out.push(line);
|
|
254
|
+
}
|
|
255
|
+
return out.join(eol).replace(/(?:\r?\n){3,}/g, `${eol}${eol}`);
|
|
256
|
+
}
|
|
257
|
+
function writeKimiBlock(path, block) {
|
|
258
|
+
let content = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
259
|
+
content = content.replace(kimiBlockRegex, "");
|
|
260
|
+
// A truncated managed block (start marker without the end marker) leaves its
|
|
261
|
+
// tables behind; appending again would duplicate [providers.cpac], make the
|
|
262
|
+
// file invalid TOML, and block `kimi login`. The block is always appended
|
|
263
|
+
// last, so dropping from an orphaned start marker to EOF is safe.
|
|
264
|
+
const orphan = content.indexOf(KIMI_BLOCK_START);
|
|
265
|
+
if (orphan !== -1)
|
|
266
|
+
content = content.slice(0, orphan);
|
|
267
|
+
content = stripOrphanCpacTables(content);
|
|
268
|
+
if (block) {
|
|
269
|
+
content = `${content}${content && !content.endsWith("\n") ? "\n" : ""}${block}\n`;
|
|
270
|
+
}
|
|
271
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
272
|
+
const mode = existsSync(path) ? statSync(path).mode & 0o7777 : 0o600;
|
|
273
|
+
atomicWrite(path, Buffer.from(content), mode);
|
|
274
|
+
}
|
|
275
|
+
const KIMI_REASONING_EFFORTS = new Set([
|
|
276
|
+
"minimal",
|
|
277
|
+
"low",
|
|
278
|
+
"medium",
|
|
279
|
+
"high",
|
|
280
|
+
"xhigh",
|
|
281
|
+
"max",
|
|
282
|
+
"ultra",
|
|
283
|
+
]);
|
|
284
|
+
function kimiSupportEfforts(row) {
|
|
285
|
+
const levels = row.supported_reasoning_levels ??
|
|
286
|
+
row.reasoning_effort_levels ??
|
|
287
|
+
row.reasoning_levels;
|
|
288
|
+
if (!Array.isArray(levels))
|
|
289
|
+
return [];
|
|
290
|
+
const seen = new Set();
|
|
291
|
+
const efforts = [];
|
|
292
|
+
for (const level of levels) {
|
|
293
|
+
const effort = typeof level === "string"
|
|
294
|
+
? level.toLowerCase()
|
|
295
|
+
: objectValue(level) && typeof level.effort === "string"
|
|
296
|
+
? level.effort.toLowerCase()
|
|
297
|
+
: undefined;
|
|
298
|
+
if (!effort || !KIMI_REASONING_EFFORTS.has(effort) || seen.has(effort))
|
|
299
|
+
continue;
|
|
300
|
+
seen.add(effort);
|
|
301
|
+
efforts.push(effort);
|
|
302
|
+
}
|
|
303
|
+
return efforts;
|
|
304
|
+
}
|
|
305
|
+
function kimiInputHasImage(row) {
|
|
306
|
+
const modalities = row.input_modalities;
|
|
307
|
+
if (!Array.isArray(modalities))
|
|
308
|
+
return true;
|
|
309
|
+
return modalities.some((value) => value === "image");
|
|
310
|
+
}
|
|
311
|
+
export async function installKimiConfig(config) {
|
|
312
|
+
const apiKey = await resolveApiKey(config.api_key_env);
|
|
313
|
+
const catalog = await fetchCatalog(config.cpa_url, apiKey);
|
|
314
|
+
let document;
|
|
315
|
+
try {
|
|
316
|
+
document = JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes));
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
throw new CPACError("invalid CPA catalog");
|
|
320
|
+
}
|
|
321
|
+
const rows = catalogModelRows(document) ?? [];
|
|
322
|
+
if (rows.length === 0)
|
|
323
|
+
throw new CPACError("CPA catalog contains no models");
|
|
324
|
+
const state = readState(config.state_dir);
|
|
325
|
+
const recorded = state ? stateProxy(state) : null;
|
|
326
|
+
const port = recorded?.port ?? config.codex_proxy_port;
|
|
327
|
+
if (!port) {
|
|
328
|
+
throw new CPACError("loopback proxy port unknown; run cpac inject first");
|
|
329
|
+
}
|
|
330
|
+
const lines = [
|
|
331
|
+
KIMI_BLOCK_START,
|
|
332
|
+
"[providers.cpac]",
|
|
333
|
+
'type = "openai"',
|
|
334
|
+
`base_url = "http://127.0.0.1:${port}/v1"`,
|
|
335
|
+
'# Placeholder: the CPAC loopback proxy replaces it with the CPA key; run "cpac inject" to start the proxy.',
|
|
336
|
+
'api_key = "cpac-loopback"',
|
|
337
|
+
];
|
|
338
|
+
for (const row of rows) {
|
|
339
|
+
const slug = catalogModelId(row);
|
|
340
|
+
if (!slug)
|
|
341
|
+
continue;
|
|
342
|
+
// ponytail: Kimi requires max_context_size; default 200000 when the catalog omits it.
|
|
343
|
+
const context = typeof row.context_window === "number" && row.context_window > 0
|
|
344
|
+
? Math.floor(row.context_window)
|
|
345
|
+
: 200000;
|
|
346
|
+
const efforts = kimiSupportEfforts(row);
|
|
347
|
+
const capabilities = [
|
|
348
|
+
...(efforts.length > 0 ? ["thinking"] : []),
|
|
349
|
+
"tool_use",
|
|
350
|
+
...(kimiInputHasImage(row) ? ["image_in"] : []),
|
|
351
|
+
];
|
|
352
|
+
lines.push("", `[models."cpac/${slug}"]`, 'provider = "cpac"', `model = ${tomlString(slug)}`, `max_context_size = ${context}`, `capabilities = ${tomlStringArray(capabilities)}`);
|
|
353
|
+
if (typeof row.display_name === "string" && row.display_name.trim()) {
|
|
354
|
+
lines.push(`display_name = ${tomlString(row.display_name)}`);
|
|
355
|
+
}
|
|
356
|
+
if (efforts.length > 0) {
|
|
357
|
+
lines.push(`support_efforts = ${tomlStringArray(efforts)}`);
|
|
358
|
+
const rawDefaultEffort = (typeof row.default_reasoning_level === "string" &&
|
|
359
|
+
row.default_reasoning_level) ||
|
|
360
|
+
(typeof row.default_reasoning_effort === "string" &&
|
|
361
|
+
row.default_reasoning_effort) ||
|
|
362
|
+
(typeof row.default_effort === "string" && row.default_effort);
|
|
363
|
+
const defaultEffort = rawDefaultEffort && efforts.includes(rawDefaultEffort.toLowerCase())
|
|
364
|
+
? rawDefaultEffort.toLowerCase()
|
|
365
|
+
: undefined;
|
|
366
|
+
if (defaultEffort)
|
|
367
|
+
lines.push(`default_effort = ${tomlString(defaultEffort)}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
lines.push(KIMI_BLOCK_END);
|
|
371
|
+
const target = kimiConfigPath();
|
|
372
|
+
ensureCpacBackup(target);
|
|
373
|
+
writeKimiBlock(target, lines.join("\n"));
|
|
374
|
+
console.log(`Installed Kimi Code provider config: ${target}`);
|
|
375
|
+
if (!recorded || !(await proxyIsHealthy(recorded))) {
|
|
376
|
+
console.log("Loopback proxy is not running; run: cpac inject");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
export async function uninstallKimiConfig() {
|
|
380
|
+
const target = kimiConfigPath();
|
|
381
|
+
if (!isKimiConfigInstalled())
|
|
382
|
+
throw new CPACError("Kimi Code config is not installed");
|
|
383
|
+
writeKimiBlock(target, null);
|
|
384
|
+
console.log(`Removed Kimi Code provider config: ${target}`);
|
|
385
|
+
}
|
|
386
|
+
function grokHome() {
|
|
387
|
+
const home = process.env.GROK_HOME?.trim() || join(homedir(), ".grok");
|
|
388
|
+
return expandUserPath(home);
|
|
389
|
+
}
|
|
390
|
+
function grokConfigPath() {
|
|
391
|
+
return join(grokHome(), "config.toml");
|
|
392
|
+
}
|
|
393
|
+
const GROK_BLOCK_START = "# >>> CPAC Grok >>>";
|
|
394
|
+
const GROK_BLOCK_END = "# <<< CPAC Grok <<<";
|
|
395
|
+
const grokBlockRegex = new RegExp(`${GROK_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${GROK_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`);
|
|
396
|
+
export function isGrokConfigInstalled() {
|
|
397
|
+
try {
|
|
398
|
+
const content = readFileSync(grokConfigPath(), "utf8");
|
|
399
|
+
return (grokBlockRegex.test(content) ||
|
|
400
|
+
/^\s*\[model\.(?:"cpac\/|'cpac\/|cpac-)/m.test(content));
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function isCpacGrokTableHeader(line) {
|
|
407
|
+
const header = line.match(/^\[([^\]]+)\]\s*$/);
|
|
408
|
+
if (!header)
|
|
409
|
+
return false;
|
|
410
|
+
const name = header[1];
|
|
411
|
+
return /^model\.(?:"cpac\/|'cpac\/|cpac-)/.test(name);
|
|
412
|
+
}
|
|
413
|
+
function stripOrphanCpacGrokTables(content) {
|
|
414
|
+
const eol = content.includes("\r\n") ? "\r\n" : "\n";
|
|
415
|
+
const out = [];
|
|
416
|
+
let skipping = false;
|
|
417
|
+
for (const line of content.split(/\r?\n/)) {
|
|
418
|
+
if (/^\[[^\]]+\]\s*$/.test(line))
|
|
419
|
+
skipping = isCpacGrokTableHeader(line);
|
|
420
|
+
if (!skipping)
|
|
421
|
+
out.push(line);
|
|
422
|
+
}
|
|
423
|
+
return out.join(eol).replace(/(?:\r?\n){3,}/g, `${eol}${eol}`);
|
|
424
|
+
}
|
|
425
|
+
function writeGrokBlock(path, block) {
|
|
426
|
+
let content = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
427
|
+
content = content.replace(grokBlockRegex, "");
|
|
428
|
+
const orphan = content.indexOf(GROK_BLOCK_START);
|
|
429
|
+
if (orphan !== -1)
|
|
430
|
+
content = content.slice(0, orphan);
|
|
431
|
+
content = stripOrphanCpacGrokTables(content);
|
|
432
|
+
if (block) {
|
|
433
|
+
content = `${content}${content && !content.endsWith("\n") ? "\n" : ""}${block}\n`;
|
|
434
|
+
}
|
|
435
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
436
|
+
const mode = existsSync(path) ? statSync(path).mode & 0o7777 : 0o600;
|
|
437
|
+
atomicWrite(path, Buffer.from(content), mode);
|
|
438
|
+
}
|
|
439
|
+
export async function installGrokConfig(config) {
|
|
440
|
+
const apiKey = await resolveApiKey(config.api_key_env);
|
|
441
|
+
const catalog = await fetchCatalog(config.cpa_url, apiKey);
|
|
442
|
+
let document;
|
|
443
|
+
try {
|
|
444
|
+
document = JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes));
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
throw new CPACError("invalid CPA catalog");
|
|
448
|
+
}
|
|
449
|
+
const rows = catalogModelRows(document) ?? [];
|
|
450
|
+
if (rows.length === 0)
|
|
451
|
+
throw new CPACError("CPA catalog contains no models");
|
|
452
|
+
const state = readState(config.state_dir);
|
|
453
|
+
const recorded = state ? stateProxy(state) : null;
|
|
454
|
+
const port = recorded?.port ?? config.codex_proxy_port;
|
|
455
|
+
if (!port) {
|
|
456
|
+
throw new CPACError("loopback proxy port unknown; run cpac inject first");
|
|
457
|
+
}
|
|
458
|
+
const lines = [GROK_BLOCK_START];
|
|
459
|
+
for (const row of rows) {
|
|
460
|
+
const slug = catalogModelId(row);
|
|
461
|
+
if (!slug)
|
|
462
|
+
continue;
|
|
463
|
+
const context = typeof row.context_window === "number" && row.context_window > 0
|
|
464
|
+
? Math.floor(row.context_window)
|
|
465
|
+
: 200000;
|
|
466
|
+
const name = typeof row.display_name === "string" && row.display_name.trim()
|
|
467
|
+
? `${row.display_name.trim()} (CPAC)`
|
|
468
|
+
: `CPAC ${slug}`;
|
|
469
|
+
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}`, "");
|
|
470
|
+
}
|
|
471
|
+
if (lines[lines.length - 1] === "")
|
|
472
|
+
lines.pop();
|
|
473
|
+
lines.push(GROK_BLOCK_END);
|
|
474
|
+
const target = grokConfigPath();
|
|
475
|
+
ensureCpacBackup(target);
|
|
476
|
+
writeGrokBlock(target, lines.join("\n"));
|
|
477
|
+
console.log(`Installed Grok Build provider config: ${target}`);
|
|
478
|
+
if (!recorded || !(await proxyIsHealthy(recorded))) {
|
|
479
|
+
console.log("Loopback proxy is not running; run: cpac inject");
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
export async function uninstallGrokConfig() {
|
|
483
|
+
const target = grokConfigPath();
|
|
484
|
+
if (!isGrokConfigInstalled())
|
|
485
|
+
throw new CPACError("Grok Build config is not installed");
|
|
486
|
+
writeGrokBlock(target, null);
|
|
487
|
+
console.log(`Removed Grok Build provider config: ${target}`);
|
|
488
|
+
}
|
|
489
|
+
function readPackageVersion() {
|
|
490
|
+
try {
|
|
491
|
+
const raw = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8");
|
|
492
|
+
const version = JSON.parse(raw).version;
|
|
493
|
+
return typeof version === "string" ? version : "0.0.0-dev";
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return "0.0.0-dev";
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
export const CPAC_VERSION = readPackageVersion();
|
|
500
|
+
export function detectClientVersion(binary) {
|
|
501
|
+
if (!/^[\w.-]+$/.test(binary))
|
|
502
|
+
return undefined;
|
|
503
|
+
try {
|
|
504
|
+
const res = spawnSync(binary, ["--version"], {
|
|
505
|
+
encoding: "utf8",
|
|
506
|
+
timeout: 2000,
|
|
507
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
508
|
+
});
|
|
509
|
+
if (res.status === 0 && typeof res.stdout === "string") {
|
|
510
|
+
const match = res.stdout.match(/v?(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
|
|
511
|
+
return match ? match[1] : res.stdout.trim().split(/\s+/)[0] || undefined;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
// ignored
|
|
516
|
+
}
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
function binaryOnPath(name) {
|
|
520
|
+
if (!/^[\w.-]+$/.test(name))
|
|
521
|
+
return false;
|
|
522
|
+
try {
|
|
523
|
+
return (spawnSync(`command -v ${name}`, { stdio: "ignore", shell: true }).status ===
|
|
524
|
+
0);
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const TARGETS = [
|
|
531
|
+
{
|
|
532
|
+
id: "codex",
|
|
533
|
+
home: () => expandUserPath(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex")),
|
|
534
|
+
detected: function () {
|
|
535
|
+
return existsSync(this.home());
|
|
536
|
+
},
|
|
537
|
+
installed: (config) => {
|
|
538
|
+
try {
|
|
539
|
+
return readFileSync(config.codex_config, "utf8").includes(MANAGED_MARKER);
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
version: () => detectClientVersion("codex"),
|
|
546
|
+
install: async (config, dryRun, v2Off, maxContext) => {
|
|
547
|
+
if (dryRun) {
|
|
548
|
+
console.log("would inject CPA catalog and loopback proxy into Codex");
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
await inject(config, v2Off, maxContext);
|
|
552
|
+
},
|
|
553
|
+
uninstall: async (config, dryRun) => {
|
|
554
|
+
if (dryRun) {
|
|
555
|
+
console.log("would restore the original Codex config");
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
await restore(config);
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
id: "pi",
|
|
563
|
+
home: () => piExtensionsDir(),
|
|
564
|
+
detected: () => binaryOnPath("pi") || existsSync(piExtensionsDir()),
|
|
565
|
+
installed: () => isPiExtensionInstalled(),
|
|
566
|
+
version: () => detectClientVersion("pi"),
|
|
567
|
+
install: async (config, dryRun) => {
|
|
568
|
+
if (dryRun) {
|
|
569
|
+
console.log(`would write Pi extension: ${join(piExtensionsDir(), "cpac.ts")}`);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
await installPiExtension(config);
|
|
573
|
+
},
|
|
574
|
+
uninstall: async (_config, dryRun) => {
|
|
575
|
+
if (dryRun) {
|
|
576
|
+
console.log(`would remove Pi extension: ${join(piExtensionsDir(), "cpac.ts")}`);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
await uninstallPiExtension();
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
id: "kimi",
|
|
584
|
+
home: () => dirname(kimiConfigPath()),
|
|
585
|
+
detected: () => binaryOnPath("kimi") || existsSync(dirname(kimiConfigPath())),
|
|
586
|
+
installed: () => isKimiConfigInstalled(),
|
|
587
|
+
version: () => detectClientVersion("kimi"),
|
|
588
|
+
install: async (config, dryRun) => {
|
|
589
|
+
if (dryRun) {
|
|
590
|
+
console.log(`would write CPA provider block: ${kimiConfigPath()}`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
await installKimiConfig(config);
|
|
594
|
+
},
|
|
595
|
+
uninstall: async (_config, dryRun) => {
|
|
596
|
+
if (dryRun) {
|
|
597
|
+
console.log(`would remove CPA provider block: ${kimiConfigPath()}`);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
await uninstallKimiConfig();
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
id: "grok",
|
|
605
|
+
home: () => grokHome(),
|
|
606
|
+
detected: () => binaryOnPath("grok") || existsSync(grokHome()),
|
|
607
|
+
installed: () => isGrokConfigInstalled(),
|
|
608
|
+
version: () => detectClientVersion("grok"),
|
|
609
|
+
install: async (config, dryRun) => {
|
|
610
|
+
if (dryRun) {
|
|
611
|
+
console.log(`would write CPA provider block: ${grokConfigPath()}`);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
await installGrokConfig(config);
|
|
615
|
+
},
|
|
616
|
+
uninstall: async (_config, dryRun) => {
|
|
617
|
+
if (dryRun) {
|
|
618
|
+
console.log(`would remove CPA provider block: ${grokConfigPath()}`);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
await uninstallGrokConfig();
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
];
|
|
625
|
+
export async function detectTargets(config) {
|
|
626
|
+
return TARGETS.map((target) => ({
|
|
627
|
+
id: target.id,
|
|
628
|
+
detected: target.detected(),
|
|
629
|
+
installed: target.installed(config),
|
|
630
|
+
path: target.home(),
|
|
631
|
+
version: target.version?.(),
|
|
632
|
+
}));
|
|
633
|
+
}
|
|
634
|
+
export async function runDetect(config, asJson) {
|
|
635
|
+
const targets = await detectTargets(config);
|
|
636
|
+
if (asJson) {
|
|
637
|
+
console.log(JSON.stringify({ version: CPAC_VERSION, targets }, null, 2));
|
|
638
|
+
return 0;
|
|
639
|
+
}
|
|
640
|
+
for (const target of targets) {
|
|
641
|
+
console.log(`${target.id.padEnd(8)} ${(target.detected ? "detected" : "missing").padEnd(9)} ${(target.installed ? "installed" : "not installed").padEnd(14)} ${target.path}`);
|
|
642
|
+
}
|
|
643
|
+
return 0;
|
|
644
|
+
}
|
|
645
|
+
function selectTargets(requested, all, eligible) {
|
|
646
|
+
const unknown = requested.filter((id) => !TARGETS.some((target) => target.id === id));
|
|
647
|
+
if (unknown.length > 0) {
|
|
648
|
+
throw new CPACError(`unknown target(s): ${unknown.join(", ")}; use ${TARGETS.map((target) => target.id).join(",")}`);
|
|
649
|
+
}
|
|
650
|
+
const ids = requested.length > 0
|
|
651
|
+
? requested
|
|
652
|
+
: all
|
|
653
|
+
? TARGETS.map((target) => target.id)
|
|
654
|
+
: eligible;
|
|
655
|
+
return TARGETS.filter((target) => ids.includes(target.id));
|
|
656
|
+
}
|
|
657
|
+
export async function runInstall(config, requested, options) {
|
|
658
|
+
const eligible = TARGETS.filter((target) => target.detected()).map((target) => target.id);
|
|
659
|
+
const selected = selectTargets(requested, options.all, eligible);
|
|
660
|
+
if (selected.length === 0) {
|
|
661
|
+
throw new CPACError(`no supported targets detected; use --target ${TARGETS.map((target) => target.id).join(",")} or --all`);
|
|
662
|
+
}
|
|
663
|
+
// Catalog context lifting is Codex-specific; refuse to silently skip it on
|
|
664
|
+
// other targets so the flag never gains cross-agent meaning.
|
|
665
|
+
if (options.maxContext && selected.some((target) => target.id !== "codex")) {
|
|
666
|
+
throw new CPACError("--max_context is only valid with --target codex");
|
|
667
|
+
}
|
|
668
|
+
for (const target of selected) {
|
|
669
|
+
if (target.installed(config) && !options.force) {
|
|
670
|
+
console.log(`${target.id}: already installed; use --force to reinstall`);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
await target.install(config, options.dryRun, options.v2Off, options.maxContext);
|
|
674
|
+
}
|
|
675
|
+
return 0;
|
|
676
|
+
}
|
|
677
|
+
export async function runSync(config, requested, options) {
|
|
678
|
+
const installed = TARGETS.filter((target) => target.installed(config)).map((target) => target.id);
|
|
679
|
+
if (requested.length === 0 && !options.all) {
|
|
680
|
+
if (installed.length === 0) {
|
|
681
|
+
throw new CPACError("no installed CPAC integrations found; run cpac install first");
|
|
682
|
+
}
|
|
683
|
+
return runInstall(config, installed, {
|
|
684
|
+
all: false,
|
|
685
|
+
dryRun: options.dryRun,
|
|
686
|
+
force: true,
|
|
687
|
+
v2Off: options.v2Off,
|
|
688
|
+
maxContext: options.maxContext,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
return runInstall(config, requested, {
|
|
692
|
+
all: options.all,
|
|
693
|
+
dryRun: options.dryRun,
|
|
694
|
+
force: true,
|
|
695
|
+
v2Off: options.v2Off,
|
|
696
|
+
maxContext: options.maxContext,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
export async function runUninstall(config, requested, options) {
|
|
700
|
+
const eligible = TARGETS.filter((target) => target.installed(config)).map((target) => target.id);
|
|
701
|
+
const selected = selectTargets(requested, options.all, eligible);
|
|
702
|
+
if (selected.length === 0) {
|
|
703
|
+
throw new CPACError("no installed CPAC integrations found; use --target <id> or --all");
|
|
704
|
+
}
|
|
705
|
+
for (const target of selected) {
|
|
706
|
+
if (!target.installed(config)) {
|
|
707
|
+
console.log(`${target.id}: not installed`);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
await target.uninstall(config, options.dryRun);
|
|
711
|
+
}
|
|
712
|
+
return 0;
|
|
713
|
+
}
|
|
714
|
+
export async function runRestore(config, requested, options) {
|
|
715
|
+
if (requested.length > 0 || options.all) {
|
|
716
|
+
return runUninstall(config, requested, options);
|
|
717
|
+
}
|
|
718
|
+
if (options.dryRun) {
|
|
719
|
+
console.log("would restore the original Codex config");
|
|
720
|
+
return 0;
|
|
721
|
+
}
|
|
722
|
+
await restore(config);
|
|
723
|
+
return 0;
|
|
724
|
+
}
|
|
725
|
+
function compareVersions(a, b) {
|
|
726
|
+
const left = a.split(".").map(Number);
|
|
727
|
+
const right = b.split(".").map(Number);
|
|
728
|
+
for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
|
729
|
+
const l = left[index] ?? 0;
|
|
730
|
+
const r = right[index] ?? 0;
|
|
731
|
+
if (l !== r)
|
|
732
|
+
return l > r ? 1 : -1;
|
|
733
|
+
}
|
|
734
|
+
return 0;
|
|
735
|
+
}
|
|
736
|
+
export async function runUpgrade(config, checkOnly) {
|
|
737
|
+
console.log(`Current version: ${CPAC_VERSION}`);
|
|
738
|
+
let latest;
|
|
739
|
+
try {
|
|
740
|
+
const response = await fetch("https://registry.npmjs.org/@yhong91/cpac/latest", {
|
|
741
|
+
headers: { Accept: "application/json" },
|
|
742
|
+
signal: AbortSignal.timeout(10_000),
|
|
743
|
+
});
|
|
744
|
+
if (response.ok) {
|
|
745
|
+
const data = (await response.json());
|
|
746
|
+
if (typeof data.version === "string")
|
|
747
|
+
latest = data.version;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
latest = undefined;
|
|
752
|
+
}
|
|
753
|
+
if (!latest) {
|
|
754
|
+
console.error("Could not fetch latest version from npm. Check your network connection.");
|
|
755
|
+
return 1;
|
|
756
|
+
}
|
|
757
|
+
console.log(`Latest version: ${latest}`);
|
|
758
|
+
const upToDate = compareVersions(CPAC_VERSION, latest) >= 0;
|
|
759
|
+
if (checkOnly) {
|
|
760
|
+
if (upToDate)
|
|
761
|
+
console.log("Already up to date.");
|
|
762
|
+
else
|
|
763
|
+
console.log("Update available. Run: cpac upgrade");
|
|
764
|
+
return 0;
|
|
765
|
+
}
|
|
766
|
+
if (upToDate) {
|
|
767
|
+
console.log("Already up to date.");
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
console.log(`Installing @yhong91/cpac@${latest}`);
|
|
771
|
+
const result = spawnSync("npm", ["install", "-g", `@yhong91/cpac@${latest}`], { stdio: "inherit" });
|
|
772
|
+
if (result.status !== 0) {
|
|
773
|
+
console.error("npm install failed");
|
|
774
|
+
return result.status ?? 1;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const installed = TARGETS.filter((target) => target.installed(config)).map((target) => target.id);
|
|
778
|
+
if (installed.length === 0) {
|
|
779
|
+
console.log("No installed agents to sync.");
|
|
780
|
+
return 0;
|
|
781
|
+
}
|
|
782
|
+
console.log(`Syncing ${installed.join(", ")}`);
|
|
783
|
+
return runSync(config, [], { all: false, dryRun: false });
|
|
784
|
+
}
|