@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.
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs/promises");
5
+ const path = require("node:path");
6
+ const os = require("node:os");
7
+ const net = require("node:net");
8
+ const { createSetupUI } = require("./setup-ui");
9
+ const { ensureClient, configureClient, existingKey, checkClientConfig } = require("./client-setup");
10
+ const { duck, cat, credit } = require("./brand");
11
+
12
+ // Onboarding ports the key/model/config flow of AgentRouterCodexProxy.ps1.
13
+ // Unlike its temporary session restore, Brink persists only user-approved settings.
14
+ const SHIPPED_MODELS = ["claude-opus-5", "claude-opus-4-8", "gpt-6-astra", "gpt-5.6-sol", "glm-5.3", "deepseek-v4-flash"];
15
+ const configDir = process.env.BRINK_CONFIG_HOME || path.join(os.homedir(), ".config", "brink");
16
+ const configPath = path.join(configDir, "config.json");
17
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
18
+ const claudeHome = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
19
+
20
+ function safeError(error) {
21
+ return String(error?.message || error).replace(/sk-[A-Za-z0-9_-]+/g, "[redacted]").replace(/Bearer\s+\S+/gi, "Bearer [redacted]").replace(/[\x00-\x1f\x7f]/g, " ");
22
+ }
23
+ async function readConfig() {
24
+ try {
25
+ const value = JSON.parse(await fs.readFile(configPath, "utf8"));
26
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Brink settings");
27
+ return value;
28
+ } catch (error) {
29
+ if (error.code === "ENOENT") return null;
30
+ throw new Error(`Could not read ${configPath}. Existing settings were not changed.`);
31
+ }
32
+ }
33
+ async function saveConfig(config) {
34
+ await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
35
+ const temporary = `${configPath}.${process.pid}.tmp`;
36
+ try {
37
+ await fs.writeFile(temporary, JSON.stringify(config, null, 2) + "\n", { mode: 0o600, flag: "wx" });
38
+ await fs.rename(temporary, configPath);
39
+ await fs.chmod(configPath, 0o600);
40
+ } finally { await fs.rm(temporary, { force: true }); }
41
+ }
42
+ function usableIds(catalog) {
43
+ const entries = Array.isArray(catalog?.data) ? catalog.data : Array.isArray(catalog?.models) ? catalog.models : [];
44
+ return [...new Set(entries.map((entry) => typeof entry === "string" ? entry : entry?.id || entry?.slug).filter((id) => typeof id === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(id)))];
45
+ }
46
+ async function loadModels(apiKey, ui, signal) {
47
+ ui.section("Model");
48
+ ui.step("Model catalog", "Fetching model IDs only. No generation or token-usage test.");
49
+ try {
50
+ const response = await fetch("https://agentrouter.org/v1/models", {
51
+ headers: { Authorization: `Bearer ${apiKey}` },
52
+ signal: AbortSignal.any([signal, AbortSignal.timeout(12000)]),
53
+ redirect: "error",
54
+ });
55
+ if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`);
56
+ const reader = response.body.getReader();
57
+ const chunks = []; let bytes = 0;
58
+ for (;;) {
59
+ const { done, value } = await reader.read(); if (done) break;
60
+ bytes += value.byteLength;
61
+ if (bytes > 8 * 1024 * 1024) { await reader.cancel(); throw new Error("Catalog exceeds size limit"); }
62
+ chunks.push(Buffer.from(value));
63
+ }
64
+ const ids = usableIds(JSON.parse(Buffer.concat(chunks, bytes).toString("utf8")));
65
+ if (!ids.length) throw new Error("Catalog contained no models");
66
+ ui.step("Catalog received", `${ids.length} models listed; access and API compatibility are controlled by AgentRouter.`);
67
+ return { ids, source: "agentrouter", fetchedAt: new Date().toISOString() };
68
+ } catch (error) {
69
+ if (signal.aborted) throw error;
70
+ ui.step("Catalog unavailable", `${safeError(error)}. Shipped model IDs are available below; access is not verified.`);
71
+ return { ids: SHIPPED_MODELS, source: "shipped-unverified" };
72
+ }
73
+ }
74
+ async function selectModel(ui, client, ids, previous) {
75
+ const defaultModel = previous && ids.includes(previous) ? previous : ids.find((id) => client === "claude" ? id.startsWith("claude-") : id === "gpt-6-astra") || ids[0];
76
+ const preferred = [defaultModel, ...ids.filter((id) => id !== defaultModel).slice(0, 8)];
77
+ const index = await ui.choose(`${client === "claude" ? "Claude Code" : "Codex"}: choose a starting model`, [...preferred, "Enter another exact model ID"], 0);
78
+ if (index < preferred.length) return preferred[index];
79
+ for (;;) {
80
+ const model = (await ui.text("Exact AgentRouter model ID", defaultModel)).trim();
81
+ if (/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(model)) return model;
82
+ ui.step("Invalid model ID", "Use the exact ID from AgentRouter, without spaces.");
83
+ }
84
+ }
85
+ async function setup() {
86
+ const ui = createSetupUI();
87
+ const controller = new AbortController();
88
+ const cancel = () => controller.abort(new Error("Setup cancelled"));
89
+ process.once("SIGINT", cancel);
90
+ try {
91
+ ui.welcome();
92
+ const current = await readConfig();
93
+ ui.section("Client");
94
+ ui.step("Choose your coding client", "Connect your AgentRouter key. Unrelated settings stay in place.");
95
+ const selection = await ui.choose("Which client should Brink set up?", ["Claude Code (recommended)", "Codex", "Both — Claude Code and Codex"], 0);
96
+ const clients = selection === 0 ? ["claude"] : selection === 1 ? ["codex"] : ["claude", "codex"];
97
+ ui.section("Key");
98
+ let apiKey = current?.apiKey || await existingKey({ codexHome, claudeHome });
99
+ if (apiKey) {
100
+ const keyChoice = await ui.choose("AgentRouter key found on this computer", ["Use the saved key (never displayed)", "Enter a different key"], 0);
101
+ if (keyChoice === 1) apiKey = null;
102
+ }
103
+ while (!apiKey) {
104
+ apiKey = (await ui.secret("Paste your AgentRouter API key")).trim();
105
+ if (!apiKey || /\s/.test(apiKey)) { apiKey = null; ui.step("Key not saved", "Enter a non-empty key without spaces."); }
106
+ }
107
+ ui.step("Key stays on this computer", "Private local files; Windows inherits your profile permissions. API-key access, not a subscription login.");
108
+ const catalog = await loadModels(apiKey, ui, controller.signal);
109
+ const port = Number(current?.port || 8787);
110
+ const results = { ...(current?.clients || {}) };
111
+ const failures = [];
112
+ for (const client of clients) {
113
+ if (controller.signal.aborted) throw controller.signal.reason;
114
+ try {
115
+ const model = await selectModel(ui, client, catalog.ids, results[client]?.model);
116
+ ui.step("Prepare your client", `${client === "claude" ? "Claude Code" : "Codex"} is checked before settings change.`);
117
+ const installed = await ensureClient(client, { onProgress: ui.progress, signal: controller.signal });
118
+ if (controller.signal.aborted) throw controller.signal.reason;
119
+ ui.step("Connect to Brink", "Saving endpoint, key and model picker. No prompt is sent.");
120
+ const configured = await configureClient(client, {
121
+ apiKey, model, modelIds: [...new Set([...catalog.ids, model])], port, codexHome, claudeHome, onProgress: ui.progress,
122
+ });
123
+ results[client] = { ...installed, ...configured, model };
124
+ await saveConfig({ version: 1, port, apiKey, clients: results, catalog });
125
+ ui.step(`${client === "claude" ? "Claude Code" : "Codex"} connected`, configured.configPath);
126
+ } catch (error) {
127
+ if (controller.signal.aborted || error.code === "ABORT_ERR") throw error;
128
+ failures.push(client);
129
+ ui.step(`${client === "claude" ? "Claude Code" : "Codex"} setup needs attention`, safeError(error));
130
+ }
131
+ }
132
+ const ready = Object.keys(results);
133
+ if (ready.length) {
134
+ ui.section("Ready");
135
+ ui.success([
136
+ "Run brink and keep that terminal open.",
137
+ ...ready.map((client) => `In a second terminal, run ${results[client].executable || client}.`),
138
+ "In either client, use /model to choose a model.",
139
+ "Rate-limit, server-error and connection-reset retry waits: 1, 2, 3, 4, 5 seconds. No model substitution.",
140
+ ...(failures.length ? ["Some clients were not changed. Run brink setup again to finish them."] : []),
141
+ ]);
142
+ } else { ui.step("No client was configured", "Fix the issue above, then run brink setup again."); }
143
+ return ready.length > 0 && failures.length === 0;
144
+ } finally {
145
+ process.removeListener("SIGINT", cancel);
146
+ ui.close();
147
+ }
148
+ }
149
+ function portAvailable(port) {
150
+ return new Promise((resolve, reject) => {
151
+ const probe = net.createServer();
152
+ probe.once("error", (error) => error.code === "EADDRINUSE" ? resolve(false) : reject(error));
153
+ probe.listen(port, "127.0.0.1", () => probe.close(() => resolve(true)));
154
+ });
155
+ }
156
+ async function check(config = null) {
157
+ config ||= await readConfig();
158
+ if (!config?.apiKey || !Object.keys(config.clients || {}).length) {
159
+ console.log("Run brink setup first to connect a client.");
160
+ return;
161
+ }
162
+ for (const [client, saved] of Object.entries(config.clients)) {
163
+ if (!["claude", "codex"].includes(client)) continue;
164
+ try {
165
+ const options = { apiKey: config.apiKey, model: saved.model,
166
+ modelIds: config.catalog?.ids || SHIPPED_MODELS, port: config.port || 8787,
167
+ codexHome, claudeHome };
168
+ const status = await checkClientConfig(client, options);
169
+ if (status.needsRepair) {
170
+ await configureClient(client, { ...options, model: status.model });
171
+ console.log(`Brink: repaired ${client} managed settings (${status.issues.join(", ")}).`);
172
+ }
173
+ } catch (error) {
174
+ console.error(`Brink: ${client} settings need attention: ${safeError(error)}. Existing file preserved; other clients remain available.`);
175
+ }
176
+ }
177
+ }
178
+
179
+ async function update() {
180
+ const ui = createSetupUI();
181
+ try {
182
+ const config = await readConfig();
183
+ const clients = Object.entries(config?.clients || {}).filter(([client]) => ["claude", "codex"].includes(client));
184
+ if (!config?.apiKey || !clients.length) {
185
+ ui.step("No configured clients", "Run brink setup first. No settings were changed.");
186
+ return;
187
+ }
188
+ ui.section("Key");
189
+ ui.step("Update your AgentRouter API key", "Changes the saved key for your configured clients, not the Brink software. No network request is sent.");
190
+ const choice = await ui.choose("Would you like to change your key?", ["Keep my current key", "Enter a new API key"], 0);
191
+ if (choice === 0) {
192
+ ui.step("Current key kept", "No settings were changed.");
193
+ return;
194
+ }
195
+ let apiKey;
196
+ for (;;) {
197
+ apiKey = (await ui.secret("New AgentRouter API key (Enter empty to keep current)")).trim();
198
+ if (!apiKey || apiKey === config.apiKey) {
199
+ ui.step("Current key kept", "No settings were changed.");
200
+ return;
201
+ }
202
+ if (!/[\x00-\x20\x7f]/.test(apiKey)) break;
203
+ ui.step("Key not saved", "Enter a key without spaces or control characters.");
204
+ }
205
+ // Check all client files before committing; malformed configs leave the key unchanged.
206
+ const prepared = [];
207
+ for (const [client, saved] of clients) {
208
+ const options = { apiKey, model: saved.model, modelIds: config.catalog?.ids || SHIPPED_MODELS,
209
+ port: config.port || 8787, codexHome, claudeHome };
210
+ const status = await checkClientConfig(client, options);
211
+ prepared.push({ client, options: { ...options, model: status.model } });
212
+ }
213
+ // Commit the authoritative key first, so check/start can finish a failed client write.
214
+ await saveConfig({ ...config, apiKey });
215
+ const pending = [];
216
+ for (const { client, options } of prepared) {
217
+ try {
218
+ await configureClient(client, options);
219
+ ui.step(`${client === "claude" ? "Claude Code" : "Codex"} key updated`);
220
+ } catch {
221
+ pending.push(client);
222
+ }
223
+ }
224
+ if (pending.length) {
225
+ throw new Error(`The new key is saved in Brink, but these client settings could not be written: ${pending.join(", ")}. Check file permissions, then run brink check to finish. No key was tested online.`);
226
+ }
227
+ ui.step("New key saved", "Restart running Claude Code/Codex sessions to load it. Brink can stay running. Model choices and unrelated settings were preserved.");
228
+ ui.step("No provider verification performed", "This does not revoke the previous key at AgentRouter or remove it from external backups or shell overrides.");
229
+ } finally {
230
+ ui.close();
231
+ }
232
+ }
233
+
234
+ async function start(plain) {
235
+ let config = await readConfig();
236
+ if (!config || !Object.keys(config.clients || {}).length) {
237
+ console.log("First run: connect a coding client with brink setup.");
238
+ if (!process.stdin.isTTY || !await setup()) return;
239
+ config = await readConfig();
240
+ }
241
+ const port = Number(config.port || 8787);
242
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid Brink port. Run brink setup.");
243
+ await check(config);
244
+ if (!await portAvailable(port)) {
245
+ try {
246
+ const response = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1500) });
247
+ const health = await response.json();
248
+ if (health.service === "brink") { console.log(`Brink is already running on port ${port}. Open your coding client in another terminal.`); return; }
249
+ } catch {}
250
+ throw new Error(`Port ${port} belongs to another process. Brink did not stop it. Close that process or change Brink's port.`);
251
+ }
252
+ process.env.AGENTROUTER_PROXY_PORT = String(port);
253
+ process.env.BRINK_CLIENTS = Object.keys(config.clients || {}).join(",");
254
+ if (plain) process.env.AGENTROUTER_PROXY_PLAIN = "1";
255
+ require("./proxy");
256
+ }
257
+ function help() {
258
+ console.log(`\nBRINK\n${credit}\n\n brink Open the live dashboard (setup on first run)\n brink setup Install and connect Claude Code or Codex\n brink start Explicitly start the proxy and dashboard\n brink start --plain Start with ordinary line logs\n brink check Repair stale Brink-managed client settings\n brink update Optionally replace your saved API key\n brink art Show the creator artwork\n brink --help Show these commands\n\nSetup saves API-key access locally. Brink keeps running until Ctrl+C or q.\nKeep Brink open; run claude or codex in a second terminal.\n`);
259
+ }
260
+ async function main(args = process.argv.slice(2)) {
261
+ const [command, ...flags] = args;
262
+ if (!command) return start(false);
263
+ if (["help", "--help", "-h"].includes(command)) return help();
264
+ if (command === "setup" && !flags.length) { await setup(); return; }
265
+ if (command === "start" && flags.every((flag) => flag === "--plain")) return start(flags.includes("--plain"));
266
+ if (command === "check" && !flags.length) return check();
267
+ if (command === "update" && !flags.length) return update();
268
+ if (command === "art" && !flags.length) { console.log(`BRINK\n${credit}\n\n${duck}\n\n${cat}`); return; }
269
+ throw new Error("Unknown command. Use brink --help. Enter API keys only at the hidden prompt, never as arguments.");
270
+ }
271
+ if (require.main === module) main().catch((error) => {
272
+ console.error(`Brink: ${safeError(error)}`);
273
+ process.exitCode = error.code === "ABORT_ERR" ? 130 : 1;
274
+ });
275
+ module.exports = { setup, start, check, update, main, usableIds };