mycomesh-consumer 0.1.0

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 ADDED
@@ -0,0 +1,150 @@
1
+ # mycomesh-consumer
2
+
3
+ Node.js CLI for consumers calling a MycoMesh OpenAI-compatible Consumer edge.
4
+ The commands remain stateless: wallet funding and V5 Session activation happen
5
+ in the browser, while this package sends API requests to the local loopback
6
+ edge or an explicitly configured HTTPS Gateway.
7
+
8
+ The package is published as `mycomesh-consumer`. `mycomesh-consumer` and the
9
+ short `mycomesh` command are equivalent.
10
+
11
+ ## Requirements
12
+
13
+ - Node.js 20 or newer
14
+ - A MycoMesh Consumer base URL (the default is the local loopback edge)
15
+ - A consumer API key for authenticated endpoints
16
+ - For paid V5 inference, an active Session ID opened by the same wallet in the
17
+ local Web dApp
18
+
19
+ For local development, install it directly from the repository:
20
+
21
+ ```sh
22
+ npm install --global ./packages/mycomesh-cli
23
+ ```
24
+
25
+ For an npm install from the public registry:
26
+
27
+ ```sh
28
+ npm install --global mycomesh-consumer
29
+ mycomesh-consumer health
30
+ ```
31
+
32
+ This installs only the stateless Consumer CLI. It does not install or start
33
+ Bridge, Relay, Proxy, or Codex services. Provider operators should install the
34
+ separate `mycomesh-provider` package.
35
+
36
+ For a one-shot command without a global install:
37
+
38
+ ```sh
39
+ npx --yes --package=mycomesh-consumer mycomesh-consumer health
40
+ ```
41
+
42
+ ## Configuration
43
+
44
+ ```sh
45
+ export MYCOMESH_BASE_URL=http://127.0.0.1:8110/v1
46
+ export MYCOMESH_API_KEY=your-consumer-key
47
+ ```
48
+
49
+ `--base-url` and `--api-key` override their corresponding environment
50
+ variables. A base URL may be the service root or end in `/v1`.
51
+ Remote base URLs must use HTTPS. Loopback HTTP remains available for local
52
+ development; `--allow-insecure-http` is an explicit test-only escape hatch.
53
+ Requests default to a 300-second deadline. Set `MYCOMESH_TIMEOUT_SECONDS` or
54
+ `--timeout`; successful JSON bodies are capped at 32 MiB while SSE remains
55
+ streamed with backpressure.
56
+
57
+ Prefer `MYCOMESH_API_KEY` over `--api-key`: command-line arguments may be stored
58
+ in shell history or visible to other local processes. The CLI never writes the
59
+ key to disk and redacts it from HTTP error output.
60
+
61
+ For the local network, start `make consumer-up`, open the local Playground,
62
+ connect a browser wallet, fund V5 escrow and approve the one-time `openSession`
63
+ transaction. Copy the active Session ID from the local status/Playground, then
64
+ set:
65
+
66
+ ```sh
67
+ export MYCOMESH_BASE_URL=http://127.0.0.1:8110/v1
68
+ export MYCOMESH_API_KEY='replace-with-local-consumer-key'
69
+ export MYCOMESH_SESSION_ID='0x...replace-with-active-v5-session-id'
70
+ ```
71
+
72
+ The Session ID is not a replacement for the API key. Both values must belong to
73
+ the same local Consumer wallet. The CLI does not connect a wallet, move funds,
74
+ or open a V5 Session; the browser wallet flow does that once. A public Gateway
75
+ can still be used explicitly with `--base-url`, but it is never the default.
76
+
77
+ ## Commands
78
+
79
+ ```sh
80
+ mycomesh health
81
+ mycomesh models
82
+
83
+ mycomesh responses \
84
+ --input "Summarize this text" \
85
+ --model mycomesh-codex-standard-v1 \
86
+ --max-output-tokens 500
87
+
88
+ mycomesh chat \
89
+ --message "Explain this function" \
90
+ --system "Be concise." \
91
+ --model mycomesh-codex-standard-v1 \
92
+ --max-completion-tokens 500
93
+ ```
94
+
95
+ The CLI validates `MYCOMESH_SESSION_ID` and adds the required
96
+ `mycomesh_session` object automatically. Use `--session-id 0x...` to override it
97
+ for one command. A bare OpenAI-shaped request without either value is useful
98
+ only with a Gateway whose operator has explicitly configured another billing
99
+ path.
100
+
101
+ Use `--stream` to request SSE. SSE bytes are forwarded to stdout as they arrive,
102
+ without rewriting event boundaries:
103
+
104
+ ```sh
105
+ mycomesh responses \
106
+ --input "Write a short status update" \
107
+ --max-output-tokens 500 \
108
+ --stream
109
+ ```
110
+
111
+ ## JSON Input
112
+
113
+ Pass an inline object, read a file, or read stdin explicitly:
114
+
115
+ ```sh
116
+ mycomesh responses --json '{"model":"my-model","input":"hello"}'
117
+ mycomesh chat --json @request.json
118
+ generate-request | mycomesh responses --json -
119
+ ```
120
+
121
+ When `MYCOMESH_SESSION_ID` or `--session-id` is set, the CLI overwrites any JSON
122
+ session field with that validated value. A client constructing the complete raw
123
+ request body instead may include the extension directly:
124
+
125
+ ```json
126
+ {
127
+ "mycomesh_session": {
128
+ "session_id": "0x..."
129
+ }
130
+ }
131
+ ```
132
+
133
+ Piped stdin is treated as JSON even without `--json`:
134
+
135
+ ```sh
136
+ printf '%s' '{"model":"my-model","messages":[{"role":"user","content":"hello"}]}' \
137
+ | mycomesh chat
138
+ ```
139
+
140
+ Explicit flags such as `--model`, `--input`, `--message`, `--system`, and
141
+ `--stream` override the corresponding JSON fields. Successful JSON responses
142
+ are pretty-printed. SSE and other response types are copied to stdout. HTTP and
143
+ input errors are written to stderr and return a nonzero exit status.
144
+
145
+ ## Development
146
+
147
+ ```sh
148
+ cd packages/mycomesh-cli
149
+ npm test
150
+ ```
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../src/cli.mjs";
4
+
5
+ process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../src/cli.mjs";
4
+
5
+ process.exitCode = await main(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "mycomesh-consumer",
3
+ "version": "0.1.0",
4
+ "description": "Stateless MycoMesh Consumer CLI for the local loopback edge or an explicit HTTPS Gateway.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "bin": {
10
+ "mycomesh": "./bin/mycomesh.mjs",
11
+ "mycomesh-consumer": "./bin/mycomesh-consumer.mjs"
12
+ },
13
+ "files": [
14
+ "bin/mycomesh.mjs",
15
+ "bin/mycomesh-consumer.mjs",
16
+ "src/cli.mjs",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test"
24
+ },
25
+ "license": "UNLICENSED"
26
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,654 @@
1
+ import { once } from "node:events";
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ const CLI_VERSION = "0.1.0";
5
+ // The npm client is local-first. A public Gateway is an explicit override;
6
+ // the default remains the loopback Consumer edge so a blocked domain cannot
7
+ // strand the Codex client.
8
+ const DEFAULT_BASE_URL = "http://127.0.0.1:8110/v1";
9
+ const DEFAULT_TIMEOUT_SECONDS = 300;
10
+ const MAX_TIMEOUT_SECONDS = 3600;
11
+ const MAX_INPUT_BYTES = 16 * 1024 * 1024;
12
+ const MAX_ERROR_BYTES = 64 * 1024;
13
+ const MAX_JSON_RESPONSE_BYTES = 32 * 1024 * 1024;
14
+ const COMMANDS = new Set(["health", "models", "responses", "chat"]);
15
+
16
+ const HELP = `Usage: mycomesh <command> [options] [input]
17
+
18
+ Consumer commands:
19
+ health Check gateway health
20
+ models List available models
21
+ responses [input] Create a Responses API request
22
+ chat [message] Create a Chat Completions API request
23
+
24
+ Connection options:
25
+ --base-url <url> Consumer root or /v1 URL (default: local loopback)
26
+ (env: MYCOMESH_BASE_URL)
27
+ --api-key <key> Bearer API key (env: MYCOMESH_API_KEY)
28
+ --timeout <seconds> Request deadline, up to 3600 seconds
29
+ (env: MYCOMESH_TIMEOUT_SECONDS; default: 300)
30
+ --allow-insecure-http Allow cleartext HTTP to a non-loopback test gateway
31
+
32
+ Session options:
33
+ --session-id <bytes32> Use an already-opened V5 Session
34
+ (env: MYCOMESH_SESSION_ID)
35
+
36
+ Request body options:
37
+ --json <json|@file|-> JSON object, JSON file, or JSON from stdin
38
+ --model <model> Set the request model
39
+ --stream Request an SSE response
40
+ --no-stream Disable streaming from a JSON base body
41
+
42
+ Responses options:
43
+ --input <text> Set the Responses API input
44
+ --max-output-tokens <n> Set max_output_tokens
45
+
46
+ Chat options:
47
+ --message <text> Set the user message
48
+ --system <text> Prepend a system message
49
+ --max-tokens <n> Set max_tokens
50
+ --max-completion-tokens <n>
51
+ Set max_completion_tokens
52
+
53
+ Other options:
54
+ -h, --help Show this help
55
+ -v, --version Show the CLI version
56
+
57
+ When --json is omitted, piped stdin is read as a JSON request object. Explicit
58
+ request options override fields loaded from JSON.`;
59
+
60
+ class CliError extends Error {
61
+ constructor(message, exitCode = 1) {
62
+ super(message);
63
+ this.name = "CliError";
64
+ this.exitCode = exitCode;
65
+ }
66
+ }
67
+
68
+ class HttpError extends CliError {
69
+ constructor(status, statusText, body) {
70
+ const label = [status, statusText].filter(Boolean).join(" ");
71
+ super(`HTTP ${label}${body ? `\n${body}` : ""}`);
72
+ this.name = "HttpError";
73
+ }
74
+ }
75
+
76
+ export async function main(argv, dependencies = {}) {
77
+ const env = dependencies.env ?? process.env;
78
+ const stdin = dependencies.stdin ?? process.stdin;
79
+ const stdout = dependencies.stdout ?? process.stdout;
80
+ const stderr = dependencies.stderr ?? process.stderr;
81
+ const fetchImpl = dependencies.fetch ?? globalThis.fetch;
82
+ const maxJsonResponseBytes =
83
+ dependencies.maxJsonResponseBytes ?? MAX_JSON_RESPONSE_BYTES;
84
+ let apiKey = env.MYCOMESH_API_KEY || "";
85
+ let requestTimeout;
86
+
87
+ try {
88
+ const parsed = parseArguments(argv, env);
89
+ apiKey = parsed.apiKey;
90
+
91
+ if (parsed.help) {
92
+ stdout.write(`${HELP}\n`);
93
+ return 0;
94
+ }
95
+ if (parsed.version) {
96
+ stdout.write(`${CLI_VERSION}\n`);
97
+ return 0;
98
+ }
99
+ if (typeof fetchImpl !== "function") {
100
+ throw new CliError("Node.js 20 or newer is required");
101
+ }
102
+
103
+ const request = await buildRequest(parsed, stdin);
104
+ requestTimeout = createRequestTimeout(parsed.timeoutSeconds);
105
+ const response = await sendRequest(
106
+ request,
107
+ fetchImpl,
108
+ requestTimeout.signal,
109
+ parsed.timeoutSeconds,
110
+ );
111
+ if (!response.ok) {
112
+ const errorBody = await readLimitedText(response.body, MAX_ERROR_BYTES);
113
+ throw new HttpError(response.status, response.statusText, errorBody.trim());
114
+ }
115
+
116
+ await writeResponse(response, stdout, maxJsonResponseBytes);
117
+ return 0;
118
+ } catch (error) {
119
+ const exitCode = error instanceof CliError ? error.exitCode : 1;
120
+ const message = requestTimeout?.signal.aborted
121
+ ? `request timed out after ${requestTimeout.timeoutSeconds} seconds`
122
+ : error instanceof Error
123
+ ? error.message
124
+ : String(error);
125
+ stderr.write(`mycomesh: ${redact(message, apiKey)}\n`);
126
+ return exitCode;
127
+ } finally {
128
+ requestTimeout?.cancel();
129
+ }
130
+ }
131
+
132
+ export function parseArguments(argv, env = process.env) {
133
+ const parsed = {
134
+ command: undefined,
135
+ baseUrl: env.MYCOMESH_BASE_URL || DEFAULT_BASE_URL,
136
+ apiKey: env.MYCOMESH_API_KEY || "",
137
+ timeoutSeconds: requestTimeoutSeconds(
138
+ env.MYCOMESH_TIMEOUT_SECONDS || String(DEFAULT_TIMEOUT_SECONDS),
139
+ "MYCOMESH_TIMEOUT_SECONDS",
140
+ ),
141
+ allowInsecureHttp: false,
142
+ sessionId: env.MYCOMESH_SESSION_ID || "",
143
+ sessionIdExplicit: false,
144
+ jsonSource: undefined,
145
+ model: undefined,
146
+ input: undefined,
147
+ message: undefined,
148
+ system: undefined,
149
+ stream: undefined,
150
+ maxOutputTokens: undefined,
151
+ maxTokens: undefined,
152
+ maxCompletionTokens: undefined,
153
+ positionals: [],
154
+ help: false,
155
+ version: false,
156
+ };
157
+
158
+ for (let index = 0; index < argv.length; index += 1) {
159
+ const token = argv[index];
160
+ if (token === "--") {
161
+ parsed.positionals.push(...argv.slice(index + 1));
162
+ break;
163
+ }
164
+ if (token === "-h" || token === "--help") {
165
+ parsed.help = true;
166
+ continue;
167
+ }
168
+ if (token === "-v" || token === "--version") {
169
+ parsed.version = true;
170
+ continue;
171
+ }
172
+ if (token === "--stream") {
173
+ parsed.stream = true;
174
+ continue;
175
+ }
176
+ if (token === "--no-stream") {
177
+ parsed.stream = false;
178
+ continue;
179
+ }
180
+ if (token === "--allow-insecure-http") {
181
+ parsed.allowInsecureHttp = true;
182
+ continue;
183
+ }
184
+ if (token.startsWith("--")) {
185
+ const separator = token.indexOf("=");
186
+ const name = separator === -1 ? token : token.slice(0, separator);
187
+ let value = separator === -1 ? undefined : token.slice(separator + 1);
188
+ if (value === undefined) {
189
+ index += 1;
190
+ value = argv[index];
191
+ }
192
+ if (value === undefined) {
193
+ throw new CliError(`${name} requires a value`, 2);
194
+ }
195
+ switch (name) {
196
+ case "--base-url":
197
+ parsed.baseUrl = value;
198
+ break;
199
+ case "--api-key":
200
+ parsed.apiKey = value;
201
+ break;
202
+ case "--timeout":
203
+ parsed.timeoutSeconds = requestTimeoutSeconds(value, name);
204
+ break;
205
+ case "--session-id":
206
+ parsed.sessionId = value;
207
+ parsed.sessionIdExplicit = true;
208
+ break;
209
+ case "--json":
210
+ parsed.jsonSource = value;
211
+ break;
212
+ case "--model":
213
+ parsed.model = value;
214
+ break;
215
+ case "--input":
216
+ parsed.input = value;
217
+ break;
218
+ case "--message":
219
+ parsed.message = value;
220
+ break;
221
+ case "--system":
222
+ parsed.system = value;
223
+ break;
224
+ case "--max-output-tokens":
225
+ parsed.maxOutputTokens = positiveInteger(value, name);
226
+ break;
227
+ case "--max-tokens":
228
+ parsed.maxTokens = positiveInteger(value, name);
229
+ break;
230
+ case "--max-completion-tokens":
231
+ parsed.maxCompletionTokens = positiveInteger(value, name);
232
+ break;
233
+ default:
234
+ throw new CliError(`unknown option: ${name}`, 2);
235
+ }
236
+ continue;
237
+ }
238
+ if (token.startsWith("-") && token !== "-") {
239
+ throw new CliError(`unknown option: ${token}`, 2);
240
+ }
241
+ if (parsed.command === undefined) {
242
+ parsed.command = token;
243
+ } else {
244
+ parsed.positionals.push(token);
245
+ }
246
+ }
247
+
248
+ if (!parsed.command && !parsed.help && !parsed.version) {
249
+ throw new CliError("a command is required; run mycomesh --help", 2);
250
+ }
251
+ if (parsed.command && !COMMANDS.has(parsed.command)) {
252
+ throw new CliError(`unknown command: ${parsed.command}`, 2);
253
+ }
254
+ if (
255
+ parsed.sessionId &&
256
+ (parsed.command === "responses" || parsed.command === "chat")
257
+ ) {
258
+ parsed.sessionId = sessionId(parsed.sessionId, "--session-id");
259
+ }
260
+ validateCommandOptions(parsed);
261
+ return parsed;
262
+ }
263
+
264
+ async function buildRequest(parsed, stdin) {
265
+ const url = endpointUrl(
266
+ parsed.baseUrl,
267
+ parsed.command,
268
+ parsed.allowInsecureHttp,
269
+ );
270
+ const headers = {
271
+ accept: "application/json",
272
+ "user-agent": `mycomesh-cli/${CLI_VERSION}`,
273
+ };
274
+ if (parsed.apiKey) {
275
+ headers.authorization = `Bearer ${parsed.apiKey}`;
276
+ }
277
+
278
+ if (parsed.command === "health" || parsed.command === "models") {
279
+ return { url, method: "GET", headers };
280
+ }
281
+
282
+ const body = await requestBody(parsed, stdin);
283
+ headers["content-type"] = "application/json";
284
+ if (body.stream === true) {
285
+ headers.accept = "text/event-stream, application/json";
286
+ }
287
+ return {
288
+ url,
289
+ method: "POST",
290
+ headers,
291
+ body: JSON.stringify(body),
292
+ };
293
+ }
294
+
295
+ async function requestBody(parsed, stdin) {
296
+ let body = {};
297
+ if (parsed.jsonSource !== undefined) {
298
+ body = await parseJsonSource(parsed.jsonSource, stdin);
299
+ } else if (stdin && stdin.isTTY !== true) {
300
+ const piped = await readLimitedText(stdin, MAX_INPUT_BYTES);
301
+ if (piped.trim()) {
302
+ body = parseJsonObject(piped, "stdin");
303
+ }
304
+ }
305
+
306
+ if (parsed.model !== undefined) {
307
+ body.model = parsed.model;
308
+ }
309
+ if (parsed.stream !== undefined) {
310
+ body.stream = parsed.stream;
311
+ }
312
+ if (parsed.sessionId) {
313
+ body.mycomesh_session = { session_id: parsed.sessionId };
314
+ }
315
+
316
+ const positionalInput = parsed.positionals.length
317
+ ? parsed.positionals.join(" ")
318
+ : undefined;
319
+ if (parsed.command === "responses") {
320
+ const input = parsed.input ?? positionalInput;
321
+ if (input !== undefined) {
322
+ body.input = input;
323
+ }
324
+ if (parsed.maxOutputTokens !== undefined) {
325
+ body.max_output_tokens = parsed.maxOutputTokens;
326
+ }
327
+ if (body.input === undefined) {
328
+ throw new CliError(
329
+ "responses requires input in JSON, --input, or a positional argument",
330
+ 2,
331
+ );
332
+ }
333
+ } else {
334
+ const message = parsed.message ?? positionalInput;
335
+ if (message !== undefined) {
336
+ const messages = [];
337
+ if (parsed.system !== undefined) {
338
+ messages.push({ role: "system", content: parsed.system });
339
+ }
340
+ messages.push({ role: "user", content: message });
341
+ body.messages = messages;
342
+ } else if (parsed.system !== undefined) {
343
+ const messages = Array.isArray(body.messages) ? body.messages : [];
344
+ body.messages = [
345
+ { role: "system", content: parsed.system },
346
+ ...messages,
347
+ ];
348
+ }
349
+ if (parsed.maxTokens !== undefined) {
350
+ body.max_tokens = parsed.maxTokens;
351
+ }
352
+ if (parsed.maxCompletionTokens !== undefined) {
353
+ body.max_completion_tokens = parsed.maxCompletionTokens;
354
+ }
355
+ if (!Array.isArray(body.messages) || body.messages.length === 0) {
356
+ throw new CliError(
357
+ "chat requires messages in JSON, --message, or a positional argument",
358
+ 2,
359
+ );
360
+ }
361
+ }
362
+ return body;
363
+ }
364
+
365
+ async function parseJsonSource(source, stdin) {
366
+ if (source === "-") {
367
+ const text = await readLimitedText(stdin, MAX_INPUT_BYTES);
368
+ return parseJsonObject(text, "stdin");
369
+ }
370
+ if (source.startsWith("@")) {
371
+ const path = source.slice(1);
372
+ if (!path) {
373
+ throw new CliError("--json @file requires a file path", 2);
374
+ }
375
+ let contents;
376
+ try {
377
+ contents = await readFile(path);
378
+ } catch (error) {
379
+ const detail = error instanceof Error ? error.message : String(error);
380
+ throw new CliError(`could not read JSON file: ${detail}`, 2);
381
+ }
382
+ if (contents.byteLength > MAX_INPUT_BYTES) {
383
+ throw new CliError(`JSON input exceeds ${MAX_INPUT_BYTES} bytes`, 2);
384
+ }
385
+ return parseJsonObject(contents.toString("utf8"), path);
386
+ }
387
+ if (Buffer.byteLength(source, "utf8") > MAX_INPUT_BYTES) {
388
+ throw new CliError(`JSON input exceeds ${MAX_INPUT_BYTES} bytes`, 2);
389
+ }
390
+ return parseJsonObject(source, "--json");
391
+ }
392
+
393
+ function parseJsonObject(text, source) {
394
+ let value;
395
+ try {
396
+ value = JSON.parse(text);
397
+ } catch (error) {
398
+ const detail = error instanceof Error ? error.message : String(error);
399
+ throw new CliError(`invalid JSON from ${source}: ${detail}`, 2);
400
+ }
401
+ if (value === null || Array.isArray(value) || typeof value !== "object") {
402
+ throw new CliError(`JSON from ${source} must be an object`, 2);
403
+ }
404
+ return { ...value };
405
+ }
406
+
407
+ function endpointUrl(baseUrl, command, allowInsecureHttp = false) {
408
+ let url;
409
+ try {
410
+ url = new URL(baseUrl);
411
+ } catch {
412
+ throw new CliError("base URL must be an absolute HTTP or HTTPS URL", 2);
413
+ }
414
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
415
+ throw new CliError("base URL must use HTTP or HTTPS", 2);
416
+ }
417
+ if (url.username || url.password) {
418
+ throw new CliError("base URL must not contain credentials", 2);
419
+ }
420
+ if (
421
+ url.protocol === "http:" &&
422
+ !allowInsecureHttp &&
423
+ !isLoopbackHostname(url.hostname)
424
+ ) {
425
+ throw new CliError(
426
+ "remote base URLs must use HTTPS; use --allow-insecure-http only for testing",
427
+ 2,
428
+ );
429
+ }
430
+ if (url.search || url.hash) {
431
+ throw new CliError("base URL must not contain a query string or fragment", 2);
432
+ }
433
+
434
+ let path = url.pathname.replace(/\/+$/, "");
435
+ if (command === "health") {
436
+ path = path.replace(/\/v1$/, "");
437
+ url.pathname = `${path}/health`;
438
+ } else {
439
+ if (!path.endsWith("/v1")) {
440
+ path = `${path}/v1`;
441
+ }
442
+ const resource = command === "chat" ? "chat/completions" : command;
443
+ url.pathname = `${path}/${resource}`;
444
+ }
445
+ return url;
446
+ }
447
+
448
+ function isLoopbackHostname(hostname) {
449
+ const normalized = hostname.toLowerCase().replace(/\.$/, "");
450
+ if (normalized === "localhost" || normalized === "[::1]") {
451
+ return true;
452
+ }
453
+ if (!/^127(?:\.[0-9]{1,3}){3}$/.test(normalized)) {
454
+ return false;
455
+ }
456
+ return normalized.split(".").every((part) => Number(part) <= 255);
457
+ }
458
+
459
+ async function sendRequest(request, fetchImpl, signal, timeoutSeconds) {
460
+ try {
461
+ return await fetchImpl(request.url, {
462
+ method: request.method,
463
+ headers: request.headers,
464
+ body: request.body,
465
+ redirect: "error",
466
+ signal,
467
+ });
468
+ } catch (error) {
469
+ if (signal.aborted) {
470
+ throw new CliError(`request timed out after ${timeoutSeconds} seconds`);
471
+ }
472
+ const detail = error instanceof Error ? error.message : String(error);
473
+ throw new CliError(`request failed: ${detail}`);
474
+ }
475
+ }
476
+
477
+ async function writeResponse(response, output, maxJsonResponseBytes) {
478
+ const contentType = (response.headers.get("content-type") || "")
479
+ .split(";", 1)[0]
480
+ .trim()
481
+ .toLowerCase();
482
+
483
+ if (contentType === "text/event-stream") {
484
+ await pipeBody(response.body, output);
485
+ return;
486
+ }
487
+ if (contentType === "application/json" || contentType.endsWith("+json")) {
488
+ const text = await readLimitedText(response.body, maxJsonResponseBytes, {
489
+ rejectOnLimit: true,
490
+ label: "JSON response",
491
+ });
492
+ if (!text.trim()) {
493
+ return;
494
+ }
495
+ let value;
496
+ try {
497
+ value = JSON.parse(text);
498
+ } catch {
499
+ throw new CliError("server returned malformed JSON");
500
+ }
501
+ output.write(`${JSON.stringify(value, null, 2)}\n`);
502
+ return;
503
+ }
504
+ await pipeBody(response.body, output);
505
+ }
506
+
507
+ async function pipeBody(body, output) {
508
+ if (!body) {
509
+ return;
510
+ }
511
+ for await (const chunk of body) {
512
+ if (!output.write(chunk)) {
513
+ await once(output, "drain");
514
+ }
515
+ }
516
+ }
517
+
518
+ async function readLimitedText(
519
+ stream,
520
+ limit,
521
+ { rejectOnLimit = false, label = "response" } = {},
522
+ ) {
523
+ if (!stream) {
524
+ return "";
525
+ }
526
+ const chunks = [];
527
+ let size = 0;
528
+ let truncated = false;
529
+ for await (const rawChunk of stream) {
530
+ const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk);
531
+ const remaining = limit - size;
532
+ if (chunk.byteLength > remaining) {
533
+ if (remaining > 0) {
534
+ chunks.push(chunk.subarray(0, remaining));
535
+ }
536
+ truncated = true;
537
+ break;
538
+ }
539
+ chunks.push(chunk);
540
+ size += chunk.byteLength;
541
+ }
542
+ const text = Buffer.concat(chunks).toString("utf8");
543
+ if (truncated && rejectOnLimit) {
544
+ throw new CliError(`${label} exceeds ${limit} bytes`);
545
+ }
546
+ return truncated ? `${text}\n[response truncated]` : text;
547
+ }
548
+
549
+ function createRequestTimeout(timeoutSeconds) {
550
+ const controller = new AbortController();
551
+ const timer = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
552
+ timer.unref?.();
553
+ return {
554
+ signal: controller.signal,
555
+ timeoutSeconds,
556
+ cancel() {
557
+ clearTimeout(timer);
558
+ },
559
+ };
560
+ }
561
+
562
+ function requestTimeoutSeconds(value, option) {
563
+ const timeout = positiveInteger(String(value), option);
564
+ if (timeout > MAX_TIMEOUT_SECONDS) {
565
+ throw new CliError(
566
+ `${option} must not exceed ${MAX_TIMEOUT_SECONDS} seconds`,
567
+ 2,
568
+ );
569
+ }
570
+ return timeout;
571
+ }
572
+
573
+ function positiveInteger(value, option) {
574
+ if (!/^[1-9][0-9]*$/.test(value)) {
575
+ throw new CliError(`${option} must be a positive integer`, 2);
576
+ }
577
+ const number = Number(value);
578
+ if (!Number.isSafeInteger(number)) {
579
+ throw new CliError(`${option} is too large`, 2);
580
+ }
581
+ return number;
582
+ }
583
+
584
+ function sessionId(value, option) {
585
+ if (!/^0x[0-9a-fA-F]{64}$/.test(value)) {
586
+ throw new CliError(`${option} must be a 32-byte 0x-prefixed hex value`, 2);
587
+ }
588
+ return value.toLowerCase();
589
+ }
590
+
591
+ function validateCommandOptions(parsed) {
592
+ if (parsed.help || parsed.version || !parsed.command) {
593
+ return;
594
+ }
595
+ const hasBodyOptions =
596
+ parsed.jsonSource !== undefined ||
597
+ parsed.model !== undefined ||
598
+ parsed.stream !== undefined ||
599
+ parsed.input !== undefined ||
600
+ parsed.message !== undefined ||
601
+ parsed.system !== undefined ||
602
+ parsed.maxOutputTokens !== undefined ||
603
+ parsed.maxTokens !== undefined ||
604
+ parsed.maxCompletionTokens !== undefined ||
605
+ parsed.positionals.length > 0;
606
+ if ((parsed.command === "health" || parsed.command === "models") && hasBodyOptions) {
607
+ throw new CliError(`${parsed.command} does not accept request body options`, 2);
608
+ }
609
+ if (
610
+ (parsed.command === "health" || parsed.command === "models") &&
611
+ parsed.sessionIdExplicit
612
+ ) {
613
+ throw new CliError(`${parsed.command} does not accept --session-id`, 2);
614
+ }
615
+ if (
616
+ parsed.command === "responses" &&
617
+ (parsed.message !== undefined ||
618
+ parsed.system !== undefined ||
619
+ parsed.maxTokens !== undefined ||
620
+ parsed.maxCompletionTokens !== undefined)
621
+ ) {
622
+ throw new CliError("chat options cannot be used with responses", 2);
623
+ }
624
+ if (
625
+ parsed.command === "chat" &&
626
+ (parsed.input !== undefined || parsed.maxOutputTokens !== undefined)
627
+ ) {
628
+ throw new CliError("responses options cannot be used with chat", 2);
629
+ }
630
+ if (parsed.input !== undefined && parsed.positionals.length > 0) {
631
+ throw new CliError("use either --input or positional input, not both", 2);
632
+ }
633
+ if (parsed.message !== undefined && parsed.positionals.length > 0) {
634
+ throw new CliError("use either --message or a positional message, not both", 2);
635
+ }
636
+ }
637
+
638
+ function redact(value, secret) {
639
+ let text = String(value);
640
+ if (!secret) {
641
+ return text;
642
+ }
643
+ const variants = new Set([
644
+ secret,
645
+ encodeURIComponent(secret),
646
+ JSON.stringify(secret).slice(1, -1),
647
+ ]);
648
+ for (const variant of variants) {
649
+ if (variant) {
650
+ text = text.split(variant).join("[REDACTED]");
651
+ }
652
+ }
653
+ return text;
654
+ }