@ar-ai/brink-cli 0.0.0-stage → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -2
- package/package.json +18 -4
- package/support/activity.js +102 -0
- package/support/brand.js +110 -0
- package/support/bridge-stream.js +109 -0
- package/support/brink.js +275 -0
- package/support/chat-bridge.js +1093 -0
- package/support/client-setup.js +620 -0
- package/support/messages-bridge.js +317 -0
- package/support/messages-request.js +442 -0
- package/support/model-catalog.js +200 -0
- package/support/monitor.js +443 -0
- package/support/proxy.js +480 -0
- package/support/setup-ui.js +333 -0
- package/support/terminal-ui.js +630 -0
- package/support/transport.js +485 -0
- package/support/usage.js +144 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
const { constants } = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { spawn } = require('node:child_process');
|
|
8
|
+
const { createHash, randomBytes } = require('node:crypto');
|
|
9
|
+
const { adaptModelCatalog } = require('./model-catalog');
|
|
10
|
+
|
|
11
|
+
const RELEASES = 'https://downloads.claude.ai/claude-code-releases';
|
|
12
|
+
const MIN_CLAUDE = [2, 1, 242];
|
|
13
|
+
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
14
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
const notify = (callback, label, detail) => callback?.({ label, detail });
|
|
16
|
+
const homes = options => ({
|
|
17
|
+
codex: path.resolve(options.codexHome || process.env.CODEX_HOME || path.join(os.homedir(), '.codex')),
|
|
18
|
+
claude: path.resolve(options.claudeHome || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function checkClient(client) {
|
|
22
|
+
if (client !== 'claude' && client !== 'codex') throw new Error('Choose Claude Code or Codex.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function abortError() {
|
|
26
|
+
const error = new Error('Client installation cancelled.');
|
|
27
|
+
error.name = 'AbortError';
|
|
28
|
+
return error;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function checkAbort(signal) {
|
|
32
|
+
if (signal?.aborted) throw abortError();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function accessible(filename) {
|
|
36
|
+
try {
|
|
37
|
+
await fs.access(filename, process.platform === 'win32' ? constants.F_OK : constants.X_OK);
|
|
38
|
+
return (await fs.stat(filename)).isFile();
|
|
39
|
+
} catch { return false; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function searchDirectories() {
|
|
43
|
+
return [...new Set([
|
|
44
|
+
...(process.env.PATH || '').split(path.delimiter).filter(Boolean),
|
|
45
|
+
path.join(os.homedir(), '.local', 'bin'),
|
|
46
|
+
path.join(os.homedir(), '.local'),
|
|
47
|
+
path.join(os.homedir(), '.npm-global', 'bin'),
|
|
48
|
+
path.join(os.homedir(), '.npm-global'),
|
|
49
|
+
path.dirname(process.execPath),
|
|
50
|
+
...(process.env.APPDATA ? [path.join(process.env.APPDATA, 'npm')] : []),
|
|
51
|
+
])];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function findExecutables(name) {
|
|
55
|
+
const names = process.platform === 'win32' ? [`${name}.exe`, `${name}.cmd`, name] : [name];
|
|
56
|
+
const found = [];
|
|
57
|
+
for (const directory of searchDirectories()) {
|
|
58
|
+
for (const filename of names) {
|
|
59
|
+
const candidate = path.resolve(directory, filename);
|
|
60
|
+
if (await accessible(candidate)) found.push(candidate);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return [...new Set(found)];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Windows npm shims need cmd.exe, which treats arguments as shell text. Resolve
|
|
67
|
+
// only the known npm entrypoints instead, and pass every argument directly to Node.
|
|
68
|
+
async function invocation(executable, args) {
|
|
69
|
+
if (process.platform !== 'win32' || !/\.(?:cmd|bat)$/i.test(executable)) {
|
|
70
|
+
return { executable, args };
|
|
71
|
+
}
|
|
72
|
+
const name = path.basename(executable).replace(/\.(?:cmd|bat)$/i, '').toLowerCase();
|
|
73
|
+
const entry = {
|
|
74
|
+
npm: ['npm', 'bin', 'npm-cli.js'],
|
|
75
|
+
codex: ['@openai', 'codex', 'bin', 'codex.js'],
|
|
76
|
+
claude: ['@anthropic-ai', 'claude-code', 'cli.js'],
|
|
77
|
+
}[name];
|
|
78
|
+
if (entry) {
|
|
79
|
+
const directory = path.dirname(executable);
|
|
80
|
+
for (const modules of [path.join(directory, 'node_modules'), path.join(directory, '..', 'lib', 'node_modules')]) {
|
|
81
|
+
const script = path.join(modules, ...entry);
|
|
82
|
+
try {
|
|
83
|
+
if ((await fs.stat(script)).isFile()) return { executable: process.execPath, args: [script, ...args] };
|
|
84
|
+
} catch { /* Try the next standard npm layout. */ }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`Cannot safely run ${name}'s Windows shim. Reinstall its official native or npm package.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function run(executable, args, { signal, timeout = 30000, env = process.env } = {}) {
|
|
91
|
+
checkAbort(signal);
|
|
92
|
+
const command = await invocation(executable, args);
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
let output = '';
|
|
95
|
+
let timedOut = false;
|
|
96
|
+
let killTimer;
|
|
97
|
+
const child = spawn(command.executable, command.args, {
|
|
98
|
+
env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], shell: false,
|
|
99
|
+
});
|
|
100
|
+
const stop = () => {
|
|
101
|
+
child.kill('SIGTERM');
|
|
102
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 3000);
|
|
103
|
+
killTimer.unref();
|
|
104
|
+
};
|
|
105
|
+
const timer = setTimeout(() => { timedOut = true; stop(); }, timeout);
|
|
106
|
+
timer.unref();
|
|
107
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
108
|
+
if (signal?.aborted) stop();
|
|
109
|
+
const collect = chunk => { if (output.length < 8192) output += chunk.toString().slice(0, 8192 - output.length); };
|
|
110
|
+
child.stdout.on('data', collect);
|
|
111
|
+
child.stderr.on('data', collect);
|
|
112
|
+
const cleanup = () => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
clearTimeout(killTimer);
|
|
115
|
+
signal?.removeEventListener('abort', stop);
|
|
116
|
+
};
|
|
117
|
+
child.once('error', error => {
|
|
118
|
+
cleanup();
|
|
119
|
+
reject(signal?.aborted ? abortError() : new Error(`Could not start ${path.basename(executable)} (${error.code || 'spawn error'}).`));
|
|
120
|
+
});
|
|
121
|
+
child.once('close', code => {
|
|
122
|
+
cleanup();
|
|
123
|
+
if (signal?.aborted) reject(abortError());
|
|
124
|
+
else if (timedOut) reject(new Error(`${path.basename(executable)} timed out. Check your connection and try again.`));
|
|
125
|
+
else if (code !== 0) reject(new Error(`${path.basename(executable)} exited with code ${code ?? 'signal'}.`));
|
|
126
|
+
else resolve(output);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function clientVersion(client, output) {
|
|
132
|
+
const match = output.match(client === 'claude' ? /\b(\d+\.\d+\.\d+(?:-[\w.-]+)?)\b/ : /\bcodex(?:-cli)?\s+(\d+\.\d+\.\d+(?:-[\w.-]+)?)\b/i);
|
|
133
|
+
return match?.[1] || null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function supportsPicker(version) {
|
|
137
|
+
const parts = version.split(/[.-]/).slice(0, 3).map(Number);
|
|
138
|
+
for (let index = 0; index < 3; index++) {
|
|
139
|
+
if (parts[index] !== MIN_CLAUDE[index]) return parts[index] > MIN_CLAUDE[index];
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function installedClient(client, signal) {
|
|
145
|
+
for (const executable of await findExecutables(client)) {
|
|
146
|
+
try {
|
|
147
|
+
const version = clientVersion(client, await run(executable, ['--version'], { signal }));
|
|
148
|
+
if (version) return { executable, version };
|
|
149
|
+
} catch (error) { if (error.name === 'AbortError') throw error; }
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function response(url, signal) {
|
|
155
|
+
checkAbort(signal);
|
|
156
|
+
const timeout = AbortSignal.timeout(180000);
|
|
157
|
+
let result;
|
|
158
|
+
try {
|
|
159
|
+
result = await fetch(url, {
|
|
160
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
161
|
+
headers: { 'accept-encoding': 'identity' },
|
|
162
|
+
});
|
|
163
|
+
} catch {
|
|
164
|
+
checkAbort(signal);
|
|
165
|
+
throw new Error('Could not reach the official Claude download service. Check your network and try brink setup again.');
|
|
166
|
+
}
|
|
167
|
+
if (!result.ok || !result.body) {
|
|
168
|
+
await result.body?.cancel();
|
|
169
|
+
throw new Error(`Official Claude download failed (HTTP ${result.status}). Check your network or supported region.`);
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function nativePlatform(signal) {
|
|
175
|
+
if (!['darwin', 'linux', 'win32'].includes(process.platform) || !['arm64', 'x64'].includes(process.arch)) {
|
|
176
|
+
throw new Error('Claude Code requires a supported 64-bit Windows, macOS, or Linux machine. See https://code.claude.com/docs/en/setup.');
|
|
177
|
+
}
|
|
178
|
+
let arch = process.arch;
|
|
179
|
+
if (process.platform === 'darwin' && arch === 'x64') {
|
|
180
|
+
try { if ((await run('/usr/sbin/sysctl', ['-n', 'sysctl.proc_translated'], { signal })).trim() === '1') arch = 'arm64'; }
|
|
181
|
+
catch (error) { if (error.name === 'AbortError') throw error; }
|
|
182
|
+
}
|
|
183
|
+
if (process.platform === 'win32' && /ARM64/i.test(process.env.PROCESSOR_ARCHITEW6432 || process.env.PROCESSOR_ARCHITECTURE || '')) arch = 'arm64';
|
|
184
|
+
let suffix = '';
|
|
185
|
+
if (process.platform === 'linux') {
|
|
186
|
+
const musl = path.join('/lib', arch === 'arm64' ? 'libc.musl-aarch64.so.1' : 'libc.musl-x86_64.so.1');
|
|
187
|
+
try { await fs.access(musl); suffix = '-musl'; } catch { /* glibc */ }
|
|
188
|
+
}
|
|
189
|
+
return `${process.platform}-${arch}${suffix}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function installClaude(onProgress, signal) {
|
|
193
|
+
const platform = await nativePlatform(signal);
|
|
194
|
+
notify(onProgress, 'Getting official Claude Code release', platform);
|
|
195
|
+
const version = (await (await response(`${RELEASES}/latest`, signal)).text()).trim();
|
|
196
|
+
if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version)) throw new Error('The official Claude service returned an invalid release version. Try again later.');
|
|
197
|
+
const manifest = await (await response(`${RELEASES}/${version}/manifest.json`, signal)).json();
|
|
198
|
+
const release = manifest.platforms?.[platform];
|
|
199
|
+
if (!release || !/^[a-f0-9]{64}$/i.test(release.checksum)) throw new Error('No verified official Claude download is available for this platform.');
|
|
200
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'brink-claude-'));
|
|
201
|
+
const filename = path.join(directory, process.platform === 'win32' ? 'claude.exe' : 'claude');
|
|
202
|
+
try {
|
|
203
|
+
const download = await response(`${RELEASES}/${version}/${platform}/${path.basename(filename)}`, signal);
|
|
204
|
+
const declared = Number(download.headers.get('content-length'));
|
|
205
|
+
const expected = Number.isSafeInteger(release.size) && release.size > 0 ? release.size : 0;
|
|
206
|
+
const total = Number.isSafeInteger(declared) && declared > 0 ? declared : expected;
|
|
207
|
+
const hash = createHash('sha256');
|
|
208
|
+
const handle = await fs.open(filename, 'wx', 0o600);
|
|
209
|
+
let bytes = 0;
|
|
210
|
+
let lastUpdate = 0;
|
|
211
|
+
const progress = () => {
|
|
212
|
+
const detail = `${(bytes / 1048576).toFixed(1)} MiB${total ? ` / ${(total / 1048576).toFixed(1)} MiB` : ' downloaded'}`;
|
|
213
|
+
onProgress?.({ label: 'Downloading Claude Code', detail, ...(total ? { percent: Math.min(100, bytes * 100 / total) } : {}) });
|
|
214
|
+
};
|
|
215
|
+
try {
|
|
216
|
+
progress();
|
|
217
|
+
for await (const chunk of download.body) {
|
|
218
|
+
checkAbort(signal);
|
|
219
|
+
await handle.writeFile(chunk);
|
|
220
|
+
hash.update(chunk);
|
|
221
|
+
bytes += chunk.length;
|
|
222
|
+
if (Date.now() - lastUpdate >= 100) { progress(); lastUpdate = Date.now(); }
|
|
223
|
+
}
|
|
224
|
+
progress();
|
|
225
|
+
} finally { await handle.close(); }
|
|
226
|
+
if ((expected && bytes !== expected) || hash.digest('hex') !== release.checksum.toLowerCase()) {
|
|
227
|
+
throw new Error('Claude Code download checksum or size did not match. Nothing was installed; try again.');
|
|
228
|
+
}
|
|
229
|
+
await fs.chmod(filename, 0o700);
|
|
230
|
+
notify(onProgress, 'Installing Claude Code', 'Official native installer; waiting for completion');
|
|
231
|
+
await run(filename, ['install', version], { signal, timeout: 600000 });
|
|
232
|
+
const executable = path.join(os.homedir(), '.local', 'bin', process.platform === 'win32' ? 'claude.exe' : 'claude');
|
|
233
|
+
if (await accessible(executable)) {
|
|
234
|
+
const installedVersion = clientVersion('claude', await run(executable, ['--version'], { signal }));
|
|
235
|
+
if (installedVersion && supportsPicker(installedVersion)) return { executable, version: installedVersion };
|
|
236
|
+
}
|
|
237
|
+
const found = await installedClient('claude', signal);
|
|
238
|
+
if (!found || !supportsPicker(found.version)) throw new Error('Claude installation finished, but the new command was not found. Add ~/.local/bin to PATH and run brink setup again.');
|
|
239
|
+
return found;
|
|
240
|
+
} finally { await fs.rm(directory, { recursive: true, force: true }); }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function installCodex(onProgress, signal) {
|
|
244
|
+
const npms = await findExecutables('npm');
|
|
245
|
+
let npm;
|
|
246
|
+
for (const candidate of npms) {
|
|
247
|
+
try { await run(candidate, ['--version'], { signal }); npm = candidate; break; }
|
|
248
|
+
catch (error) { if (error.name === 'AbortError') throw error; }
|
|
249
|
+
}
|
|
250
|
+
if (!npm) throw new Error('Codex needs Node.js with npm. Install Node.js LTS from https://nodejs.org, reopen your terminal, and run brink setup again. No sudo is needed.');
|
|
251
|
+
const prefix = path.join(os.homedir(), '.local');
|
|
252
|
+
notify(onProgress, 'Installing Codex', 'Downloading and installing @openai/codex in your user folder; npm does not report a reliable total');
|
|
253
|
+
try {
|
|
254
|
+
await run(npm, ['install', '--global', '--prefix', prefix, '--no-audit', '--no-fund', '--loglevel=error', '@openai/codex@latest'], {
|
|
255
|
+
signal, timeout: 600000,
|
|
256
|
+
env: { ...process.env, PATH: [path.dirname(process.execPath), process.env.PATH || ''].join(path.delimiter) },
|
|
257
|
+
});
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error.name === 'AbortError') throw error;
|
|
260
|
+
throw new Error(`${error.message} Codex was not configured. Check npm registry access and write permission to ${prefix}, then run brink setup again.`);
|
|
261
|
+
}
|
|
262
|
+
const executable = path.join(prefix, process.platform === 'win32' ? 'codex.cmd' : 'bin/codex');
|
|
263
|
+
const version = clientVersion('codex', await run(executable, ['--version'], { signal }));
|
|
264
|
+
if (!version) throw new Error('The Codex package installed but its version could not be read. Reopen your terminal and run brink setup again.');
|
|
265
|
+
return { executable, version };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function ensureClient(client, { onProgress, signal } = {}) {
|
|
269
|
+
checkClient(client);
|
|
270
|
+
checkAbort(signal);
|
|
271
|
+
const name = client === 'claude' ? 'Claude Code' : 'Codex';
|
|
272
|
+
const existing = await installedClient(client, signal);
|
|
273
|
+
if (existing && (client !== 'claude' || supportsPicker(existing.version))) {
|
|
274
|
+
notify(onProgress, `${name} ready`, existing.version);
|
|
275
|
+
return existing;
|
|
276
|
+
}
|
|
277
|
+
notify(onProgress, existing ? 'Updating Claude Code for the full model picker' : `${name} unavailable; installing`, existing ? 'Model picker requires Claude Code 2.1.242 or later' : 'User installation; no administrator access needed');
|
|
278
|
+
try {
|
|
279
|
+
const installed = client === 'claude' ? await installClaude(onProgress, signal) : await installCodex(onProgress, signal);
|
|
280
|
+
notify(onProgress, `${name} installed`, installed.version);
|
|
281
|
+
return installed;
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (error.name !== 'AbortError') notify(onProgress, `${name} installation failed`, error.message);
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function readOptional(filename) {
|
|
289
|
+
try { return await fs.readFile(filename, 'utf8'); }
|
|
290
|
+
catch (error) {
|
|
291
|
+
if (error.code === 'ENOENT') return null;
|
|
292
|
+
throw new Error(`Cannot read ${filename} (${error.code || 'file error'}). Existing configuration was not changed.`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function parseSettings(text, filename) {
|
|
297
|
+
if (text === null) return {};
|
|
298
|
+
try {
|
|
299
|
+
const settings = JSON.parse(text.replace(/^\uFEFF/, ''));
|
|
300
|
+
if (!record(settings) || (own(settings, 'env') && !record(settings.env))) throw new Error();
|
|
301
|
+
return settings;
|
|
302
|
+
} catch { throw new Error(`Invalid JSON settings in ${filename}. Fix this file first; it was not changed.`); }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let tomlParser;
|
|
306
|
+
function parseToml(text, filename) {
|
|
307
|
+
if (!tomlParser) {
|
|
308
|
+
try { tomlParser = require('toml-eslint-parser'); }
|
|
309
|
+
catch { throw new Error('Brink needs its TOML parser. Reinstall Brink, or run npm install in the Brink installation folder.'); }
|
|
310
|
+
}
|
|
311
|
+
try { return tomlParser.parseTOML(text, { tomlVersion: '1.0' }); }
|
|
312
|
+
catch { throw new Error(`Invalid TOML in ${filename}. Fix this file first; it was not changed.`); }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const keyParts = key => key.keys.map(part => part.type === 'TOMLBare' ? part.name : part.value);
|
|
316
|
+
const startsWith = (parts, prefix) => parts.length >= prefix.length && prefix.every((part, index) => part === parts[index]);
|
|
317
|
+
const samePath = (left, right) => left.length === right.length && startsWith(left, right);
|
|
318
|
+
const tomlKey = parts => parts.map(part => /^[A-Za-z0-9_-]+$/.test(part) ? part : JSON.stringify(part)).join('.');
|
|
319
|
+
const tomlValue = value => typeof value === 'string' ? JSON.stringify(value).replace(/\u007f/gi, '\\u007f') : String(value);
|
|
320
|
+
|
|
321
|
+
function tomlEntries(ast) {
|
|
322
|
+
const entries = [];
|
|
323
|
+
const walk = (body, prefix) => {
|
|
324
|
+
for (const node of body) {
|
|
325
|
+
if (node.type === 'TOMLTable') {
|
|
326
|
+
if (node.kind === 'standard') walk(node.body, node.resolvedKey);
|
|
327
|
+
} else {
|
|
328
|
+
const parts = [...prefix, ...keyParts(node.key)];
|
|
329
|
+
entries.push({ node, parts });
|
|
330
|
+
if (node.value.type === 'TOMLInlineTable') walk(node.value.body, parts);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
walk(ast.body[0].body, []);
|
|
335
|
+
return entries;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function headerEnd(ast, table) {
|
|
339
|
+
const closing = ast.tokens.find(token => token.range[0] >= table.key.range[1] && token.value === ']');
|
|
340
|
+
if (!closing) throw new Error('Cannot locate the TOML table header safely. Configuration was not changed.');
|
|
341
|
+
return closing.range[1];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Mutate value spans, not lines: quoted/dotted keys and multiline strings keep
|
|
345
|
+
// their surrounding comments, whitespace, and all unrelated provider tables.
|
|
346
|
+
function setToml(text, parts, value, filename) {
|
|
347
|
+
const ast = parseToml(text, filename);
|
|
348
|
+
const entries = tomlEntries(ast);
|
|
349
|
+
const existing = entries.find(entry => samePath(entry.parts, parts));
|
|
350
|
+
const literal = tomlValue(value);
|
|
351
|
+
if (existing) {
|
|
352
|
+
const [start, end] = existing.node.value.range;
|
|
353
|
+
return text.slice(0, start) + literal + text.slice(end);
|
|
354
|
+
}
|
|
355
|
+
const inline = entries.filter(entry => entry.node.value.type === 'TOMLInlineTable' && startsWith(parts, entry.parts)).sort((a, b) => b.parts.length - a.parts.length)[0];
|
|
356
|
+
if (inline) {
|
|
357
|
+
const node = inline.node.value;
|
|
358
|
+
const offset = node.range[1] - 1;
|
|
359
|
+
return text.slice(0, offset) + `${node.body.length ? ',' : ''} ${tomlKey(parts.slice(inline.parts.length))} = ${literal} ` + text.slice(offset);
|
|
360
|
+
}
|
|
361
|
+
const newline = text.includes('\r\n') ? '\r\n' : '\n';
|
|
362
|
+
const tables = ast.body[0].body.filter(node => node.type === 'TOMLTable');
|
|
363
|
+
const table = tables.filter(node => node.kind === 'standard' && startsWith(parts, node.resolvedKey)).sort((a, b) => b.resolvedKey.length - a.resolvedKey.length)[0];
|
|
364
|
+
if (table) {
|
|
365
|
+
const end = headerEnd(ast, table);
|
|
366
|
+
const lineEnd = text.indexOf('\n', end);
|
|
367
|
+
const offset = lineEnd === -1 ? text.length : lineEnd + 1;
|
|
368
|
+
return text.slice(0, offset) + (lineEnd === -1 ? newline : '') + `${tomlKey(parts.slice(table.resolvedKey.length))} = ${literal}${newline}` + text.slice(offset);
|
|
369
|
+
}
|
|
370
|
+
const offset = tables[0]?.range[0] ?? text.length;
|
|
371
|
+
return text.slice(0, offset) + (offset && text[offset - 1] !== '\n' ? newline : '') + `${tomlKey(parts)} = ${literal}${newline}` + text.slice(offset);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function removeToml(text, parts, filename) {
|
|
375
|
+
const ast = parseToml(text, filename);
|
|
376
|
+
const entries = tomlEntries(ast);
|
|
377
|
+
const match = entries.find(entry => samePath(entry.parts, parts));
|
|
378
|
+
const spans = [];
|
|
379
|
+
if (match) {
|
|
380
|
+
const node = match.node;
|
|
381
|
+
let [start, end] = node.range;
|
|
382
|
+
if (node.parent.type === 'TOMLInlineTable') {
|
|
383
|
+
const siblings = node.parent.body;
|
|
384
|
+
const index = siblings.indexOf(node);
|
|
385
|
+
if (index < siblings.length - 1) end = siblings[index + 1].range[0];
|
|
386
|
+
else if (index > 0) start = siblings[index - 1].range[1];
|
|
387
|
+
}
|
|
388
|
+
spans.push([start, end]);
|
|
389
|
+
} else {
|
|
390
|
+
for (const entry of entries) {
|
|
391
|
+
if (startsWith(entry.parts, parts) && entry.node.parent.type !== 'TOMLInlineTable') spans.push(entry.node.range);
|
|
392
|
+
}
|
|
393
|
+
for (const table of ast.body[0].body) {
|
|
394
|
+
if (table.type === 'TOMLTable' && startsWith(table.resolvedKey, parts)) spans.push([table.range[0], headerEnd(ast, table)]);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
for (const [start, end] of spans.sort((a, b) => b[0] - a[0])) text = text.slice(0, start) + text.slice(end);
|
|
398
|
+
return text;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function atomicWrite(filename, content, expected) {
|
|
402
|
+
await fs.mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
|
|
403
|
+
try {
|
|
404
|
+
const stat = await fs.lstat(filename);
|
|
405
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Refusing to replace non-regular configuration ${filename}.`);
|
|
406
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
407
|
+
const temporary = `${filename}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
408
|
+
let handle;
|
|
409
|
+
try {
|
|
410
|
+
handle = await fs.open(temporary, 'wx', 0o600);
|
|
411
|
+
await handle.writeFile(content, 'utf8');
|
|
412
|
+
await handle.sync();
|
|
413
|
+
await handle.close();
|
|
414
|
+
handle = null;
|
|
415
|
+
if (expected !== undefined && await readOptional(filename) !== expected) throw new Error(`Configuration changed during setup: ${filename}. Run brink setup again; the other edit was preserved.`);
|
|
416
|
+
await fs.rename(temporary, filename);
|
|
417
|
+
} finally {
|
|
418
|
+
await handle?.close();
|
|
419
|
+
await fs.rm(temporary, { force: true });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function validateSelection({ apiKey, model, modelIds, port }) {
|
|
424
|
+
if (typeof apiKey !== 'string' || !apiKey.trim() || /[\x00-\x20\x7f]/.test(apiKey)) throw new Error('Enter a non-empty AgentRouter API key without whitespace.');
|
|
425
|
+
if (typeof model !== 'string' || !model || model.trim() !== model || /[\x00-\x1f\x7f]/.test(model)) throw new Error('Choose an exact model ID from the AgentRouter catalog.');
|
|
426
|
+
if (!Array.isArray(modelIds) || modelIds.some(id => typeof id !== 'string' || !id || id.trim() !== id || /[\x00-\x1f\x7f]/.test(id))) throw new Error('AgentRouter model IDs must be non-empty exact strings.');
|
|
427
|
+
if (!modelIds.includes(model)) throw new Error('The selected model is not in the supplied AgentRouter catalog.');
|
|
428
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Choose a local port between 1 and 65535.');
|
|
429
|
+
return [...new Set(modelIds)];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function configureClient(client, options = {}) {
|
|
433
|
+
checkClient(client);
|
|
434
|
+
const { apiKey, model, modelIds = [], port = 8787, onProgress } = options;
|
|
435
|
+
const ids = validateSelection({ apiKey, model, modelIds, port });
|
|
436
|
+
const directories = homes(options);
|
|
437
|
+
if (client === 'claude') {
|
|
438
|
+
const configPath = path.join(directories.claude, 'settings.json');
|
|
439
|
+
const previous = await readOptional(configPath);
|
|
440
|
+
const settings = parseSettings(previous, configPath);
|
|
441
|
+
settings.env = {
|
|
442
|
+
...settings.env,
|
|
443
|
+
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
|
444
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
445
|
+
// A blank setting overrides an inherited API key; deletion would restore it.
|
|
446
|
+
ANTHROPIC_API_KEY: '',
|
|
447
|
+
BRINK_GATEWAY_PROVIDER: 'agentrouter',
|
|
448
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
|
|
449
|
+
CLAUDE_CODE_MAX_RETRIES: '0',
|
|
450
|
+
CLAUDE_CODE_RETRY_WATCHDOG: '0',
|
|
451
|
+
CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: '1',
|
|
452
|
+
};
|
|
453
|
+
delete settings.env.ANTHROPIC_MODEL;
|
|
454
|
+
for (const flag of ['CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_ANTHROPIC_AWS']) settings.env[flag] = '';
|
|
455
|
+
settings.model = model;
|
|
456
|
+
settings.fallbackModel = [];
|
|
457
|
+
settings.modelPicker = {
|
|
458
|
+
...(record(settings.modelPicker) ? settings.modelPicker : {}),
|
|
459
|
+
options: ids.map(id => ({ model: id, label: id })),
|
|
460
|
+
replaceBuiltInOptions: true,
|
|
461
|
+
};
|
|
462
|
+
if (Array.isArray(settings.availableModels))
|
|
463
|
+
settings.availableModels = [...new Set([...settings.availableModels, ...ids])];
|
|
464
|
+
await atomicWrite(configPath, `${JSON.stringify(settings, null, 2)}\n`, previous);
|
|
465
|
+
notify(onProgress, 'Claude Code configured', `Messages API at http://127.0.0.1:${port}; gateway token saved privately`);
|
|
466
|
+
notify(onProgress, 'Claude model compatibility', 'All supplied IDs are configured for /model. GPT requests use the Responses bridge; other models require gateway Messages support. Managed restrictions can still apply.');
|
|
467
|
+
return { configPath };
|
|
468
|
+
}
|
|
469
|
+
const configPath = path.join(directories.codex, 'config.toml');
|
|
470
|
+
const previous = await readOptional(configPath);
|
|
471
|
+
let text = previous ?? '';
|
|
472
|
+
parseToml(text, configPath);
|
|
473
|
+
const catalog = `${JSON.stringify(adaptModelCatalog({ data: ids.map(id => ({ id })) }), null, 2)}\n`;
|
|
474
|
+
const digest = createHash('sha256').update(catalog).digest('hex').slice(0, 16);
|
|
475
|
+
const catalogPath = path.join(directories.codex, `brink-models-${digest}.json`);
|
|
476
|
+
const provider = ['model_providers', 'brink'];
|
|
477
|
+
// Brink owns this provider. Saved OAuth and unrelated providers stay intact;
|
|
478
|
+
// a recognized legacy AgentRouter credential is rotated below as requested.
|
|
479
|
+
for (const field of ['env_key', 'env_key_instructions', 'auth', 'http_headers', 'env_http_headers', 'query_params']) text = removeToml(text, [...provider, field], configPath);
|
|
480
|
+
for (const [field, value] of Object.entries({
|
|
481
|
+
name: 'Brink / AgentRouter',
|
|
482
|
+
base_url: `http://127.0.0.1:${port}/v1`,
|
|
483
|
+
wire_api: 'responses',
|
|
484
|
+
experimental_bearer_token: apiKey,
|
|
485
|
+
requires_openai_auth: false,
|
|
486
|
+
request_max_retries: 0,
|
|
487
|
+
stream_max_retries: 0,
|
|
488
|
+
supports_websockets: false,
|
|
489
|
+
})) text = setToml(text, [...provider, field], value, configPath);
|
|
490
|
+
for (const [field, value] of Object.entries({ model, model_provider: 'brink', model_catalog_json: catalogPath })) text = setToml(text, [field], value, configPath);
|
|
491
|
+
const legacyEntries = tomlEntries(parseToml(text, configPath));
|
|
492
|
+
const legacyBase = legacyEntries.find(entry => samePath(entry.parts, ['model_providers', 'agentrouter', 'base_url']))?.node.value.value;
|
|
493
|
+
if (agentRouterUrl(legacyBase, true)) {
|
|
494
|
+
text = setToml(text, ['model_providers', 'agentrouter', 'experimental_bearer_token'], apiKey, configPath);
|
|
495
|
+
for (const field of ['env_key', 'env_key_instructions', 'auth']) text = removeToml(text, ['model_providers', 'agentrouter', field], configPath);
|
|
496
|
+
}
|
|
497
|
+
parseToml(text, configPath);
|
|
498
|
+
// Content-addressed catalogs leave the previous working catalog untouched if
|
|
499
|
+
// saving config fails, and never overwrite a user's unrelated catalog file.
|
|
500
|
+
await atomicWrite(catalogPath, catalog);
|
|
501
|
+
await atomicWrite(configPath, text, previous);
|
|
502
|
+
notify(onProgress, 'Codex configured', `Responses API at http://127.0.0.1:${port}/v1; gateway token saved privately`);
|
|
503
|
+
const hasProfile = tomlEntries(parseToml(text, configPath)).some(entry => samePath(entry.parts, ['profile']));
|
|
504
|
+
if (hasProfile) notify(onProgress, 'Existing Codex profile preserved', 'A selected profile can override your model or provider; launch without that profile to use Brink.');
|
|
505
|
+
return { configPath, catalogPath };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function agentRouterUrl(value, namedProvider = false) {
|
|
509
|
+
if (typeof value !== 'string') return false;
|
|
510
|
+
try {
|
|
511
|
+
const url = new URL(value);
|
|
512
|
+
if (url.protocol === 'https:' && ['agentrouter.org', 'api.agentrouter.org', 'agentrouter.us', 'api.agentrouter.us'].includes(url.hostname)) return true;
|
|
513
|
+
return namedProvider && ['http:', 'https:'].includes(url.protocol) && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
|
|
514
|
+
} catch { return false; }
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function existingKey(options = {}) {
|
|
518
|
+
const directories = homes(options);
|
|
519
|
+
const configPath = path.join(directories.codex, 'config.toml');
|
|
520
|
+
const text = await readOptional(configPath);
|
|
521
|
+
if (text !== null) {
|
|
522
|
+
const entries = tomlEntries(parseToml(text, configPath));
|
|
523
|
+
const value = parts => entries.find(entry => samePath(entry.parts, parts))?.node.value.value;
|
|
524
|
+
const names = [...new Set(['brink', 'agentrouter', ...entries.filter(entry => entry.parts[0] === 'model_providers').map(entry => entry.parts[1])])];
|
|
525
|
+
for (const name of names) {
|
|
526
|
+
const prefix = ['model_providers', name];
|
|
527
|
+
if (!agentRouterUrl(value([...prefix, 'base_url']), name === 'brink' || name === 'agentrouter')) continue;
|
|
528
|
+
const direct = value([...prefix, 'experimental_bearer_token']);
|
|
529
|
+
if (typeof direct === 'string' && direct.trim()) return direct;
|
|
530
|
+
const envKey = value([...prefix, 'env_key']);
|
|
531
|
+
if (typeof envKey === 'string' && typeof process.env[envKey] === 'string' && process.env[envKey].trim()) return process.env[envKey];
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const settingsPath = path.join(directories.claude, 'settings.json');
|
|
535
|
+
const settings = parseSettings(await readOptional(settingsPath), settingsPath);
|
|
536
|
+
const env = settings.env || {};
|
|
537
|
+
// A localhost Claude endpoint alone is not proof that its secret belongs to
|
|
538
|
+
// AgentRouter. Only recognize Brink's own saved setup for local endpoints.
|
|
539
|
+
const localBrink = env.BRINK_GATEWAY_PROVIDER === 'agentrouter';
|
|
540
|
+
if (agentRouterUrl(env.ANTHROPIC_BASE_URL, localBrink)) {
|
|
541
|
+
for (const field of ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY']) {
|
|
542
|
+
if (typeof env[field] === 'string' && env[field].trim()) return env[field];
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function checkClientConfig(client, options = {}) {
|
|
549
|
+
checkClient(client);
|
|
550
|
+
const directories = homes(options);
|
|
551
|
+
const { apiKey, model, modelIds = [], port = 8787 } = options;
|
|
552
|
+
const ids = validateSelection({ apiKey, model, modelIds, port });
|
|
553
|
+
const issues = [];
|
|
554
|
+
let selected = model;
|
|
555
|
+
let configPath;
|
|
556
|
+
let catalogPath;
|
|
557
|
+
if (client === 'claude') {
|
|
558
|
+
configPath = path.join(directories.claude, 'settings.json');
|
|
559
|
+
const settings = parseSettings(await readOptional(configPath), configPath);
|
|
560
|
+
if (ids.includes(settings.model)) selected = settings.model;
|
|
561
|
+
else issues.push('Default model is missing or no longer in the supplied catalog.');
|
|
562
|
+
const env = settings.env || {};
|
|
563
|
+
const required = {
|
|
564
|
+
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
|
565
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
566
|
+
ANTHROPIC_API_KEY: '',
|
|
567
|
+
BRINK_GATEWAY_PROVIDER: 'agentrouter',
|
|
568
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
|
|
569
|
+
CLAUDE_CODE_MAX_RETRIES: '0',
|
|
570
|
+
CLAUDE_CODE_RETRY_WATCHDOG: '0',
|
|
571
|
+
CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: '1',
|
|
572
|
+
};
|
|
573
|
+
if (Object.entries(required).some(([key, value]) => env[key] !== value)) issues.push('Gateway address, credential, or retry settings need repair.');
|
|
574
|
+
if (own(env, 'ANTHROPIC_MODEL')) issues.push('An obsolete model environment override is present.');
|
|
575
|
+
if (['CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_ANTHROPIC_AWS'].some(key => env[key] || process.env[key] && env[key] !== '')) issues.push('A conflicting cloud-provider override is present.');
|
|
576
|
+
const pickerIds = Array.isArray(settings.modelPicker?.options) ? settings.modelPicker.options.map(entry => entry?.model) : null;
|
|
577
|
+
if (settings.modelPicker?.replaceBuiltInOptions !== true || JSON.stringify(pickerIds) !== JSON.stringify(ids)) issues.push('The model picker needs the current exact model IDs.');
|
|
578
|
+
if (Array.isArray(settings.availableModels) && ids.some(id => !settings.availableModels.includes(id))) issues.push('The local model allowlist hides catalog models.');
|
|
579
|
+
if (!Array.isArray(settings.fallbackModel) || settings.fallbackModel.length) issues.push('Automatic model fallback needs disabling.');
|
|
580
|
+
} else {
|
|
581
|
+
configPath = path.join(directories.codex, 'config.toml');
|
|
582
|
+
const entries = tomlEntries(parseToml(await readOptional(configPath) ?? '', configPath));
|
|
583
|
+
const value = parts => entries.find(entry => samePath(entry.parts, parts))?.node.value.value;
|
|
584
|
+
if (ids.includes(value(['model']))) selected = value(['model']);
|
|
585
|
+
else issues.push('Default model is missing or no longer in the supplied catalog.');
|
|
586
|
+
const required = {
|
|
587
|
+
base_url: `http://127.0.0.1:${port}/v1`,
|
|
588
|
+
wire_api: 'responses',
|
|
589
|
+
experimental_bearer_token: apiKey,
|
|
590
|
+
requires_openai_auth: false,
|
|
591
|
+
request_max_retries: 0,
|
|
592
|
+
stream_max_retries: 0,
|
|
593
|
+
supports_websockets: false,
|
|
594
|
+
};
|
|
595
|
+
if (value(['model_provider']) !== 'brink' || Object.entries(required).some(([key, expected]) => value(['model_providers', 'brink', key]) !== expected)) issues.push('Gateway address, credential, provider, or retry settings need repair.');
|
|
596
|
+
if (entries.some(entry => startsWith(entry.parts, ['model_providers', 'brink']) && ['env_key', 'env_key_instructions', 'auth', 'http_headers', 'env_http_headers', 'query_params'].includes(entry.parts[2]))) issues.push('Conflicting Brink provider authentication or request overrides are present.');
|
|
597
|
+
const legacy = ['model_providers', 'agentrouter'];
|
|
598
|
+
if (agentRouterUrl(value([...legacy, 'base_url']), true) && (value([...legacy, 'experimental_bearer_token']) !== apiKey || entries.some(entry => startsWith(entry.parts, legacy) && ['env_key', 'env_key_instructions', 'auth'].includes(entry.parts[2])))) issues.push('The older AgentRouter provider credential needs rotation.');
|
|
599
|
+
catalogPath = value(['model_catalog_json']);
|
|
600
|
+
if (typeof catalogPath !== 'string' || !path.isAbsolute(catalogPath)) issues.push('A Brink model catalog must be configured.');
|
|
601
|
+
else {
|
|
602
|
+
try {
|
|
603
|
+
const catalog = JSON.parse(await readOptional(catalogPath) ?? 'null');
|
|
604
|
+
const slugs = catalog?.models?.map(entry => entry.slug);
|
|
605
|
+
if (!Array.isArray(slugs) || JSON.stringify([...slugs].sort()) !== JSON.stringify([...ids].sort())) issues.push('The model catalog is missing or stale.');
|
|
606
|
+
} catch { issues.push('The model catalog is unreadable or invalid.'); }
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return { needsRepair: issues.length > 0, model: selected, issues, configPath, ...(catalogPath ? { catalogPath } : {}) };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function repairClient(client, options = {}) {
|
|
613
|
+
const check = await checkClientConfig(client, options);
|
|
614
|
+
if (!check.needsRepair) return { configPath: check.configPath, ...(check.catalogPath ? { catalogPath: check.catalogPath } : {}), model: check.model };
|
|
615
|
+
notify(options.onProgress, `Repairing ${client === 'claude' ? 'Claude Code' : 'Codex'} configuration`, check.issues.join(' '));
|
|
616
|
+
const configured = await configureClient(client, { ...options, model: check.model });
|
|
617
|
+
return { ...configured, model: check.model };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
module.exports = { ensureClient, configureClient, existingKey, checkClientConfig, repairClient };
|