@b3labs/b3iq-cli 0.0.0-stage → 0.0.1

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,272 @@
1
+ import { fetchMediaDownload } from "./media-download.js";
2
+ import { readFile, mkdir, writeFile, unlink, open, rename } from "node:fs/promises";
3
+ import { constants } from "node:fs";
4
+ import { isAbsolute } from "node:path";
5
+ import { configDir, isLoopbackHost } from "./config.js";
6
+ import { join } from "node:path";
7
+ import { randomUUID } from "node:crypto";
8
+ import { explainGatewayStatus, explainNetworkFailure } from "./diagnosis.js";
9
+ import { CliError } from "./output.js";
10
+ export const DEFAULT_GATEWAY_URL = "https://b3iq.org/v1/api";
11
+ export const inferenceKeyPath = () => join(configDir(), "gateway-key");
12
+ function validateGatewayKey(key) {
13
+ if (!key?.trim() || /[\r\n]/.test(key.trim()))
14
+ throw new CliError("Set B3IQ_GATEWAY_API_KEY or save a dashboard key with b3iq inference key-save (stdin).", "auth");
15
+ return key.trim();
16
+ }
17
+ export async function savedGatewayKey(path = inferenceKeyPath()) {
18
+ let key;
19
+ try {
20
+ key = await readFile(path, "utf8");
21
+ }
22
+ catch {
23
+ throw new CliError("Save the desktop API key first with b3iq inference key-save (stdin). Desktop setup and its helper use the saved file, not a terminal-only environment key.", "auth");
24
+ }
25
+ return validateGatewayKey(key);
26
+ }
27
+ export async function gatewayKey() {
28
+ return process.env.B3IQ_GATEWAY_API_KEY ? validateGatewayKey(process.env.B3IQ_GATEWAY_API_KEY) : savedGatewayKey();
29
+ }
30
+ export async function saveGatewayKey(input) {
31
+ const key = input.trim();
32
+ if (!key || key.length > 4096 || /[\r\n]/.test(key))
33
+ throw new CliError("Supply one dashboard API key on stdin.", "usage");
34
+ const path = inferenceKeyPath();
35
+ await mkdir(configDir(), { recursive: true, mode: 0o700 });
36
+ const temp = `${path}.${randomUUID()}`;
37
+ try {
38
+ await writeFile(temp, key + "\n", { flag: "wx", mode: 0o600 });
39
+ await rename(temp, path);
40
+ }
41
+ finally {
42
+ await unlink(temp).catch(() => { });
43
+ }
44
+ }
45
+ export async function clearGatewayKey() { await unlink(inferenceKeyPath()).catch(error => { if (error.code !== "ENOENT")
46
+ throw error; }); }
47
+ export function gatewayURL(value = DEFAULT_GATEWAY_URL) {
48
+ const url = new URL(value);
49
+ if (url.username || url.password || url.search || url.hash || (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname)))) {
50
+ throw new CliError("Gateway must be HTTPS (or loopback HTTP), without credentials, query or fragment.", "usage");
51
+ }
52
+ url.pathname = url.pathname.replace(/\/+$/, "");
53
+ return url;
54
+ }
55
+ export function nativeInputModalities(model) {
56
+ return model.b3iq?.input_modalities?.includes("image") && model.b3iq.capabilities?.includes("vision") ? ["text", "image"] : ["text"];
57
+ }
58
+ export class InferenceGateway {
59
+ base;
60
+ options;
61
+ constructor(options = {}) { this.base = gatewayURL(options.gateway ?? process.env.B3IQ_GATEWAY_URL); this.options = options; }
62
+ async request(path, body, signal) {
63
+ const url = new URL(this.base);
64
+ url.pathname += path;
65
+ if (this.options.machine)
66
+ url.searchParams.set("node_id", this.options.machine);
67
+ if (path === "/models")
68
+ url.searchParams.set("b3iq_metadata", "true");
69
+ const key = this.options.key ?? await gatewayKey();
70
+ let response;
71
+ try {
72
+ const fetcher = this.options.fetch ?? fetch;
73
+ const content = /^\/(?:media\/jobs|videos)\/[^/]+\/content$/.test(path);
74
+ const init = {
75
+ method: body === undefined ? "GET" : "POST", redirect: "error",
76
+ headers: { authorization: `Bearer ${key}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
77
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
78
+ signal: content ? signal : AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(120_000)]),
79
+ };
80
+ response = await (content ? fetchMediaDownload(fetcher, url, init) : fetcher(url, init));
81
+ }
82
+ catch (error) {
83
+ if (error instanceof CliError)
84
+ throw error;
85
+ throw explainNetworkFailure(error, "inference gateway");
86
+ }
87
+ if (!response.ok) {
88
+ await response.body?.cancel();
89
+ throw explainGatewayStatus(response.status, this.options.machine, response.headers.get("x-request-id") ?? undefined);
90
+ }
91
+ return response;
92
+ }
93
+ async json(path, body, signal, maxBytes = 1024 * 1024) {
94
+ const response = await this.request(path, body, signal);
95
+ const bytes = await boundedBytes(response, maxBytes);
96
+ try {
97
+ const value = JSON.parse(bytes.toString("utf8"));
98
+ if (!value || typeof value !== "object" || Array.isArray(value))
99
+ throw new Error();
100
+ return value;
101
+ }
102
+ catch {
103
+ throw new CliError("The inference gateway returned invalid JSON.", "api");
104
+ }
105
+ }
106
+ async models(signal) {
107
+ const result = await this.json("/models", undefined, signal);
108
+ if (!Array.isArray(result.data) || result.data.some(value => typeof value?.id !== "string"))
109
+ throw new CliError("Invalid model discovery response.", "api");
110
+ return result.data;
111
+ }
112
+ async requireModality(model, modality, signal) {
113
+ const models = await this.models(signal), selected = models.find(row => row.id === model);
114
+ if (!selected)
115
+ throw new CliError("That exact model ID is not served by the key's permitted machines. Run b3iq_models before selecting media models.", "usage");
116
+ if (!selected.b3iq?.modality)
117
+ throw new CliError("Serving modality metadata is unavailable. Update the gateway before selecting a media model.", "usage");
118
+ if (selected.b3iq.modality === modality)
119
+ return selected;
120
+ const compatible = models.filter(row => row.b3iq?.modality === modality).map(row => row.id);
121
+ throw new CliError(compatible.length
122
+ ? `The selected model does not generate ${modality}. Use an exact ${modality} model ID: ${compatible.join(", ")}.`
123
+ : `No ${modality}-generation model is served by the key's permitted machines. Install and serve a compatible ${modality} model first; do not retry with a text or other media model.`, "usage");
124
+ }
125
+ async image(model, prompt, signal, imagePath) {
126
+ const selected = await this.requireModality(model, "image", signal);
127
+ if (imagePath !== undefined && !selected.b3iq?.input_modalities?.includes("image"))
128
+ throw new CliError("That served image model does not advertise reference-image input. Choose an editing model from b3iq_models and update the host/gateway before retrying.", "usage");
129
+ const image = imagePath === undefined ? undefined : await referenceImage(imagePath);
130
+ const result = await this.json(image ? "/images/edits" : "/images/generations", { model, prompt, n: 1, response_format: "b64_json", async: true, ...(image ? { image } : {}) }, signal, 48 * 1024 * 1024);
131
+ if (Array.isArray(result.data))
132
+ return { status: "completed", ...await this.saveImage(result) };
133
+ if (typeof result.id !== "string" || !/^med_[a-f0-9-]{36}$/.test(result.id))
134
+ throw new CliError("The gateway did not return a media job. Update the gateway and inference plugin before retrying.", "api");
135
+ return result;
136
+ }
137
+ async imageStatus(id, signal) {
138
+ if (!/^med_[a-f0-9-]{36}$/.test(id))
139
+ throw new CliError("Invalid media job ID.", "usage");
140
+ const status = await this.json(`/media/jobs/${id}`, undefined, signal);
141
+ if (status.status !== "completed")
142
+ return status;
143
+ const result = await this.json(`/media/jobs/${id}/content`, undefined, signal, 48 * 1024 * 1024);
144
+ return { ...status, ...await this.saveImage(result) };
145
+ }
146
+ async saveImage(result) {
147
+ const row = Array.isArray(result.data) ? result.data[0] : undefined;
148
+ const encoded = row?.b64_json;
149
+ if (typeof encoded !== "string" || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded))
150
+ throw new CliError("The image response must contain inline base64 PNG data.", "api");
151
+ const image = Buffer.from(encoded, "base64");
152
+ if (!image.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
153
+ throw new CliError("The image response is not a PNG.", "api");
154
+ const path = await this.artifactPath("png");
155
+ await writeFile(path, image, { flag: "wx", mode: 0o600 });
156
+ return { path, mimeType: "image/png" };
157
+ }
158
+ async video(model, prompt, seconds, signal) {
159
+ await this.requireModality(model, "video", signal);
160
+ return this.json("/videos", { model, prompt, seconds }, signal);
161
+ }
162
+ async videoStatus(id, signal) {
163
+ if (!/^vid_[a-f0-9-]{36}$/.test(id))
164
+ throw new CliError("Invalid video job ID.", "usage");
165
+ const result = await this.json(`/videos/${id}`, undefined, signal);
166
+ if (result.status !== "completed")
167
+ return result;
168
+ const response = await this.request(`/videos/${id}/content`, undefined, signal);
169
+ if (response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "video/mp4") {
170
+ await response.body?.cancel();
171
+ throw new CliError("The video content type is not video/mp4.", "api");
172
+ }
173
+ const path = await this.artifactPath("mp4");
174
+ const handle = await open(path, "wx", 0o600);
175
+ const reader = response.body?.getReader();
176
+ if (!reader) {
177
+ await handle.close();
178
+ await unlink(path);
179
+ throw new CliError("Empty video content.", "api");
180
+ }
181
+ let size = 0, prefix = Buffer.alloc(0);
182
+ try {
183
+ for (;;) {
184
+ signal?.throwIfAborted();
185
+ const { value, done } = await reader.read();
186
+ if (done)
187
+ break;
188
+ size += value.byteLength;
189
+ if (size > 256 * 1024 * 1024)
190
+ throw new CliError("Video exceeds 256 MiB.", "api");
191
+ if (prefix.length < 12)
192
+ prefix = Buffer.concat([prefix, value]).subarray(0, 12);
193
+ await handle.writeFile(value);
194
+ }
195
+ if (prefix.subarray(4, 8).toString("ascii") !== "ftyp")
196
+ throw new CliError("The video content is not MP4.", "api");
197
+ return { ...result, path, mimeType: "video/mp4" };
198
+ }
199
+ catch (error) {
200
+ await unlink(path).catch(() => { });
201
+ throw error;
202
+ }
203
+ finally {
204
+ await reader.cancel().catch(() => { });
205
+ reader.releaseLock();
206
+ await handle.close();
207
+ }
208
+ }
209
+ async artifactPath(extension) {
210
+ const dir = this.options.outputDir ?? join(configDir(), "media");
211
+ await mkdir(dir, { recursive: true, mode: 0o700 });
212
+ return join(dir, `${randomUUID()}.${extension}`);
213
+ }
214
+ }
215
+ async function boundedBytes(response, maximum) {
216
+ const reader = response.body?.getReader();
217
+ if (!reader)
218
+ throw new CliError("Empty inference response.", "api");
219
+ const chunks = [];
220
+ let total = 0;
221
+ try {
222
+ for (;;) {
223
+ const { value, done } = await reader.read();
224
+ if (done)
225
+ break;
226
+ total += value.byteLength;
227
+ if (total > maximum)
228
+ throw new CliError("Inference response exceeds the supported size.", "api");
229
+ chunks.push(value);
230
+ }
231
+ return Buffer.concat(chunks);
232
+ }
233
+ finally {
234
+ await reader.cancel().catch(() => { });
235
+ reader.releaseLock();
236
+ }
237
+ }
238
+ async function referenceImage(path) {
239
+ if (!isAbsolute(path))
240
+ throw new CliError("image_path must be the absolute path to the user's PNG, JPEG or WebP attachment.", "usage");
241
+ let handle;
242
+ try {
243
+ handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
244
+ const stat = await handle.stat();
245
+ const maxBytes = 5 * 1024 * 1024;
246
+ if (!stat.isFile() || stat.size <= 0 || stat.size > maxBytes)
247
+ throw new Error();
248
+ const buffer = Buffer.alloc(Math.min(stat.size + 1, maxBytes + 1));
249
+ let length = 0;
250
+ while (length < buffer.length) {
251
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
252
+ if (!bytesRead)
253
+ break;
254
+ length += bytesRead;
255
+ }
256
+ if (length !== stat.size)
257
+ throw new Error();
258
+ const bytes = buffer.subarray(0, length);
259
+ const mime = bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ? "image/png"
260
+ : bytes.subarray(0, 3).equals(Buffer.from([255, 216, 255])) ? "image/jpeg"
261
+ : bytes.subarray(0, 4).toString() === "RIFF" && bytes.subarray(8, 12).toString() === "WEBP" ? "image/webp" : null;
262
+ if (!mime)
263
+ throw new Error();
264
+ return `data:${mime};base64,${bytes.toString("base64")}`;
265
+ }
266
+ catch {
267
+ throw new CliError("Cannot read reference image. Supply a readable PNG, JPEG or WebP file of at most 5 MiB; directories, URLs and other formats are unsupported.", "usage");
268
+ }
269
+ finally {
270
+ await handle?.close();
271
+ }
272
+ }
@@ -0,0 +1,120 @@
1
+ import { createInterface } from "node:readline";
2
+ import { api, whoAmI } from "./api.js";
3
+ import { resolveSettings } from "./config.js";
4
+ import { explainAmbiguousMachine, explainMachineNotVisible, explainMissingMachineSelector, readAccountBestEffort, shellArg, withDeadline, } from "./diagnosis.js";
5
+ import { CliError, plain, warn } from "./output.js";
6
+ export const machineLabel = (n) => (plain(n.name ?? "").replace(/\s+/g, " ").trim() || `${n.node_id.slice(0, 8)}…`);
7
+ export function matchMachines(nodes, selector) {
8
+ const sel = selector.trim();
9
+ const lower = sel.toLowerCase();
10
+ const isExactIdOrName = (n) => n.node_id === sel || n.name?.toLowerCase() === lower;
11
+ const isIdPrefixOrLabelSubstring = (n) => n.node_id.startsWith(sel) || machineLabel(n).toLowerCase().includes(lower);
12
+ const exactMatches = nodes.filter(isExactIdOrName);
13
+ return exactMatches.length ? exactMatches : nodes.filter(isIdPrefixOrLabelSubstring);
14
+ }
15
+ async function accountForMiss() {
16
+ return readAccountBestEffort(() => withDeadline(whoAmI(), 4_000));
17
+ }
18
+ export async function resolveMachine(selector) {
19
+ const sel = (selector ?? resolveSettings().defaultMachine ?? "").trim();
20
+ if (!sel)
21
+ throw explainMissingMachineSelector();
22
+ const { nodes, org_context } = await api.nodes();
23
+ const matches = matchMachines(nodes, sel);
24
+ if (matches.length === 0) {
25
+ throw explainMachineNotVisible({
26
+ selector: sel,
27
+ machinesListed: nodes.length,
28
+ account: await accountForMiss(),
29
+ orgIdSet: Boolean(resolveSettings().orgId),
30
+ orgContext: org_context ?? null,
31
+ });
32
+ }
33
+ if (matches.length > 1)
34
+ throw explainAmbiguousMachine(sel, matches.map((node) => ({ node_id: node.node_id, label: machineLabel(node) })));
35
+ return matches[0];
36
+ }
37
+ export function noteReportedOffline(node) {
38
+ if (node.liveness !== "offline")
39
+ return;
40
+ warn(`${machineLabel(node)} (${node.node_id}) was reported offline. The command is still being submitted. If this wait ends first, run \`b3iq commands get <id> --machine ${shellArg(node.node_id)}\` before submitting another.`);
41
+ }
42
+ export async function resolveMachineOrRental(selector) {
43
+ const sel = (selector ?? resolveSettings().defaultMachine ?? "").trim();
44
+ if (!sel)
45
+ throw explainMissingMachineSelector();
46
+ const { nodes, org_context } = await api.nodes();
47
+ const orgId = org_context ?? null;
48
+ const owned = matchMachines(nodes, sel);
49
+ if (owned.length > 1)
50
+ throw explainAmbiguousMachine(sel, owned.map((node) => ({ node_id: node.node_id, label: machineLabel(node) })));
51
+ if (owned.length === 1)
52
+ return { kind: "machine", node: owned[0], orgId };
53
+ const { guests } = await api.rentedGuests();
54
+ const rentals = guests.map((rental) => ({ node_id: rental.node_id, name: rental.name, rental }));
55
+ const matches = matchMachines(rentals, sel);
56
+ if (matches.length === 0) {
57
+ throw explainMachineNotVisible({
58
+ selector: sel,
59
+ machinesListed: nodes.length,
60
+ rentalsListed: guests.length,
61
+ account: await accountForMiss(),
62
+ orgIdSet: Boolean(resolveSettings().orgId),
63
+ orgContext: orgId,
64
+ });
65
+ }
66
+ if (matches.length > 1)
67
+ throw explainAmbiguousMachine(sel, matches.map((node) => ({ node_id: node.node_id, label: machineLabel(node) })));
68
+ const { rental, ...node } = matches[0];
69
+ return { kind: "rental", node, rental, orgId };
70
+ }
71
+ export async function pickMachineOrRental(io = { input: process.stdin, output: process.stderr }) {
72
+ const [{ nodes, org_context }, { guests }] = await Promise.all([api.nodes(), api.rentedGuests()]);
73
+ const orgId = org_context ?? null;
74
+ const choices = [
75
+ ...nodes.map((node) => ({ kind: "machine", node, orgId })),
76
+ ...guests.map((rental) => ({ kind: "rental", node: { node_id: rental.node_id, name: rental.name }, rental, orgId })),
77
+ ];
78
+ if (choices.length === 0) {
79
+ throw explainMachineNotVisible({
80
+ selector: "",
81
+ machinesListed: 0,
82
+ rentalsListed: 0,
83
+ account: await accountForMiss(),
84
+ orgIdSet: Boolean(resolveSettings().orgId),
85
+ orgContext: orgId,
86
+ });
87
+ }
88
+ if (choices.length === 1)
89
+ return choices[0];
90
+ const width = String(choices.length).length;
91
+ const lines = choices.map((choice, i) => {
92
+ const note = choice.kind === "rental" ? "your rented guest" : (choice.node.liveness ?? "");
93
+ return ` ${String(i + 1).padStart(width)}) ${machineLabel(choice.node)} ${choice.node.node_id.slice(0, 8)}${note ? ` ${note}` : ""}`;
94
+ });
95
+ io.output.write(`Which machine?\n${lines.join("\n")}\n`);
96
+ const rl = createInterface({ input: io.input, terminal: false });
97
+ const answers = rl[Symbol.asyncIterator]();
98
+ try {
99
+ for (;;) {
100
+ io.output.write(`Number or name [1-${choices.length}]: `);
101
+ const answer = await answers.next();
102
+ if (answer.done)
103
+ throw new CliError("No machine chosen.", "usage", 1);
104
+ const text = answer.value.trim();
105
+ if (!text)
106
+ continue;
107
+ const index = /^\d+$/.test(text) ? Number(text) - 1 : -1;
108
+ if (choices[index])
109
+ return choices[index];
110
+ const matched = new Set(matchMachines(choices.map((choice) => choice.node), text));
111
+ const matches = choices.filter((choice) => matched.has(choice.node));
112
+ if (matches.length === 1)
113
+ return matches[0];
114
+ io.output.write(matches.length ? `"${text}" matches ${matches.length} machines. Pick a number.\n` : `No machine matches "${text}".\n`);
115
+ }
116
+ }
117
+ finally {
118
+ rl.close();
119
+ }
120
+ }
@@ -0,0 +1,36 @@
1
+ import { api, whoAmI } from "./api.js";
2
+ import { resolveSettings } from "./config.js";
3
+ import { readAccountBestEffort, withDeadline } from "./diagnosis.js";
4
+ import { CliError } from "./output.js";
5
+ import { matchMachines } from "./machines.js";
6
+ export async function probeManagement(machine) {
7
+ const { token, orgId } = resolveSettings();
8
+ const org_id_set = Boolean(orgId);
9
+ if (!token)
10
+ return { checked: false, reason: "no_management_token", org_id_set, account_lookup: "no_token", account: null };
11
+ const account = await readAccountBestEffort(() => withDeadline(whoAmI(), 4_000));
12
+ try {
13
+ const { nodes } = await withDeadline(api.nodes(), 4_000);
14
+ const matches = machine ? matchMachines(nodes, machine) : [];
15
+ return {
16
+ checked: true,
17
+ org_id_set,
18
+ account_lookup: account.lookup,
19
+ account: account.account,
20
+ machines_listed: nodes.length,
21
+ ...(machine
22
+ ? { machine_visible: matches.length === 1, machine_ambiguous: matches.length > 1 }
23
+ : {}),
24
+ };
25
+ }
26
+ catch (error) {
27
+ const org = error instanceof CliError && error.diagnosis?.diagnostic === "org_scope_unsupported";
28
+ return {
29
+ checked: false,
30
+ reason: org ? "org_scope_unsupported" : "lookup_failed",
31
+ org_id_set,
32
+ account_lookup: account.lookup,
33
+ account: account.account,
34
+ };
35
+ }
36
+ }
@@ -0,0 +1,50 @@
1
+ export async function fetchMediaDownload(fetcher, url, init) {
2
+ const controller = new AbortController();
3
+ const abort = () => controller.abort(init.signal?.reason);
4
+ if (init.signal?.aborted)
5
+ abort();
6
+ else
7
+ init.signal?.addEventListener("abort", abort, { once: true });
8
+ let timer;
9
+ const progress = () => {
10
+ clearTimeout(timer);
11
+ timer = setTimeout(() => controller.abort(new DOMException("Media download stalled. Check the same job again to download its saved result.", "TimeoutError")), 120_000);
12
+ };
13
+ const cleanup = () => { clearTimeout(timer); init.signal?.removeEventListener("abort", abort); };
14
+ progress();
15
+ try {
16
+ const response = await fetcher(url, { ...init, signal: controller.signal });
17
+ if (!response.body) {
18
+ cleanup();
19
+ return response;
20
+ }
21
+ progress();
22
+ const reader = response.body.getReader();
23
+ return new Response(new ReadableStream({
24
+ async pull(output) {
25
+ try {
26
+ const { value, done } = await reader.read();
27
+ if (done) {
28
+ cleanup();
29
+ reader.releaseLock();
30
+ output.close();
31
+ return;
32
+ }
33
+ if (value.byteLength)
34
+ progress();
35
+ output.enqueue(value);
36
+ }
37
+ catch (error) {
38
+ cleanup();
39
+ void reader.cancel(error).catch(() => { });
40
+ output.error(error);
41
+ }
42
+ },
43
+ cancel(reason) { cleanup(); const cancelled = reader.cancel(reason); controller.abort(reason); return cancelled; },
44
+ }), { status: response.status, statusText: response.statusText, headers: response.headers });
45
+ }
46
+ catch (error) {
47
+ cleanup();
48
+ throw error;
49
+ }
50
+ }
@@ -0,0 +1,151 @@
1
+ import { resolveSettings } from "./config.js";
2
+ let jsonMode = false;
3
+ let quietMode = false;
4
+ export function configureOutput(flags) {
5
+ quietMode = flags.quiet ?? false;
6
+ if (flags.json && flags.pretty) {
7
+ fail(new CliError("--json and --pretty cannot be used together", "usage", 1));
8
+ }
9
+ const stdoutIsPipedToAnAgentOrFile = !process.stdout.isTTY;
10
+ if (flags.json)
11
+ jsonMode = true;
12
+ else if (flags.pretty)
13
+ jsonMode = false;
14
+ else
15
+ jsonMode = stdoutIsPipedToAnAgentOrFile;
16
+ }
17
+ export const isJSON = () => jsonMode;
18
+ export const isQuiet = () => quietMode;
19
+ const glyph = { info: "›", ok: "✓", warn: "!", err: "✗" };
20
+ const color = (code, s) => process.stderr.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s;
21
+ export function plain(text) {
22
+ return text
23
+ .replace(/\u001b\[[0-?]*[ -\/]*[@-~]/g, "")
24
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g, "")
25
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
26
+ }
27
+ const cell = (text) => plain(text ?? "").replace(/[\t\n]/g, " ");
28
+ export const log = (msg) => void process.stderr.write(`${color(2, glyph.info)} ${plain(msg)}\n`);
29
+ export const success = (msg) => void process.stderr.write(`${color(32, glyph.ok)} ${plain(msg)}\n`);
30
+ export const warn = (msg) => void process.stderr.write(`${color(33, glyph.warn)} ${plain(msg)}\n`);
31
+ export const errline = (msg) => void process.stderr.write(`${color(31, glyph.err)} ${plain(msg)}\n`);
32
+ export function emit(data, render) {
33
+ if (jsonMode) {
34
+ process.stdout.write(JSON.stringify(data, null, process.stdout.isTTY ? 2 : 0) + "\n");
35
+ return;
36
+ }
37
+ render();
38
+ }
39
+ export function emitIds(ids) {
40
+ process.stdout.write(ids.filter(Boolean).join("\n") + (ids.length ? "\n" : ""));
41
+ }
42
+ export function kv(rows, width = 12) {
43
+ for (const [k, v] of rows)
44
+ process.stdout.write(` ${k.padEnd(width)} ${cell(v)}\n`);
45
+ }
46
+ export function table(headers, rows) {
47
+ const clean = rows.map((r) => r.map(cell));
48
+ const widths = headers.map((h, i) => Math.max(h.length, ...clean.map((r) => (r[i] ?? "").length)));
49
+ const line = (cells) => cells.map((c, i) => (c ?? "").padEnd(widths[i])).join(" ").trimEnd();
50
+ process.stdout.write(" " + color(1, line(headers)) + "\n");
51
+ for (const r of clean)
52
+ process.stdout.write(" " + line(r) + "\n");
53
+ }
54
+ export const EXIT_CODE = { success: 0, usage: 1, api: 2, auth: 3, ratelimited: 4 };
55
+ export class CliError extends Error {
56
+ code;
57
+ exitCode;
58
+ requestId;
59
+ diagnosis;
60
+ constructor(message, code, exitCode, requestId, diagnosis) {
61
+ super(message);
62
+ this.code = code;
63
+ this.exitCode = exitCode ?? EXIT_CODE[code in EXIT_CODE ? code : "api"];
64
+ this.requestId = requestId;
65
+ this.diagnosis = diagnosis;
66
+ }
67
+ }
68
+ const SECRET_PATTERNS = [/Bearer\s+\S+/gi, /b3iq_(?:pat|key)_[A-Za-z0-9._-]+/g, /sk-[A-Za-z0-9._-]{8,}/g];
69
+ export function redact(text) {
70
+ let out = text;
71
+ const secrets = new Set();
72
+ for (const value of [process.env.B3IQ_TOKEN, process.env.B3IQ_GATEWAY_API_KEY]) {
73
+ if (value && value.length >= 8)
74
+ secrets.add(value);
75
+ }
76
+ try {
77
+ const token = resolveSettings().token;
78
+ if (token && token.length >= 8)
79
+ secrets.add(token);
80
+ }
81
+ catch {
82
+ }
83
+ for (const secret of secrets)
84
+ out = out.split(secret).join("[redacted]");
85
+ for (const pattern of SECRET_PATTERNS) {
86
+ pattern.lastIndex = 0;
87
+ out = out.replace(pattern, "[redacted]");
88
+ }
89
+ return out;
90
+ }
91
+ function redactObserved(observed) {
92
+ if (!observed)
93
+ return undefined;
94
+ const out = {};
95
+ for (const [key, value] of Object.entries(observed))
96
+ out[key] = typeof value === "string" ? redact(value) : value;
97
+ return out;
98
+ }
99
+ export function formatFailureLines(error) {
100
+ const lines = [redact(error.message)];
101
+ const diagnosis = error.diagnosis;
102
+ if (diagnosis) {
103
+ lines.push(`Cause: ${redact(diagnosis.cause)}`);
104
+ if (diagnosis.limitation)
105
+ lines.push(redact(diagnosis.limitation));
106
+ if (diagnosis.recovery.length) {
107
+ lines.push("Next:");
108
+ for (const step of diagnosis.recovery)
109
+ lines.push(` ${redact(step)}`);
110
+ }
111
+ if (diagnosis.command_id)
112
+ lines.push(`command_id: ${redact(diagnosis.command_id)}`);
113
+ }
114
+ if (error.requestId)
115
+ lines.push(`request_id: ${redact(error.requestId)}`);
116
+ return lines;
117
+ }
118
+ export function failureRecord(error) {
119
+ const diagnosis = error.diagnosis;
120
+ const observed = redactObserved(diagnosis?.observed);
121
+ return {
122
+ error: redact(error.message),
123
+ code: error.code,
124
+ request_id: error.requestId ? redact(error.requestId) : null,
125
+ ...(diagnosis
126
+ ? {
127
+ diagnostic: diagnosis.diagnostic,
128
+ cause: redact(diagnosis.cause),
129
+ ...(observed ? { observed } : {}),
130
+ recovery: diagnosis.recovery.map(redact),
131
+ ...(diagnosis.limitation ? { limitation: redact(diagnosis.limitation) } : {}),
132
+ ...(diagnosis.command_id ? { command_id: redact(diagnosis.command_id) } : {}),
133
+ }
134
+ : {}),
135
+ };
136
+ }
137
+ export function fail(err) {
138
+ const e = err instanceof CliError
139
+ ? err
140
+ : new CliError(err instanceof Error ? err.message : String(err), "api");
141
+ if (jsonMode) {
142
+ process.stderr.write(JSON.stringify(failureRecord(e)) + "\n");
143
+ }
144
+ else {
145
+ const [first, ...rest] = formatFailureLines(e);
146
+ errline(first ?? e.message);
147
+ for (const line of rest)
148
+ process.stderr.write(`${plain(line)}\n`);
149
+ }
150
+ process.exit(e.exitCode);
151
+ }