@ar-ai/brink-cli 0.0.0-stage → 1.0.6
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 +58 -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,333 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const readline = require("node:readline");
|
|
4
|
+
const { mascotLines, credit } = require("./brand");
|
|
5
|
+
const { clean, fit, wrap } = require("./terminal-ui");
|
|
6
|
+
|
|
7
|
+
const ESC = "\x1b[";
|
|
8
|
+
const SEGMENTS = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
9
|
+
|
|
10
|
+
function cancellation(message = "Setup cancelled.") {
|
|
11
|
+
const error = new Error(message);
|
|
12
|
+
error.name = "AbortError";
|
|
13
|
+
error.code = "ABORT_ERR";
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function eof() {
|
|
18
|
+
const error = new Error("Input ended before setup was complete.");
|
|
19
|
+
error.code = "EOF";
|
|
20
|
+
return error;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createSetupUI({ input = process.stdin, output = process.stdout } = {}) {
|
|
24
|
+
const interactive = Boolean(input.isTTY && output.isTTY);
|
|
25
|
+
const raw = interactive && typeof input.setRawMode === "function";
|
|
26
|
+
const motion = interactive && process.env.TERM !== "dumb" && !process.env.BRINK_PLAIN;
|
|
27
|
+
const color = motion && process.env.NO_COLOR === undefined;
|
|
28
|
+
const wasFlowing = input.readableFlowing === true;
|
|
29
|
+
let closed = false;
|
|
30
|
+
let active = null;
|
|
31
|
+
let lineReader = null;
|
|
32
|
+
let ended = false;
|
|
33
|
+
let queuedLines = [];
|
|
34
|
+
let lineWaiter = null;
|
|
35
|
+
let lastProgress = null;
|
|
36
|
+
let currentSection = null;
|
|
37
|
+
const sections = ["Client", "Key", "Model", "Ready"];
|
|
38
|
+
const width = () => Math.max(1, (output.columns || 80) - 2);
|
|
39
|
+
const style = (text, tone) => !color ? text : `${tone === "amber" ? `${ESC}38;2;225;177;99m` : tone === "section" ? `${ESC}38;2;225;177;99m${ESC}1m` : tone === "bold" ? `${ESC}1m` : `${ESC}2m`}${text}${ESC}0m`;
|
|
40
|
+
|
|
41
|
+
function line(value = "", tone) {
|
|
42
|
+
for (const part of wrap(value, width(), Infinity)) {
|
|
43
|
+
output.write(` ${tone ? style(part, tone) : part}\n`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function masthead(title, detail, reserve) {
|
|
48
|
+
const information = [
|
|
49
|
+
{ text: "BRINK", tone: "section" },
|
|
50
|
+
{ text: title, tone: "bold" },
|
|
51
|
+
{ text: detail, tone: "dim" },
|
|
52
|
+
{ text: "", tone: "dim" },
|
|
53
|
+
{ text: "Start with brink", tone: "amber" },
|
|
54
|
+
{ text: credit, tone: "dim" },
|
|
55
|
+
];
|
|
56
|
+
const room = width();
|
|
57
|
+
const rows = output.rows || 24;
|
|
58
|
+
const sideBySide = room >= 62;
|
|
59
|
+
const artBudget = Math.max(0, rows - reserve);
|
|
60
|
+
const art = motion ? mascotLines(sideBySide ? room - 34 : room, sideBySide ? artBudget : Math.min(3, artBudget - 4)) : [];
|
|
61
|
+
if (sideBySide && art.length) {
|
|
62
|
+
const artWidth = art[0].length;
|
|
63
|
+
const textWidth = room - artWidth - 3;
|
|
64
|
+
const height = Math.max(art.length, information.length);
|
|
65
|
+
const textStart = Math.floor((height - information.length) / 2);
|
|
66
|
+
for (let i = 0; i < height; i++) {
|
|
67
|
+
const info = information[i - textStart] || { text: "", tone: "dim" };
|
|
68
|
+
output.write(` ${style(fit(art[i] || "", artWidth), "amber")}${style(" │ ", "dim")}${style(fit(info.text, textWidth).trimEnd(), info.tone)}\n`);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
for (const row of art) line(row, "amber");
|
|
72
|
+
line("BRINK", "section");
|
|
73
|
+
line(title, "bold");
|
|
74
|
+
line(detail, "dim");
|
|
75
|
+
line(credit, "dim");
|
|
76
|
+
}
|
|
77
|
+
line("─".repeat(room), "dim");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function available() {
|
|
81
|
+
if (closed) throw cancellation("Setup UI is closed.");
|
|
82
|
+
if (active) throw new Error("Finish the current setup question before asking another.");
|
|
83
|
+
}
|
|
84
|
+
function section(name) {
|
|
85
|
+
available();
|
|
86
|
+
const index = sections.indexOf(name);
|
|
87
|
+
if (index < 0) throw new Error(`Unknown setup section: ${name}`);
|
|
88
|
+
if (currentSection === name) return;
|
|
89
|
+
currentSection = name;
|
|
90
|
+
lastProgress = null;
|
|
91
|
+
output.write("\n");
|
|
92
|
+
line(sections.map((title) => title === name ? `[${title}]` : title).join(" > "), "dim");
|
|
93
|
+
line(fit(` ${index + 1} / ${sections.length} ${name.toUpperCase()} `, width()), "section");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function question(value) {
|
|
97
|
+
lastProgress = null;
|
|
98
|
+
output.write("\n");
|
|
99
|
+
line("─".repeat(Math.min(width(), 48)), "dim");
|
|
100
|
+
line(value, "bold");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function rawPrompt({ initial = "", hidden = false, render, accept, keyHandler }) {
|
|
104
|
+
available();
|
|
105
|
+
if (input.readableEnded || input.destroyed) return Promise.reject(eof());
|
|
106
|
+
if (lineReader) {
|
|
107
|
+
lineReader.close();
|
|
108
|
+
lineReader = null;
|
|
109
|
+
ended = false;
|
|
110
|
+
queuedLines = [];
|
|
111
|
+
}
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
let value = initial;
|
|
114
|
+
let settled = false;
|
|
115
|
+
const wasRaw = Boolean(input.isRaw);
|
|
116
|
+
const promptWasFlowing = input.readableFlowing === true;
|
|
117
|
+
const redraw = () => {
|
|
118
|
+
if (motion) output.write(`\r${ESC}2K ${fit(render(value), width()).trimEnd()}`);
|
|
119
|
+
};
|
|
120
|
+
const cleanup = () => {
|
|
121
|
+
input.removeListener("keypress", onKey);
|
|
122
|
+
input.removeListener("end", onEnd);
|
|
123
|
+
input.removeListener("close", onEnd);
|
|
124
|
+
input.removeListener("error", onError);
|
|
125
|
+
output.removeListener("resize", redraw);
|
|
126
|
+
process.removeListener("SIGINT", onCancel);
|
|
127
|
+
process.removeListener("exit", onExit);
|
|
128
|
+
if (!input.destroyed) input.setRawMode(wasRaw);
|
|
129
|
+
if (!promptWasFlowing) input.pause();
|
|
130
|
+
active = null;
|
|
131
|
+
};
|
|
132
|
+
const finish = (error, answer) => {
|
|
133
|
+
if (settled) return;
|
|
134
|
+
settled = true;
|
|
135
|
+
value = "";
|
|
136
|
+
cleanup();
|
|
137
|
+
if (motion) output.write(`\r${ESC}2K`);
|
|
138
|
+
if (error) line(error.code === "EOF" ? "Input ended." : "Cancelled.", "dim");
|
|
139
|
+
else if (hidden) line("Key received (hidden).", "dim");
|
|
140
|
+
else if (answer !== undefined) line(render(answer), "dim");
|
|
141
|
+
if (error) reject(error);
|
|
142
|
+
else resolve(answer);
|
|
143
|
+
};
|
|
144
|
+
const onCancel = () => finish(cancellation());
|
|
145
|
+
const onEnd = () => finish(eof());
|
|
146
|
+
const onError = (error) => finish(error);
|
|
147
|
+
const onExit = () => { value = ""; cleanup(); };
|
|
148
|
+
const onKey = (text, key = {}) => {
|
|
149
|
+
if (settled) return;
|
|
150
|
+
if ((key.ctrl && key.name === "c") || key.name === "escape") return onCancel();
|
|
151
|
+
if (key.ctrl && key.name === "d") return onEnd();
|
|
152
|
+
if (key.name === "return" || key.name === "enter") {
|
|
153
|
+
const result = accept(value);
|
|
154
|
+
if (result !== undefined) return finish(null, result);
|
|
155
|
+
if (!motion) line("Enter a valid choice.", "amber");
|
|
156
|
+
return redraw();
|
|
157
|
+
}
|
|
158
|
+
if (keyHandler) {
|
|
159
|
+
const next = keyHandler(text, key, value);
|
|
160
|
+
if (next !== undefined) value = next;
|
|
161
|
+
} else if (key.name === "backspace" || key.name === "delete") {
|
|
162
|
+
value = [...SEGMENTS.segment(value)].slice(0, -1).map((part) => part.segment).join("");
|
|
163
|
+
} else if (key.ctrl && key.name === "u") {
|
|
164
|
+
value = "";
|
|
165
|
+
} else if (!key.ctrl && !key.meta && text && !text.includes("\x1b")) {
|
|
166
|
+
value = (value + text.replace(/[\x00-\x1f\x7f-\x9f]/g, "")).slice(0, 16384);
|
|
167
|
+
}
|
|
168
|
+
redraw();
|
|
169
|
+
};
|
|
170
|
+
active = { cancel: onCancel };
|
|
171
|
+
try {
|
|
172
|
+
input.setRawMode(true);
|
|
173
|
+
readline.emitKeypressEvents(input);
|
|
174
|
+
input.on("keypress", onKey);
|
|
175
|
+
input.once("end", onEnd);
|
|
176
|
+
input.once("close", onEnd);
|
|
177
|
+
input.once("error", onError);
|
|
178
|
+
output.on("resize", redraw);
|
|
179
|
+
process.on("SIGINT", onCancel);
|
|
180
|
+
process.on("exit", onExit);
|
|
181
|
+
if (!motion) line(render(value), "dim");
|
|
182
|
+
redraw();
|
|
183
|
+
input.resume();
|
|
184
|
+
} catch (error) {
|
|
185
|
+
finish(error);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readLine() {
|
|
191
|
+
available();
|
|
192
|
+
if (!lineReader) {
|
|
193
|
+
if (input.readableEnded || input.destroyed) return Promise.reject(eof());
|
|
194
|
+
lineReader = readline.createInterface({ input, output, terminal: interactive && motion });
|
|
195
|
+
lineReader.on("line", (value) => {
|
|
196
|
+
if (lineWaiter) lineWaiter(null, value);
|
|
197
|
+
else queuedLines.push(value);
|
|
198
|
+
});
|
|
199
|
+
lineReader.on("close", () => { ended = true; if (lineWaiter) lineWaiter(eof()); });
|
|
200
|
+
lineReader.on("SIGINT", () => { if (lineWaiter) lineWaiter(cancellation()); });
|
|
201
|
+
lineReader.on("error", (error) => { if (lineWaiter) lineWaiter(error); });
|
|
202
|
+
}
|
|
203
|
+
if (queuedLines.length) return Promise.resolve(queuedLines.shift());
|
|
204
|
+
if (ended) return Promise.reject(eof());
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
const onCancel = () => finish(cancellation());
|
|
207
|
+
const finish = (error, value) => {
|
|
208
|
+
lineWaiter = null;
|
|
209
|
+
active = null;
|
|
210
|
+
process.removeListener("SIGINT", onCancel);
|
|
211
|
+
if (error) reject(error);
|
|
212
|
+
else resolve(value);
|
|
213
|
+
};
|
|
214
|
+
lineWaiter = finish;
|
|
215
|
+
active = { cancel: onCancel };
|
|
216
|
+
process.on("SIGINT", onCancel);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function choose(prompt, options, defaultIndex = 0) {
|
|
221
|
+
available();
|
|
222
|
+
if (!Array.isArray(options) || options.length === 0) throw new Error("A setup choice needs at least one option.");
|
|
223
|
+
if (!Number.isInteger(defaultIndex) || defaultIndex < 0 || defaultIndex >= options.length) throw new RangeError("Default choice is outside the options.");
|
|
224
|
+
const labels = options.map((option) => typeof option === "string" ? option : option.label);
|
|
225
|
+
question(prompt);
|
|
226
|
+
options.forEach((option, index) => {
|
|
227
|
+
line(`${index + 1}. ${labels[index]}${index === defaultIndex ? " (default)" : ""}`);
|
|
228
|
+
if (typeof option === "object" && option.detail) line(` ${option.detail}`, "dim");
|
|
229
|
+
});
|
|
230
|
+
line(raw && motion ? `Arrows / number + Enter · default ${defaultIndex + 1}` : `Number + Enter · default ${defaultIndex + 1}`, "dim");
|
|
231
|
+
let selected = defaultIndex;
|
|
232
|
+
const parse = (value) => {
|
|
233
|
+
if (typeof value === "number") return value;
|
|
234
|
+
if (!value.trim()) return selected;
|
|
235
|
+
if (!/^\d+$/.test(value.trim())) return undefined;
|
|
236
|
+
const index = Number(value.trim()) - 1;
|
|
237
|
+
return index >= 0 && index < options.length ? index : undefined;
|
|
238
|
+
};
|
|
239
|
+
if (raw && motion) {
|
|
240
|
+
return rawPrompt({
|
|
241
|
+
render: (value) => {
|
|
242
|
+
const index = parse(value);
|
|
243
|
+
return index === undefined ? `Choose 1-${options.length}: ${clean(value)}` : `${index + 1}. ${labels[index]}`;
|
|
244
|
+
},
|
|
245
|
+
accept: parse,
|
|
246
|
+
keyHandler: (text, key, value) => {
|
|
247
|
+
if (key.name === "up" || key.name === "down") {
|
|
248
|
+
selected = ((parse(value) ?? selected) + (key.name === "up" ? -1 : 1) + options.length) % options.length;
|
|
249
|
+
return "";
|
|
250
|
+
}
|
|
251
|
+
if (key.name === "backspace") return value.slice(0, -1);
|
|
252
|
+
if (key.ctrl && key.name === "u") return "";
|
|
253
|
+
return !key.ctrl && !key.meta && /^\d+$/.test(text || "") ? (value + text).slice(0, 8) : value;
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
for (;;) {
|
|
258
|
+
output.write(` Choose [${defaultIndex + 1}]: `);
|
|
259
|
+
const result = parse(await readLine());
|
|
260
|
+
if (result !== undefined) return result;
|
|
261
|
+
line(`Please choose a number from 1 to ${options.length}.`, "amber");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function text(prompt, defaultValue = "") {
|
|
266
|
+
available();
|
|
267
|
+
question(prompt);
|
|
268
|
+
if (defaultValue) line(`Enter to use: ${defaultValue}`, "dim");
|
|
269
|
+
if (raw && motion) return rawPrompt({ render: (value) => `> ${clean(value)}`, accept: (value) => value || defaultValue });
|
|
270
|
+
output.write(" > ");
|
|
271
|
+
return (await readLine()) || defaultValue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function secret(prompt) {
|
|
275
|
+
available();
|
|
276
|
+
if (!raw) {
|
|
277
|
+
const error = new Error("Hidden key entry needs an interactive terminal. Run brink setup directly in your terminal; do not pipe API keys.");
|
|
278
|
+
error.code = "SECRET_TTY_REQUIRED";
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
question(prompt);
|
|
282
|
+
line("Paste key + Enter. Input stays hidden.", "dim");
|
|
283
|
+
line("Ctrl+C cancel · Ctrl+U clear", "dim");
|
|
284
|
+
return rawPrompt({ hidden: true, render: () => "> [hidden]", accept: (value) => value.trim() });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function step(label, detail) {
|
|
288
|
+
lastProgress = null;
|
|
289
|
+
line(label, "amber");
|
|
290
|
+
if (detail) line(detail, "dim");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function progress({ label = "Installing...", detail, percent } = {}) {
|
|
294
|
+
if (closed || active) return;
|
|
295
|
+
const measured = typeof percent === "number" && Number.isFinite(percent) && percent >= 0 && percent <= 100;
|
|
296
|
+
const bucket = measured ? Math.floor(percent) : null;
|
|
297
|
+
const key = `${label}\n${detail || ""}`;
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
if (lastProgress && lastProgress.key === key && lastProgress.bucket === bucket) return;
|
|
300
|
+
if (lastProgress && lastProgress.label === label && (measured || lastProgress.key === key) && now - lastProgress.time < 1000 && bucket !== 100) return;
|
|
301
|
+
line(`${label}${measured ? ` ${bucket}%` : ""}`, "amber");
|
|
302
|
+
if (detail) line(detail, "dim");
|
|
303
|
+
lastProgress = { key, label, bucket, time: now };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function welcome() {
|
|
307
|
+
available();
|
|
308
|
+
output.write("\n");
|
|
309
|
+
masthead("Your coding client, connected.", "Client / key / model / ready", 16);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function success(lines) {
|
|
313
|
+
if (closed) return;
|
|
314
|
+
output.write("\n");
|
|
315
|
+
const detailRows = lines.reduce((count, value) => count + wrap(value, width(), Infinity).length, 0);
|
|
316
|
+
masthead("Setup complete.", "Your coding client, connected.", detailRows + 7);
|
|
317
|
+
section("Ready");
|
|
318
|
+
for (const value of lines) line(value);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function close() {
|
|
322
|
+
if (closed) return;
|
|
323
|
+
closed = true;
|
|
324
|
+
active?.cancel();
|
|
325
|
+
queuedLines = [];
|
|
326
|
+
lineReader?.close();
|
|
327
|
+
if (!wasFlowing) input.pause();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return { welcome, section, choose, secret, text, step, progress, success, close };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = { createSetupUI };
|