@impulselab/hepha 0.1.0 → 0.2.44

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/dist/index.js CHANGED
@@ -12,12 +12,50 @@ import { homedir } from "os";
12
12
  import { dirname, join } from "path";
13
13
 
14
14
  // src/lib/errors.ts
15
+ var CLI_ERROR_CODES = {
16
+ BAD_USAGE: "BAD_USAGE",
17
+ /** The session needs a person — the exit-3 family. */
18
+ NEEDS_HUMAN: "NEEDS_HUMAN",
19
+ NOT_AUTHENTICATED: "NOT_AUTHENTICATED",
20
+ NETWORK: "NETWORK",
21
+ TIMEOUT: "TIMEOUT",
22
+ /** Ctrl-C: a cancellation, not a failure of ours. */
23
+ INTERRUPTED: "INTERRUPTED",
24
+ /** A 2xx whose shape this CLI version does not understand. */
25
+ API_SCHEMA_MISMATCH: "API_SCHEMA_MISMATCH",
26
+ /** Not JSON at all — wrong host, proxy, error page. */
27
+ NON_JSON_RESPONSE: "NON_JSON_RESPONSE",
28
+ ERROR: "ERROR"
29
+ };
15
30
  var CliError = class extends Error {
16
31
  exitCode;
17
- constructor(message, exitCode = 1) {
32
+ details;
33
+ /**
34
+ * A followed session parking on a question is a non-zero outcome AND a result
35
+ * worth reading. Carried here so stdout stays ONE JSON document.
36
+ */
37
+ result;
38
+ constructor(message, exitCode = 1, details = {}, result) {
18
39
  super(message);
19
40
  this.name = "CliError";
20
41
  this.exitCode = exitCode;
42
+ this.details = details;
43
+ if (result !== void 0) {
44
+ this.result = result;
45
+ }
46
+ }
47
+ /** The code to report, defaulting to whatever the exit code implies. */
48
+ get code() {
49
+ if (this.details.code) {
50
+ return this.details.code;
51
+ }
52
+ if (this.exitCode === 2) {
53
+ return CLI_ERROR_CODES.BAD_USAGE;
54
+ }
55
+ if (this.exitCode === 3) {
56
+ return CLI_ERROR_CODES.NEEDS_HUMAN;
57
+ }
58
+ return CLI_ERROR_CODES.ERROR;
21
59
  }
22
60
  };
23
61
 
@@ -105,7 +143,144 @@ var config = {
105
143
  }
106
144
  };
107
145
 
146
+ // src/lib/output.ts
147
+ var COLORS = {
148
+ reset: "\x1B[0m",
149
+ dim: "\x1B[2m",
150
+ bold: "\x1B[1m",
151
+ red: "\x1B[31m",
152
+ green: "\x1B[32m",
153
+ yellow: "\x1B[33m",
154
+ blue: "\x1B[34m",
155
+ magenta: "\x1B[35m"
156
+ };
157
+ var STATUS_COLORS = {
158
+ running: "blue",
159
+ provisioning: "blue",
160
+ queued: "dim",
161
+ created: "dim",
162
+ delivering: "blue",
163
+ verifying: "blue",
164
+ waiting_human: "yellow",
165
+ awaiting_approval: "yellow",
166
+ completed: "green",
167
+ failed: "red",
168
+ killed: "red",
169
+ abstained: "yellow"
170
+ };
171
+ function supportsColor() {
172
+ return process.stdout.isTTY === true && process.env.NO_COLOR === void 0 && !jsonMode;
173
+ }
174
+ var jsonMode = false;
175
+ var out = {
176
+ /** Declared once per run, before anything can fail. */
177
+ setJson(enabled) {
178
+ jsonMode = enabled;
179
+ },
180
+ isJson() {
181
+ return jsonMode;
182
+ },
183
+ json(value) {
184
+ process.stdout.write(`${JSON.stringify(value, null, 2)}
185
+ `);
186
+ },
187
+ line(text = "") {
188
+ process.stdout.write(`${text}
189
+ `);
190
+ },
191
+ error(text) {
192
+ process.stderr.write(`${out.paint(`error: ${out.safe(text)}`, "red")}
193
+ `);
194
+ },
195
+ /**
196
+ * The one place a failure is reported. Under `--json` stdout must be
197
+ * parseable, and it was not: failures went to stderr as prose. Success keeps
198
+ * its bare shape, so nothing that already parses stdout has to change.
199
+ */
200
+ failure(error) {
201
+ if (!jsonMode) {
202
+ out.error(error.message);
203
+ return;
204
+ }
205
+ out.json({
206
+ ok: false,
207
+ error: {
208
+ ...error.details.status === void 0 ? {} : { status: error.details.status },
209
+ code: error.code,
210
+ message: out.safe(error.message),
211
+ ...error.details.issues ? { issues: error.details.issues } : {},
212
+ ...error.details.retryAfterSeconds === void 0 ? {} : { retryAfterSeconds: error.details.retryAfterSeconds }
213
+ },
214
+ // Inside the envelope, so stdout stays one parseable document.
215
+ ...error.result === void 0 ? {} : { result: error.result }
216
+ });
217
+ },
218
+ paint(text, color) {
219
+ if (!supportsColor()) {
220
+ return text;
221
+ }
222
+ return `${COLORS[color] ?? ""}${text}${COLORS.reset}`;
223
+ },
224
+ /**
225
+ * Neutralise terminal control sequences in text the platform gives us.
226
+ *
227
+ * Transcript content, PR titles and question bodies carry model output, tool
228
+ * output and repository contents — none of it trusted. Printed raw, an ESC
229
+ * sequence could repaint or erase what the user already read, or emit an OSC
230
+ * 8 hyperlink pointing somewhere else entirely. Newlines and tabs survive;
231
+ * every other C0/C1 control does not. (`--json` is unaffected: JSON.stringify
232
+ * already escapes control characters.)
233
+ */
234
+ safe(text) {
235
+ return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
236
+ },
237
+ /** `safe`, flattened to one line — anything going into a table cell. */
238
+ cell(text) {
239
+ return out.safe(text).replace(/\s+/g, " ").trim();
240
+ },
241
+ status(status) {
242
+ return out.paint(status, STATUS_COLORS[status] ?? "reset");
243
+ },
244
+ /** Left-aligned two-column block — the shape used by every list command. */
245
+ table(rows) {
246
+ if (rows.length === 0) {
247
+ return;
248
+ }
249
+ const widths = rows[0].map(
250
+ (_, column) => Math.max(...rows.map((row) => stripAnsi(row[column] ?? "").length))
251
+ );
252
+ for (const row of rows) {
253
+ const line = row.map((cell, column) => {
254
+ const padding = widths[column] - stripAnsi(cell).length;
255
+ return column === row.length - 1 ? cell : cell + " ".repeat(padding);
256
+ }).join(" ");
257
+ out.line(line.trimEnd());
258
+ }
259
+ }
260
+ };
261
+ function stripAnsi(text) {
262
+ return text.replace(/\u001b\[\d+m/g, "");
263
+ }
264
+
108
265
  // src/lib/api.ts
266
+ var DEFAULT_TIMEOUT_MS = 3e4;
267
+ function resolveTimeout(override) {
268
+ if (override !== void 0) {
269
+ return override;
270
+ }
271
+ const raw = process.env.HEPHA_TIMEOUT_MS?.trim();
272
+ if (!raw) {
273
+ return DEFAULT_TIMEOUT_MS;
274
+ }
275
+ const parsed = Number(raw);
276
+ if (!Number.isFinite(parsed) || parsed <= 0) {
277
+ throw new CliError(
278
+ `HEPHA_TIMEOUT_MS expects a positive number of milliseconds, got "${raw}".`,
279
+ 2
280
+ );
281
+ }
282
+ return parsed;
283
+ }
109
284
  function authHeaders(as) {
110
285
  if (as) {
111
286
  return { authorization: `Bearer ${as.token}` };
@@ -119,24 +294,54 @@ function authHeaders(as) {
119
294
  return { authorization: `Bearer ${token}` };
120
295
  }
121
296
  throw new CliError(
122
- "Not signed in. Run `hepha login`, or set HEPHA_API_KEY to act as an agent."
297
+ "Not signed in. Run `hepha login`, or set HEPHA_API_KEY to act as an agent.",
298
+ 1,
299
+ { code: CLI_ERROR_CODES.NOT_AUTHENTICATED }
123
300
  );
124
301
  }
302
+ function readIssues(payload) {
303
+ if (!payload || typeof payload !== "object") {
304
+ return void 0;
305
+ }
306
+ const data = payload.data;
307
+ if (!data || typeof data !== "object") {
308
+ return void 0;
309
+ }
310
+ const issues = data.issues;
311
+ return Array.isArray(issues) ? issues : void 0;
312
+ }
313
+ function readString2(payload, key) {
314
+ if (!payload || typeof payload !== "object") {
315
+ return void 0;
316
+ }
317
+ const value = payload[key];
318
+ return typeof value === "string" && value.length > 0 ? value : void 0;
319
+ }
320
+ function retryAfterSeconds(response) {
321
+ const raw = response.headers.get("retry-after") ?? response.headers.get("x-retry-after");
322
+ if (!raw) {
323
+ return void 0;
324
+ }
325
+ const seconds = Number(raw.trim());
326
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : void 0;
327
+ }
125
328
  function messageFrom(status, payload) {
126
- if (payload && typeof payload === "object" && "message" in payload) {
127
- const message = payload.message;
128
- if (typeof message === "string" && message.length > 0) {
129
- return message;
130
- }
329
+ const message = readString2(payload, "message");
330
+ if (message) {
331
+ return status === 403 && process.env.HEPHA_API_KEY ? `${message} (HEPHA_API_KEY is set and takes precedence over your \`hepha login\` session \u2014 unset it to act as yourself.)` : message;
131
332
  }
132
333
  if (status === 401) {
133
334
  return "Unauthorized \u2014 your credential is missing, expired or revoked.";
134
335
  }
135
336
  return `Request failed with status ${status}.`;
136
337
  }
137
- async function api(path, options = {}) {
338
+ function excerpt(text) {
339
+ const flattened = out.cell(text);
340
+ return flattened.length > 200 ? `${flattened.slice(0, 200)}\u2026` : flattened;
341
+ }
342
+ async function api(requestPath, options = {}) {
138
343
  const baseUrl = options.as?.baseUrl ?? config.load().baseUrl;
139
- const url = new URL(`${baseUrl.replace(/\/$/, "")}/api/v1${path}`);
344
+ const url = new URL(`${baseUrl.replace(/\/$/, "")}/api/v1${requestPath}`);
140
345
  for (const [key, value] of Object.entries(options.query ?? {})) {
141
346
  if (value !== void 0) {
142
347
  url.searchParams.set(key, String(value));
@@ -146,37 +351,103 @@ async function api(path, options = {}) {
146
351
  ...authHeaders(options.as),
147
352
  ...options.body === void 0 ? {} : { "content-type": "application/json" }
148
353
  };
354
+ const method = options.method ?? "GET";
355
+ const deadline = resolveTimeout(options.timeoutMs);
356
+ const controller = new AbortController();
357
+ let abortedBy = null;
358
+ const timer = setTimeout(() => {
359
+ abortedBy ??= "timeout";
360
+ controller.abort();
361
+ }, deadline);
362
+ const onInterrupt = () => {
363
+ abortedBy ??= "interrupt";
364
+ controller.abort();
365
+ };
366
+ process.once("SIGINT", onInterrupt);
149
367
  let response;
368
+ let text;
150
369
  try {
151
370
  response = await fetch(url, {
152
- method: options.method ?? "GET",
371
+ method,
153
372
  headers,
373
+ signal: controller.signal,
154
374
  ...options.body === void 0 ? {} : { body: JSON.stringify(options.body) }
155
375
  });
376
+ text = await response.text();
156
377
  } catch (cause) {
378
+ if (abortedBy === "interrupt") {
379
+ throw new CliError(`${method} ${url.pathname} interrupted.`, 130, {
380
+ code: CLI_ERROR_CODES.INTERRUPTED
381
+ });
382
+ }
383
+ if (abortedBy === "timeout") {
384
+ throw new CliError(
385
+ `${method} ${url.pathname} timed out after ${deadline}ms. Raise HEPHA_TIMEOUT_MS if the platform is simply slow.`,
386
+ 1,
387
+ { code: CLI_ERROR_CODES.TIMEOUT }
388
+ );
389
+ }
157
390
  throw new CliError(
158
- `Cannot reach ${baseUrl}. Set HEPHA_URL if the platform lives elsewhere. (${String(cause)})`
391
+ `Cannot reach ${baseUrl}. Set HEPHA_URL if the platform lives elsewhere. (${String(cause)})`,
392
+ 1,
393
+ { code: CLI_ERROR_CODES.NETWORK }
159
394
  );
395
+ } finally {
396
+ clearTimeout(timer);
397
+ process.removeListener("SIGINT", onInterrupt);
160
398
  }
161
- const text = await response.text();
399
+ const retryAfter = retryAfterSeconds(response);
162
400
  let payload = null;
163
401
  if (text) {
164
402
  try {
165
403
  payload = JSON.parse(text);
166
404
  } catch {
405
+ const body = excerpt(text);
406
+ const contentType = response.headers.get("content-type") ?? "unknown";
167
407
  throw new CliError(
168
- `${url.origin} answered ${response.status} with a non-JSON body. Is HEPHA_URL pointing at a Hephaistos deployment?`
408
+ response.ok ? `${method} ${url.pathname} answered ${response.status} with a non-JSON body (${contentType}). Is HEPHA_URL pointing at a Hephaistos deployment?${body ? ` Body: ${body}` : ""}` : `${method} ${url.pathname} failed with ${response.status} and a non-JSON body (${contentType}).${body ? ` Body: ${body}` : ""}`,
409
+ 1,
410
+ {
411
+ status: response.status,
412
+ code: CLI_ERROR_CODES.NON_JSON_RESPONSE,
413
+ ...retryAfter === void 0 ? {} : { retryAfterSeconds: retryAfter }
414
+ }
169
415
  );
170
416
  }
171
417
  }
172
418
  if (!response.ok) {
173
- throw new CliError(messageFrom(response.status, payload));
419
+ const code = readString2(payload, "code") ?? readString2(payload, "error");
420
+ const issues = readIssues(payload);
421
+ throw new CliError(
422
+ messageFrom(response.status, payload) + (response.status === 429 && retryAfter !== void 0 ? ` Retry in ${retryAfter}s.` : ""),
423
+ 1,
424
+ {
425
+ status: response.status,
426
+ ...code ? { code } : {},
427
+ ...issues ? { issues } : {},
428
+ ...retryAfter === void 0 ? {} : { retryAfterSeconds: retryAfter }
429
+ }
430
+ );
174
431
  }
175
432
  return payload;
176
433
  }
177
434
 
435
+ // src/lib/api-path.ts
436
+ function path(strings, ...values) {
437
+ return strings.reduce(
438
+ (accumulator, literal, index) => accumulator + (index === 0 ? "" : encodeURIComponent(values[index - 1] ?? "")) + literal,
439
+ ""
440
+ );
441
+ }
442
+
178
443
  // src/lib/args.ts
179
- var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["json", "follow", "help", "all", "quiet"]);
444
+ var GLOBAL_BOOLEANS = ["json", "help", "quiet"];
445
+ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
446
+ ...GLOBAL_BOOLEANS,
447
+ "follow",
448
+ "all",
449
+ "version"
450
+ ]);
180
451
  var args = {
181
452
  parse(argv) {
182
453
  const parsed = {
@@ -203,6 +474,12 @@ var args = {
203
474
  if (equalsAt >= 0) {
204
475
  name = withoutDashes.slice(0, equalsAt);
205
476
  value = withoutDashes.slice(equalsAt + 1);
477
+ if (BOOLEAN_FLAGS.has(name)) {
478
+ throw new CliError(
479
+ `--${name} is a boolean flag: pass --${name} to enable it, or omit it. "--${name}=${value}" is not a value.`,
480
+ 2
481
+ );
482
+ }
206
483
  } else {
207
484
  name = withoutDashes;
208
485
  const next = argv[index + 1];
@@ -211,7 +488,7 @@ var args = {
211
488
  index += 1;
212
489
  }
213
490
  }
214
- if (value === void 0) {
491
+ if (value === void 0 || value.trim() === "") {
215
492
  if (!BOOLEAN_FLAGS.has(name)) {
216
493
  throw new CliError(`--${name} expects a value.`, 2);
217
494
  }
@@ -223,6 +500,41 @@ var args = {
223
500
  }
224
501
  return parsed;
225
502
  },
503
+ /**
504
+ * Refuse anything this action does not understand. Called once the action is
505
+ * known, because `sessions list` and `sessions logs` accept different flags.
506
+ */
507
+ expect(parsed, spec) {
508
+ const allowed = /* @__PURE__ */ new Set([
509
+ ...GLOBAL_BOOLEANS,
510
+ ...spec.flags ?? [],
511
+ ...spec.booleans ?? []
512
+ ]);
513
+ for (const name of [...Object.keys(parsed.flags), ...parsed.booleans]) {
514
+ if (!allowed.has(name)) {
515
+ throw new CliError(`Unknown flag --${name}.`, 2);
516
+ }
517
+ }
518
+ const max = spec.positional ?? 0;
519
+ if (parsed.positional.length > max) {
520
+ const surplus = parsed.positional.slice(max).map((value) => `"${value}"`).join(", ");
521
+ throw new CliError(
522
+ `Unexpected argument${parsed.positional.length - max > 1 ? "s" : ""} ${surplus}.`,
523
+ 2
524
+ );
525
+ }
526
+ },
527
+ /** The action name, validated against what the group actually implements. */
528
+ action(parsed, group, allowed, fallback) {
529
+ const action = parsed.positional[0] ?? fallback;
530
+ if (!allowed.includes(action)) {
531
+ throw new CliError(
532
+ `Unknown ${group} command "${action}". Expected one of: ${allowed.join(", ")}.`,
533
+ 2
534
+ );
535
+ }
536
+ return action;
537
+ },
226
538
  required(parsed, index, name) {
227
539
  const value = parsed.positional[index];
228
540
  if (!value) {
@@ -230,7 +542,11 @@ var args = {
230
542
  }
231
543
  return value;
232
544
  },
233
- number(parsed, name) {
545
+ /**
546
+ * A whole number inside a range, refused locally: `--page -1` used to come
547
+ * back from the API as a bare "Input validation failed".
548
+ */
549
+ integer(parsed, name, bounds = {}) {
234
550
  const raw = parsed.flags[name];
235
551
  if (raw === void 0) {
236
552
  return void 0;
@@ -238,116 +554,115 @@ var args = {
238
554
  const trimmed = raw.trim();
239
555
  const value = Number(trimmed);
240
556
  if (trimmed === "" || !Number.isFinite(value)) {
241
- throw new CliError(`--${name} expects a number.`, 2);
557
+ throw new CliError(`--${name} expects a number, got "${raw}".`, 2);
558
+ }
559
+ if (!Number.isInteger(value)) {
560
+ throw new CliError(`--${name} expects a whole number, got "${raw}".`, 2);
561
+ }
562
+ if (bounds.min !== void 0 && value < bounds.min) {
563
+ throw new CliError(`--${name} must be at least ${bounds.min}.`, 2);
564
+ }
565
+ if (bounds.max !== void 0 && value > bounds.max) {
566
+ throw new CliError(`--${name} must be at most ${bounds.max}.`, 2);
242
567
  }
243
568
  return value;
569
+ },
570
+ /** One of a fixed set, named in the error so the fix is obvious. */
571
+ choice(parsed, name, allowed) {
572
+ const raw = parsed.flags[name];
573
+ if (raw === void 0) {
574
+ return void 0;
575
+ }
576
+ if (!allowed.includes(raw)) {
577
+ throw new CliError(
578
+ `--${name} expects one of: ${allowed.join(", ")}. Got "${raw}".`,
579
+ 2
580
+ );
581
+ }
582
+ return raw;
244
583
  }
245
584
  };
246
585
 
247
- // src/lib/output.ts
248
- var COLORS = {
249
- reset: "\x1B[0m",
250
- dim: "\x1B[2m",
251
- bold: "\x1B[1m",
252
- red: "\x1B[31m",
253
- green: "\x1B[32m",
254
- yellow: "\x1B[33m",
255
- blue: "\x1B[34m",
256
- magenta: "\x1B[35m"
257
- };
258
- var STATUS_COLORS = {
259
- running: "blue",
260
- provisioning: "blue",
261
- queued: "dim",
262
- created: "dim",
263
- delivering: "blue",
264
- verifying: "blue",
265
- waiting_human: "yellow",
266
- awaiting_approval: "yellow",
267
- completed: "green",
268
- failed: "red",
269
- killed: "red",
270
- abstained: "yellow"
271
- };
272
- function supportsColor() {
273
- return process.stdout.isTTY === true && process.env.NO_COLOR === void 0;
274
- }
275
- var out = {
276
- json(value) {
277
- process.stdout.write(`${JSON.stringify(value, null, 2)}
278
- `);
279
- },
280
- line(text = "") {
281
- process.stdout.write(`${text}
282
- `);
283
- },
284
- error(text) {
285
- process.stderr.write(`${out.paint(`error: ${out.safe(text)}`, "red")}
286
- `);
287
- },
288
- paint(text, color) {
289
- if (!supportsColor()) {
290
- return text;
586
+ // src/lib/version.ts
587
+ import { readFileSync as readFileSync2 } from "fs";
588
+ import { dirname as dirname2, join as join2 } from "path";
589
+ import { fileURLToPath } from "url";
590
+ function readVersion() {
591
+ let directory = dirname2(fileURLToPath(import.meta.url));
592
+ for (let depth = 0; depth < 5; depth += 1) {
593
+ try {
594
+ const manifest = JSON.parse(
595
+ readFileSync2(join2(directory, "package.json"), "utf8")
596
+ );
597
+ const version = manifest.version;
598
+ if (typeof version === "string" && version.length > 0) {
599
+ return version;
600
+ }
601
+ } catch {
291
602
  }
292
- return `${COLORS[color] ?? ""}${text}${COLORS.reset}`;
293
- },
294
- /**
295
- * Neutralise terminal control sequences in text the platform gives us.
296
- *
297
- * Transcript content, PR titles and question bodies carry model output, tool
298
- * output and repository contents — none of it trusted. Printed raw, an ESC
299
- * sequence could repaint or erase what the user already read, or emit an OSC
300
- * 8 hyperlink pointing somewhere else entirely. Newlines and tabs survive;
301
- * every other C0/C1 control does not. (`--json` is unaffected: JSON.stringify
302
- * already escapes control characters.)
303
- */
304
- safe(text) {
305
- return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
306
- },
307
- /** `safe`, flattened to one line — anything going into a table cell. */
308
- cell(text) {
309
- return out.safe(text).replace(/\s+/g, " ").trim();
310
- },
311
- status(status) {
312
- return out.paint(status, STATUS_COLORS[status] ?? "reset");
313
- },
314
- /** Left-aligned two-column block — the shape used by every list command. */
315
- table(rows) {
316
- if (rows.length === 0) {
317
- return;
603
+ const parent = dirname2(directory);
604
+ if (parent === directory) {
605
+ break;
318
606
  }
319
- const widths = rows[0].map(
320
- (_, column) => Math.max(...rows.map((row) => stripAnsi(row[column] ?? "").length))
321
- );
322
- for (const row of rows) {
323
- const line = row.map((cell, column) => {
324
- const padding = widths[column] - stripAnsi(cell).length;
325
- return column === row.length - 1 ? cell : cell + " ".repeat(padding);
326
- }).join(" ");
327
- out.line(line.trimEnd());
607
+ directory = parent;
608
+ }
609
+ return "unknown";
610
+ }
611
+ var VERSION = readVersion();
612
+
613
+ // src/lib/expect-shape.ts
614
+ function read(payload, dotted) {
615
+ return dotted.split(".").reduce((current, key) => {
616
+ if (!current || typeof current !== "object") {
617
+ return void 0;
618
+ }
619
+ return current[key];
620
+ }, payload);
621
+ }
622
+ function expectShape(payload, endpoint, requirements) {
623
+ for (const [field, predicate] of Object.entries(requirements)) {
624
+ if (!predicate(read(payload, field))) {
625
+ throw new CliError(
626
+ `${endpoint} returned a payload this CLI does not understand: "${field}" is missing or has the wrong type. hepha ${VERSION} may be out of step with the platform \u2014 try \`npm install -g @impulselab/hepha\`.`,
627
+ 1,
628
+ { code: CLI_ERROR_CODES.API_SCHEMA_MISMATCH }
629
+ );
328
630
  }
329
631
  }
330
- };
331
- function stripAnsi(text) {
332
- return text.replace(/\u001b\[\d+m/g, "");
632
+ return payload;
333
633
  }
334
634
 
635
+ // src/lib/is.ts
636
+ var is = {
637
+ string: (value) => typeof value === "string",
638
+ number: (value) => typeof value === "number",
639
+ array: (value) => Array.isArray(value),
640
+ object: (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value)
641
+ };
642
+
335
643
  // src/commands/approvals.ts
644
+ var ACTIONS = ["list", "show", "approve", "reject"];
645
+ var REJECT_MODES = ["rework", "terminate"];
336
646
  async function approvalsCommand(parsed) {
337
- const action = parsed.positional[0] ?? "list";
647
+ const action = args.action(parsed, "approvals", ACTIONS, "list");
338
648
  const asJson = parsed.booleans.has("json");
339
649
  if (action === "list") {
340
- const result = await api("/approvals");
650
+ args.expect(parsed, { positional: 1 });
651
+ const result2 = expectShape(
652
+ await api("/approvals"),
653
+ "GET /approvals",
654
+ { items: is.array }
655
+ );
341
656
  if (asJson) {
342
- out.json(result);
657
+ out.json(result2);
343
658
  return;
344
659
  }
345
- if (result.items.length === 0) {
660
+ if (result2.items.length === 0) {
346
661
  out.line("No guardrail waiting on you.");
347
662
  return;
348
663
  }
349
664
  out.table(
350
- result.items.map((item) => [
665
+ result2.items.map((item) => [
351
666
  out.cell(item.id),
352
667
  out.cell(item.guardrailKind),
353
668
  out.cell(item.repoName ?? "\u2014"),
@@ -356,73 +671,77 @@ async function approvalsCommand(parsed) {
356
671
  );
357
672
  return;
358
673
  }
674
+ args.expect(parsed, {
675
+ positional: 2,
676
+ ...action === "reject" ? { flags: ["mode", "feedback"] } : {}
677
+ });
359
678
  const guardrailId = args.required(parsed, 1, "guardrailRequestId");
360
679
  if (action === "show") {
361
- const detail = await api(
362
- `/approvals/${guardrailId}`
680
+ const detail2 = expectShape(
681
+ await api(path`/approvals/${guardrailId}`),
682
+ "GET /approvals/{id}",
683
+ { guardrailKind: is.string, status: is.string, summary: is.string }
363
684
  );
364
685
  if (asJson) {
365
- out.json(detail);
686
+ out.json(detail2);
366
687
  return;
367
688
  }
368
689
  out.line(
369
- `${out.paint(out.cell(detail.guardrailKind), "bold")} ${detail.status}`
690
+ `${out.paint(out.cell(detail2.guardrailKind), "bold")} ${detail2.status}`
370
691
  );
371
- out.line(out.safe(detail.summary));
692
+ out.line(out.safe(detail2.summary));
372
693
  out.line();
373
- out.json(detail.payload);
694
+ out.json(detail2.payload);
374
695
  return;
375
696
  }
376
697
  if (action === "approve") {
377
- const result = await api(
378
- `/approvals/${guardrailId}/approve`,
698
+ const result2 = await api(
699
+ path`/approvals/${guardrailId}/approve`,
379
700
  { method: "POST", body: {} }
380
701
  );
381
702
  if (asJson) {
382
- out.json(result);
703
+ out.json(result2);
383
704
  return;
384
705
  }
385
706
  out.line(`${out.paint("\u2713", "green")} Approved ${out.cell(guardrailId)}`);
386
707
  return;
387
708
  }
388
- if (action === "reject") {
389
- const mode = parsed.flags.mode ?? "rework";
390
- if (mode !== "rework" && mode !== "terminate") {
391
- throw new CliError("--mode expects rework or terminate.", 2);
392
- }
393
- if (mode === "rework" && !parsed.flags.feedback) {
394
- throw new CliError(
395
- "--feedback is required with --mode rework: the agent needs to know what to change.",
396
- 2
397
- );
398
- }
399
- const result = await api(
400
- `/approvals/${guardrailId}/reject`,
401
- {
402
- method: "POST",
403
- body: {
404
- mode,
405
- ...parsed.flags.feedback ? { feedback: parsed.flags.feedback } : {}
406
- }
407
- }
408
- );
409
- if (asJson) {
410
- out.json(result);
411
- return;
412
- }
413
- out.line(
414
- `${out.paint("\u2713", "green")} Rejected ${out.cell(guardrailId)} (${mode})`
709
+ const mode = args.choice(parsed, "mode", REJECT_MODES) ?? "rework";
710
+ if (mode === "rework" && !parsed.flags.feedback) {
711
+ throw new CliError(
712
+ "--feedback is required with --mode rework: the agent needs to know what to change.",
713
+ 2
415
714
  );
715
+ }
716
+ const result = await api(
717
+ path`/approvals/${guardrailId}/reject`,
718
+ {
719
+ method: "POST",
720
+ body: {
721
+ mode,
722
+ ...parsed.flags.feedback ? { feedback: parsed.flags.feedback } : {}
723
+ }
724
+ }
725
+ );
726
+ if (asJson) {
727
+ out.json(result);
416
728
  return;
417
729
  }
418
- throw new CliError(`Unknown approvals command "${action}".`, 2);
730
+ out.line(
731
+ `${out.paint("\u2713", "green")} Rejected ${out.cell(guardrailId)} (${mode})`
732
+ );
419
733
  }
420
734
 
421
735
  // src/commands/catalog.ts
422
736
  async function catalogCommand(topic, parsed) {
423
737
  const asJson = parsed.booleans.has("json");
424
738
  if (topic === "repos") {
425
- const result = await api("/repos");
739
+ args.expect(parsed, {});
740
+ const result = expectShape(
741
+ await api("/repos"),
742
+ "GET /repos",
743
+ { repos: is.array }
744
+ );
426
745
  if (asJson) {
427
746
  out.json(result);
428
747
  return;
@@ -433,6 +752,7 @@ async function catalogCommand(topic, parsed) {
433
752
  return;
434
753
  }
435
754
  if (topic === "branches") {
755
+ args.expect(parsed, { positional: 1 });
436
756
  const repoFullName = args.required(parsed, 0, "owner/repo");
437
757
  const segments = repoFullName.split("/");
438
758
  const [owner, repo] = segments;
@@ -443,15 +763,15 @@ async function catalogCommand(topic, parsed) {
443
763
  );
444
764
  }
445
765
  const result = await api(
446
- `/repos/${owner}/${repo}/branches`
766
+ path`/repos/${owner}/${repo}/branches`
447
767
  );
768
+ if (!result.result) {
769
+ throw new CliError(`No GitHub installation covers ${repoFullName}.`);
770
+ }
448
771
  if (asJson) {
449
772
  out.json(result);
450
773
  return;
451
774
  }
452
- if (!result.result) {
453
- throw new CliError(`No GitHub installation covers ${repoFullName}.`);
454
- }
455
775
  for (const branch of result.result.branches) {
456
776
  out.line(
457
777
  branch === result.result.defaultBranch ? `${out.cell(branch)} ${out.paint("(default)", "dim")}` : out.cell(branch)
@@ -460,7 +780,12 @@ async function catalogCommand(topic, parsed) {
460
780
  return;
461
781
  }
462
782
  if (topic === "models") {
463
- const result = await api("/models");
783
+ args.expect(parsed, {});
784
+ const result = expectShape(
785
+ await api("/models"),
786
+ "GET /models",
787
+ { harnesses: is.array, gatewayModels: is.array }
788
+ );
464
789
  if (asJson) {
465
790
  out.json(result);
466
791
  return;
@@ -477,7 +802,12 @@ async function catalogCommand(topic, parsed) {
477
802
  return;
478
803
  }
479
804
  if (topic === "skills") {
480
- const result = await api("/skills");
805
+ args.expect(parsed, {});
806
+ const result = expectShape(
807
+ await api("/skills"),
808
+ "GET /skills",
809
+ { skills: is.array }
810
+ );
481
811
  if (asJson) {
482
812
  out.json(result);
483
813
  return;
@@ -504,17 +834,24 @@ THE LOOP
504
834
  hepha run "<prompt>" --repo owner/repo --follow
505
835
  Start a session and follow it until it finishes or asks you something.
506
836
  Exit 0 completed \xB7 3 waiting on a human \xB7 1 failed.
837
+ [--title "\u2026"] [--task-type implement|new-project|question|draft]
838
+ [--model <slug>] [--harness claude-code|codex] [--branch <name>]
839
+ [--skill <id>]\u2026
507
840
  hepha questions list [--session <id>]
841
+ hepha questions show <batchId>
508
842
  hepha questions answer <batchId> --option <questionId>=1,2 --text <questionId>="\u2026"
843
+ [--answers '<json array>']
844
+ hepha questions cancel <batchId>
509
845
  hepha approvals list | show <id> | approve <id> | reject <id> --mode rework --feedback "\u2026"
510
846
 
511
847
  SESSIONS
512
848
  hepha sessions list [--status active|waiting|done] [--repo owner/repo]
513
- [--page N] [--limit N] [--all]
849
+ [--search "\u2026"] [--page N] [--limit N] [--all]
514
850
  hepha sessions get <id>
515
- hepha sessions logs <id> [--after <sequence>] [--follow]
851
+ hepha sessions logs <id> [--after <sequence>] [--limit N] [--follow]
516
852
  hepha sessions send <id> "<message>"
517
- hepha sessions pause|resume|kill|archive <id> [--reason "\u2026"]
853
+ hepha sessions pause|resume|archive <id>
854
+ hepha sessions kill <id> [--reason "\u2026"]
518
855
  hepha sessions checks|pr|verify <id>
519
856
 
520
857
  CATALOG
@@ -532,31 +869,47 @@ AUTH
532
869
  hepha keys revoke <keyId>
533
870
 
534
871
  ENVIRONMENT
535
- HEPHA_API_KEY Act as an agent. Takes precedence over a stored login.
536
- HEPHA_TOKEN A device-flow session token, instead of ~/.hepha/config.json.
537
- HEPHA_URL Platform base URL.
872
+ HEPHA_API_KEY Act as an agent. Takes precedence over a stored login, so
873
+ the human-only commands (approvals approve/reject, keys)
874
+ answer 403 while it is set \u2014 unset it to act as yourself.
875
+ HEPHA_TOKEN A device-flow session token, instead of ~/.hepha/config.json.
876
+ HEPHA_URL Platform base URL.
877
+ HEPHA_TIMEOUT_MS Per-request timeout in milliseconds (default 30000).
538
878
 
539
879
  GLOBAL FLAGS
540
880
  --json Machine-readable output on stdout. Every command that
541
881
  returns data honours it; login is interactive and does not.
882
+ Failures print {"ok":false,"error":{\u2026}} and keep their exit code.
883
+ --quiet Suppress the streamed transcript while following a session.
884
+ --version Print the version and exit.
885
+ --help Print this.
886
+
887
+ Unknown flags and surplus arguments are refused: for an agent, a typo must
888
+ fail rather than run with default options.
542
889
 
543
890
  `;
544
891
  function helpCommand() {
545
892
  const baseUrl = config.load().baseUrl.replace(/\/$/, "");
546
- out.line(`${HELP}Full reference: ${baseUrl}/api/v1/docs`);
893
+ out.line(`${HELP}hepha ${VERSION} \xB7 full reference: ${baseUrl}/api/v1/docs`);
547
894
  }
548
895
 
549
896
  // src/commands/keys.ts
897
+ var ACTIONS2 = ["list", "create", "revoke"];
550
898
  async function keysCommand(parsed) {
551
- const action = parsed.positional[0] ?? "list";
899
+ const action = args.action(parsed, "keys", ACTIONS2, "list");
552
900
  const asJson = parsed.booleans.has("json");
553
901
  if (action === "list") {
554
- const result = await api("/keys");
902
+ args.expect(parsed, { positional: 1 });
903
+ const result2 = expectShape(
904
+ await api("/keys"),
905
+ "GET /keys",
906
+ { keys: is.array }
907
+ );
555
908
  if (asJson) {
556
- out.json(result);
909
+ out.json(result2);
557
910
  return;
558
911
  }
559
- if (result.keys.length === 0) {
912
+ if (result2.keys.length === 0) {
560
913
  out.line("No keys. Create one with `hepha keys create <name>`.");
561
914
  return;
562
915
  }
@@ -567,7 +920,7 @@ async function keysCommand(parsed) {
567
920
  out.paint("PREFIX", "dim"),
568
921
  out.paint("SCOPES", "dim")
569
922
  ],
570
- ...result.keys.map((key) => [
923
+ ...result2.keys.map((key) => [
571
924
  out.cell(key.id),
572
925
  out.cell(key.name ?? "\u2014"),
573
926
  out.cell(key.start ?? "\u2014"),
@@ -577,17 +930,28 @@ async function keysCommand(parsed) {
577
930
  return;
578
931
  }
579
932
  if (action === "create") {
933
+ args.expect(parsed, {
934
+ flags: ["scope", "expires-in-days"],
935
+ positional: 2
936
+ });
580
937
  const name = args.required(parsed, 1, "name");
581
938
  const scopes = parsed.repeated.scope;
582
- const expiresInDays = args.number(parsed, "expires-in-days");
583
- const created = await api("/keys", {
584
- method: "POST",
585
- body: {
586
- name,
587
- ...scopes && scopes.length > 0 ? { scopes } : {},
588
- ...expiresInDays !== void 0 ? { expiresInDays } : {}
589
- }
939
+ const expiresInDays = args.integer(parsed, "expires-in-days", {
940
+ min: 1,
941
+ max: 365
590
942
  });
943
+ const created = expectShape(
944
+ await api("/keys", {
945
+ method: "POST",
946
+ body: {
947
+ name,
948
+ ...scopes && scopes.length > 0 ? { scopes } : {},
949
+ ...expiresInDays !== void 0 ? { expiresInDays } : {}
950
+ }
951
+ }),
952
+ "POST /keys",
953
+ { key: is.string, scopes: is.array }
954
+ );
591
955
  if (asJson) {
592
956
  out.json(created);
593
957
  return;
@@ -607,19 +971,16 @@ async function keysCommand(parsed) {
607
971
  out.line(out.paint(` Scopes: ${created.scopes.join(" ")}`, "dim"));
608
972
  return;
609
973
  }
610
- if (action === "revoke") {
611
- const keyId = args.required(parsed, 1, "keyId");
612
- const result = await api(`/keys/${keyId}`, {
613
- method: "DELETE"
614
- });
615
- if (asJson) {
616
- out.json(result);
617
- return;
618
- }
619
- out.line(`${out.paint("\u2713", "green")} Revoked ${out.cell(keyId)}`);
974
+ args.expect(parsed, { positional: 2 });
975
+ const keyId = args.required(parsed, 1, "keyId");
976
+ const result = await api(path`/keys/${keyId}`, {
977
+ method: "DELETE"
978
+ });
979
+ if (asJson) {
980
+ out.json(result);
620
981
  return;
621
982
  }
622
- throw new CliError(`Unknown keys command "${action}".`, 2);
983
+ out.line(`${out.paint("\u2713", "green")} Revoked ${out.cell(keyId)}`);
623
984
  }
624
985
 
625
986
  // src/commands/login.ts
@@ -644,6 +1005,12 @@ var API_SCOPES = [
644
1005
  var DEFAULT_API_SCOPES = API_SCOPES.filter(
645
1006
  (scope) => scope.endsWith(":read")
646
1007
  );
1008
+ var SESSION = {
1009
+ /** Cookie + session row lifetime: 30 days. */
1010
+ EXPIRES_IN: 60 * 60 * 24 * 30,
1011
+ /** Refresh the expiry at most once a day, to avoid a write per request. */
1012
+ UPDATE_AGE: 60 * 60 * 24
1013
+ };
647
1014
  var DEVICE_AUTHORIZATION = {
648
1015
  /** Only these clients may open a device flow. */
649
1016
  CLIENT_IDS: ["hepha-cli"],
@@ -652,9 +1019,52 @@ var DEVICE_AUTHORIZATION = {
652
1019
  /** Minimum delay the CLI must wait between two token polls. */
653
1020
  INTERVAL: "5s",
654
1021
  /** Page a human visits to approve a pending device. */
655
- VERIFICATION_PATH: "/device"
1022
+ VERIFICATION_PATH: "/device",
1023
+ /**
1024
+ * Per-IP ceiling on `/device/token`. The window is deliberately SHORTER than
1025
+ * INTERVAL: Better Auth only resets its counter when two requests are further
1026
+ * apart than the window, so a 5s poll under the default 60s window never reset
1027
+ * it and every login 429'd partway through the code's lifetime.
1028
+ */
1029
+ POLL_RATE_LIMIT: { WINDOW_SECONDS: 4, MAX_REQUESTS: 10 }
1030
+ };
1031
+
1032
+ // src/lib/device-token-error.ts
1033
+ var deviceTokenError = {
1034
+ /** What the server said, when it said anything at all. */
1035
+ message(error) {
1036
+ if (!error || typeof error !== "object") {
1037
+ return void 0;
1038
+ }
1039
+ const message = error.message;
1040
+ return typeof message === "string" && message.length > 0 ? message : void 0;
1041
+ },
1042
+ /** Better Auth answers `X-Retry-After`, the platform the standard header. */
1043
+ retryAfter(error) {
1044
+ if (!error || typeof error !== "object") {
1045
+ return void 0;
1046
+ }
1047
+ const headers = error.headers;
1048
+ if (!(headers instanceof Headers)) {
1049
+ return void 0;
1050
+ }
1051
+ const raw = headers.get("x-retry-after") ?? headers.get("retry-after") ?? void 0;
1052
+ if (!raw) {
1053
+ return void 0;
1054
+ }
1055
+ const seconds = Number(raw.trim());
1056
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : void 0;
1057
+ }
656
1058
  };
657
1059
 
1060
+ // src/commands/login-backoff.ts
1061
+ function nextPollDelay(interval, remainingSeconds) {
1062
+ if (remainingSeconds <= 0) {
1063
+ return null;
1064
+ }
1065
+ return Math.min(interval, remainingSeconds);
1066
+ }
1067
+
658
1068
  // src/lib/open-browser.ts
659
1069
  import { spawn } from "child_process";
660
1070
  function openBrowser(url) {
@@ -674,12 +1084,14 @@ function openBrowser(url) {
674
1084
 
675
1085
  // src/commands/login.ts
676
1086
  var CLIENT_ID = DEVICE_AUTHORIZATION.CLIENT_IDS[0];
1087
+ var MIN_BACKOFF = 10;
677
1088
  function sleep(seconds) {
678
1089
  return new Promise((resolve) => setTimeout(resolve, seconds * 1e3));
679
1090
  }
680
1091
  async function loginCommand(parsed) {
1092
+ args.expect(parsed, { flags: ["url"] });
681
1093
  const requested = parsed.flags.url?.trim();
682
- if (requested && !isHttpUrl(requested)) {
1094
+ if (requested !== void 0 && !isHttpUrl(requested)) {
683
1095
  throw new CliError(`--url is not a valid http(s) URL: "${requested}".`, 2);
684
1096
  }
685
1097
  const baseUrl = (requested ?? config.load().baseUrl).replace(/\/$/, "");
@@ -706,12 +1118,23 @@ async function loginCommand(parsed) {
706
1118
  let interval = device.interval ?? 5;
707
1119
  const deadline = Date.now() + (device.expires_in ?? 900) * 1e3;
708
1120
  while (Date.now() < deadline) {
709
- await sleep(interval);
1121
+ const delay = nextPollDelay(interval, (deadline - Date.now()) / 1e3);
1122
+ if (delay === null) {
1123
+ break;
1124
+ }
1125
+ await sleep(delay);
710
1126
  const { data: token, error } = await authClient.device.token({
711
1127
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
712
1128
  device_code: device.device_code,
713
1129
  client_id: CLIENT_ID
714
1130
  });
1131
+ if (error?.status === 429) {
1132
+ interval = Math.max(
1133
+ deviceTokenError.retryAfter(error) ?? interval * 2,
1134
+ MIN_BACKOFF
1135
+ );
1136
+ continue;
1137
+ }
715
1138
  if (token?.access_token) {
716
1139
  const identity = await api("/me", {
717
1140
  as: { baseUrl, token: token.access_token }
@@ -745,7 +1168,7 @@ async function loginCommand(parsed) {
745
1168
  throw new CliError("Access denied \u2014 the request was rejected.");
746
1169
  default:
747
1170
  throw new CliError(
748
- error.error_description || "Device authorization failed."
1171
+ error.error_description || deviceTokenError.message(error) || `Device authorization failed (HTTP ${error.status}).`
749
1172
  );
750
1173
  }
751
1174
  }
@@ -754,6 +1177,7 @@ async function loginCommand(parsed) {
754
1177
 
755
1178
  // src/commands/logout.ts
756
1179
  function logoutCommand(parsed) {
1180
+ args.expect(parsed, {});
757
1181
  config.clear();
758
1182
  if (parsed.booleans.has("json")) {
759
1183
  out.json({ signedOut: true });
@@ -762,47 +1186,107 @@ function logoutCommand(parsed) {
762
1186
  out.line("Signed out.");
763
1187
  }
764
1188
 
765
- // src/commands/questions.ts
766
- function printBatch(batch) {
767
- out.line(
768
- `${out.paint(out.cell(batch.sessionTitle), "bold")} ${out.paint(`round ${batch.round}`, "dim")}`
769
- );
770
- out.line(
771
- out.paint(
772
- `batch ${out.cell(batch.id)} \xB7 session ${out.cell(batch.sessionId)}`,
773
- "dim"
774
- )
775
- );
776
- for (const question of batch.questions) {
777
- out.line();
778
- if (question.header) {
779
- out.line(out.paint(out.cell(question.header), "dim"));
1189
+ // src/commands/collect-answers.ts
1190
+ var LIMITS = {
1191
+ QUESTION_ID: 40,
1192
+ OPTION_ID: 40,
1193
+ OPTIONS_PER_ANSWER: 50,
1194
+ FREE_TEXT: 1e4,
1195
+ ANSWERS: 50
1196
+ };
1197
+ function validateAnswer(answer, label) {
1198
+ if (answer.questionId.trim().length === 0) {
1199
+ throw new CliError(`${label} needs a non-empty "questionId".`, 2);
1200
+ }
1201
+ if (answer.questionId.length > LIMITS.QUESTION_ID) {
1202
+ throw new CliError(
1203
+ `${label} has a "questionId" longer than ${LIMITS.QUESTION_ID} characters.`,
1204
+ 2
1205
+ );
1206
+ }
1207
+ const options = answer.selectedOptionIds;
1208
+ if (options !== void 0) {
1209
+ if (options.length > LIMITS.OPTIONS_PER_ANSWER) {
1210
+ throw new CliError(
1211
+ `${label} selects more than ${LIMITS.OPTIONS_PER_ANSWER} options.`,
1212
+ 2
1213
+ );
780
1214
  }
781
- out.line(
782
- `${out.paint(out.cell(question.id), "dim")} ${out.safe(question.body)}`
1215
+ for (const id of options) {
1216
+ if (id.trim().length === 0) {
1217
+ throw new CliError(`${label} has an empty option id.`, 2);
1218
+ }
1219
+ if (id.trim().length > LIMITS.OPTION_ID) {
1220
+ throw new CliError(
1221
+ `${label} has an option id longer than ${LIMITS.OPTION_ID} characters.`,
1222
+ 2
1223
+ );
1224
+ }
1225
+ }
1226
+ }
1227
+ if (answer.freeText !== void 0) {
1228
+ const freeText = answer.freeText.trim();
1229
+ if (freeText.length === 0) {
1230
+ throw new CliError(`${label} has empty free text.`, 2);
1231
+ }
1232
+ if (freeText.length > LIMITS.FREE_TEXT) {
1233
+ throw new CliError(
1234
+ `${label} has free text longer than ${LIMITS.FREE_TEXT} characters.`,
1235
+ 2
1236
+ );
1237
+ }
1238
+ }
1239
+ if ((options === void 0 || options.length === 0) && answer.freeText === void 0) {
1240
+ throw new CliError(
1241
+ `${label} answers nothing. Give it at least one option id or some free text.`,
1242
+ 2
783
1243
  );
784
- if (question.image) {
785
- out.line(out.paint(` (image) ${out.cell(question.image)}`, "dim"));
1244
+ }
1245
+ }
1246
+ function parseAnswersFlag(raw) {
1247
+ let decoded;
1248
+ try {
1249
+ decoded = JSON.parse(raw);
1250
+ } catch {
1251
+ throw new CliError("--answers expects a JSON array.", 2);
1252
+ }
1253
+ if (!Array.isArray(decoded) || decoded.length === 0) {
1254
+ throw new CliError(
1255
+ `--answers expects a non-empty JSON array, e.g. '[{"questionId":"q1","selectedOptionIds":["1"]}]'.`,
1256
+ 2
1257
+ );
1258
+ }
1259
+ if (decoded.length > LIMITS.ANSWERS) {
1260
+ throw new CliError(
1261
+ `--answers carries more than ${LIMITS.ANSWERS} entries.`,
1262
+ 2
1263
+ );
1264
+ }
1265
+ for (const [index, entry] of decoded.entries()) {
1266
+ const label = `--answers[${index}]`;
1267
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
1268
+ throw new CliError(`${label} must be an object.`, 2);
786
1269
  }
787
- for (const option of question.options) {
788
- const recommended = option.id === question.recommendedOptionId;
789
- const label = option.label ? out.cell(option.label) : option.image ? out.paint(`(image) ${out.cell(option.image)}`, "dim") : out.paint("(no label)", "dim");
790
- out.line(
791
- ` ${out.cell(option.id)}) ${label}${recommended ? out.paint(" \u2190 recommended", "green") : ""}`
1270
+ const answer = entry;
1271
+ if (typeof answer.questionId !== "string") {
1272
+ throw new CliError(`${label} needs a non-empty "questionId".`, 2);
1273
+ }
1274
+ if (answer.selectedOptionIds !== void 0 && (!Array.isArray(answer.selectedOptionIds) || answer.selectedOptionIds.some((id) => typeof id !== "string"))) {
1275
+ throw new CliError(
1276
+ `${label}.selectedOptionIds must be an array of strings.`,
1277
+ 2
792
1278
  );
793
1279
  }
794
- if (question.allowFreeText) {
795
- out.line(out.paint(" free text accepted", "dim"));
1280
+ if (answer.freeText !== void 0 && typeof answer.freeText !== "string") {
1281
+ throw new CliError(`${label}.freeText must be a string.`, 2);
796
1282
  }
1283
+ validateAnswer(entry, label);
797
1284
  }
1285
+ return decoded;
798
1286
  }
799
1287
  function collectAnswers(parsed) {
800
1288
  if (parsed.flags.answers) {
801
- try {
802
- return JSON.parse(parsed.flags.answers);
803
- } catch {
804
- throw new CliError("--answers expects a JSON array.", 2);
805
- }
1289
+ return parseAnswersFlag(parsed.flags.answers);
806
1290
  }
807
1291
  const byQuestion = /* @__PURE__ */ new Map();
808
1292
  const put = (pair, apply) => {
@@ -832,24 +1316,74 @@ function collectAnswers(parsed) {
832
1316
  2
833
1317
  );
834
1318
  }
1319
+ if (answers.length > LIMITS.ANSWERS) {
1320
+ throw new CliError(
1321
+ `More than ${LIMITS.ANSWERS} answers in one submission.`,
1322
+ 2
1323
+ );
1324
+ }
1325
+ for (const answer of answers) {
1326
+ validateAnswer(answer, `--option/--text ${answer.questionId}`);
1327
+ }
835
1328
  return answers;
836
1329
  }
1330
+
1331
+ // src/commands/questions.ts
1332
+ var ACTIONS3 = ["list", "show", "answer", "cancel"];
1333
+ function printBatch(batch) {
1334
+ out.line(
1335
+ `${out.paint(out.cell(batch.sessionTitle), "bold")} ${out.paint(`round ${batch.round}`, "dim")}`
1336
+ );
1337
+ out.line(
1338
+ out.paint(
1339
+ `batch ${out.cell(batch.id)} \xB7 session ${out.cell(batch.sessionId)}`,
1340
+ "dim"
1341
+ )
1342
+ );
1343
+ for (const question of batch.questions) {
1344
+ out.line();
1345
+ if (question.header) {
1346
+ out.line(out.paint(out.cell(question.header), "dim"));
1347
+ }
1348
+ out.line(
1349
+ `${out.paint(out.cell(question.id), "dim")} ${out.safe(question.body)}`
1350
+ );
1351
+ if (question.image) {
1352
+ out.line(out.paint(` (image) ${out.cell(question.image)}`, "dim"));
1353
+ }
1354
+ for (const option of question.options) {
1355
+ const recommended = option.id === question.recommendedOptionId;
1356
+ const label = option.label ? out.cell(option.label) : option.image ? out.paint(`(image) ${out.cell(option.image)}`, "dim") : out.paint("(no label)", "dim");
1357
+ out.line(
1358
+ ` ${out.cell(option.id)}) ${label}${recommended ? out.paint(" \u2190 recommended", "green") : ""}`
1359
+ );
1360
+ }
1361
+ if (question.allowFreeText) {
1362
+ out.line(out.paint(" free text accepted", "dim"));
1363
+ }
1364
+ }
1365
+ }
837
1366
  async function questionsCommand(parsed) {
838
- const action = parsed.positional[0] ?? "list";
1367
+ const action = args.action(parsed, "questions", ACTIONS3, "list");
839
1368
  const asJson = parsed.booleans.has("json");
840
1369
  if (action === "list") {
841
- const path = parsed.flags.session ? `/sessions/${parsed.flags.session}/questions` : "/questions";
842
- const result = await api(path);
1370
+ args.expect(parsed, { flags: ["session"], positional: 1 });
1371
+ const session = parsed.flags.session;
1372
+ const result2 = expectShape(
1373
+ await api(session ? path`/sessions/${session}/questions` : "/questions"),
1374
+ "GET /questions",
1375
+ { items: is.array }
1376
+ );
843
1377
  if (asJson) {
844
- out.json(result);
1378
+ out.json(result2);
845
1379
  return;
846
1380
  }
847
- if (result.items.length === 0) {
1381
+ if (result2.items.length === 0) {
848
1382
  out.line("Nothing waiting on you.");
849
1383
  return;
850
1384
  }
851
1385
  out.table(
852
- result.items.map((batch) => [
1386
+ result2.items.map((batch) => [
853
1387
  out.cell(batch.id),
854
1388
  out.cell(batch.sessionId),
855
1389
  `${batch.questions.length} question(s)`,
@@ -858,9 +1392,17 @@ async function questionsCommand(parsed) {
858
1392
  );
859
1393
  return;
860
1394
  }
1395
+ args.expect(parsed, {
1396
+ positional: 2,
1397
+ ...action === "answer" ? { flags: ["option", "text", "answers"] } : {}
1398
+ });
861
1399
  const batchId = args.required(parsed, 1, "batchId");
862
1400
  if (action === "show") {
863
- const batch = await api(`/questions/${batchId}`);
1401
+ const batch = expectShape(
1402
+ await api(path`/questions/${batchId}`),
1403
+ "GET /questions/{id}",
1404
+ { id: is.string, questions: is.array, sessionTitle: is.string }
1405
+ );
864
1406
  if (asJson) {
865
1407
  out.json(batch);
866
1408
  return;
@@ -869,34 +1411,34 @@ async function questionsCommand(parsed) {
869
1411
  return;
870
1412
  }
871
1413
  if (action === "answer") {
872
- const result = await api(
873
- `/questions/${batchId}/submit`,
874
- { method: "POST", body: { answers: collectAnswers(parsed) } }
1414
+ const result2 = expectShape(
1415
+ await api(path`/questions/${batchId}/submit`, {
1416
+ method: "POST",
1417
+ body: { answers: collectAnswers(parsed) }
1418
+ }),
1419
+ "POST /questions/{id}/submit",
1420
+ { answeredCount: is.number }
875
1421
  );
876
1422
  if (asJson) {
877
- out.json(result);
1423
+ out.json(result2);
878
1424
  return;
879
1425
  }
880
1426
  out.line(
881
- `${out.paint("\u2713", "green")} Answered ${result.answeredCount} question(s); the session resumes.`
1427
+ `${out.paint("\u2713", "green")} Answered ${result2.answeredCount} question(s); the session resumes.`
882
1428
  );
883
1429
  return;
884
1430
  }
885
- if (action === "cancel") {
886
- const result = await api(
887
- `/questions/${batchId}/cancel`,
888
- { method: "POST", body: {} }
889
- );
890
- if (asJson) {
891
- out.json(result);
892
- return;
893
- }
894
- out.line(
895
- 'Round cancelled. The session stays parked \u2014 send a message to steer it: `hepha sessions send <id> "\u2026"`'
896
- );
1431
+ const result = await api(
1432
+ path`/questions/${batchId}/cancel`,
1433
+ { method: "POST", body: {} }
1434
+ );
1435
+ if (asJson) {
1436
+ out.json(result);
897
1437
  return;
898
1438
  }
899
- throw new CliError(`Unknown questions command "${action}".`, 2);
1439
+ out.line(
1440
+ 'Round cancelled. The session stays parked \u2014 send a message to steer it: `hepha sessions send <id> "\u2026"`'
1441
+ );
900
1442
  }
901
1443
 
902
1444
  // src/lib/format-event.ts
@@ -951,15 +1493,19 @@ function formatEvent(event) {
951
1493
  var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed", "killed", "abstained"]);
952
1494
  var BLOCKED = /* @__PURE__ */ new Set(["waiting_human", "awaiting_approval"]);
953
1495
  var POLL_SECONDS = 3;
1496
+ var PAGE_SIZE = 200;
954
1497
  function sleep2(seconds) {
955
1498
  return new Promise((resolve) => setTimeout(resolve, seconds * 1e3));
956
1499
  }
957
1500
  async function followSession(sessionId, options = {}) {
958
1501
  let cursor = options.after ?? 0;
959
1502
  for (; ; ) {
960
- const page = await api(
961
- `/sessions/${sessionId}/events`,
962
- { query: { after: cursor || void 0, limit: 200 } }
1503
+ const page = expectShape(
1504
+ await api(path`/sessions/${sessionId}/events`, {
1505
+ query: { after: cursor || void 0, limit: PAGE_SIZE }
1506
+ }),
1507
+ "GET /sessions/{id}/events",
1508
+ { items: is.array }
963
1509
  );
964
1510
  for (const event of page.items) {
965
1511
  cursor = Math.max(cursor, event.sequence);
@@ -977,7 +1523,7 @@ async function followSession(sessionId, options = {}) {
977
1523
  if (page.hasMore) {
978
1524
  continue;
979
1525
  }
980
- const session = await api(`/sessions/${sessionId}`);
1526
+ const session = await api(path`/sessions/${sessionId}`);
981
1527
  if (TERMINAL.has(session.status) || BLOCKED.has(session.status)) {
982
1528
  return session;
983
1529
  }
@@ -1044,38 +1590,71 @@ function sessionOutcome(session) {
1044
1590
  case "waiting_human":
1045
1591
  return new CliError(
1046
1592
  `Waiting on an answer \u2014 \`hepha questions list --session ${session.id}\``,
1047
- 3
1593
+ 3,
1594
+ {},
1595
+ session
1048
1596
  );
1049
1597
  case "awaiting_approval":
1050
1598
  return new CliError(
1051
1599
  "Waiting on a guardrail approval \u2014 `hepha approvals list`",
1052
- 3
1600
+ 3,
1601
+ {},
1602
+ session
1053
1603
  );
1054
1604
  default:
1055
1605
  return new CliError(
1056
- `Session ended ${session.status}${session.statusReason ? `: ${session.statusReason}` : "."}`
1606
+ `Session ended ${session.status}${session.statusReason ? `: ${session.statusReason}` : "."}`,
1607
+ 1,
1608
+ {},
1609
+ session
1057
1610
  );
1058
1611
  }
1059
1612
  }
1060
1613
 
1061
1614
  // src/commands/run.ts
1615
+ var TASK_TYPES = [
1616
+ "implement",
1617
+ "new-project",
1618
+ "question",
1619
+ "draft"
1620
+ ];
1621
+ var HARNESSES = [
1622
+ "claude-code",
1623
+ "codex"
1624
+ ];
1062
1625
  async function runCommand(parsed) {
1626
+ args.expect(parsed, {
1627
+ flags: [
1628
+ "title",
1629
+ "task-type",
1630
+ "model",
1631
+ "harness",
1632
+ "repo",
1633
+ "branch",
1634
+ "skill"
1635
+ ],
1636
+ booleans: ["follow"],
1637
+ positional: 1
1638
+ });
1063
1639
  const prompt = args.required(parsed, 0, "prompt");
1064
1640
  const asJson = parsed.booleans.has("json");
1641
+ const taskType = args.choice(parsed, "task-type", TASK_TYPES);
1642
+ const harness = args.choice(parsed, "harness", HARNESSES);
1065
1643
  const body = {
1066
1644
  prompt,
1067
1645
  ...parsed.flags.title ? { title: parsed.flags.title } : {},
1068
- ...parsed.flags["task-type"] ? { taskType: parsed.flags["task-type"] } : {},
1646
+ ...taskType ? { taskType } : {},
1069
1647
  ...parsed.flags.model ? { modelSelection: parsed.flags.model } : {},
1070
- ...parsed.flags.harness ? { harnessPin: parsed.flags.harness } : {},
1648
+ ...harness ? { harnessPin: harness } : {},
1071
1649
  ...parsed.flags.repo ? { repoFullName: parsed.flags.repo } : {},
1072
1650
  ...parsed.flags.branch ? { branch: parsed.flags.branch } : {},
1073
1651
  ...parsed.repeated.skill ? { pinnedSkillIds: parsed.repeated.skill } : {}
1074
1652
  };
1075
- const session = await api("/sessions", {
1076
- method: "POST",
1077
- body
1078
- });
1653
+ const session = expectShape(
1654
+ await api("/sessions", { method: "POST", body }),
1655
+ "POST /sessions",
1656
+ { id: is.string, title: is.string, status: is.string }
1657
+ );
1079
1658
  if (!parsed.booleans.has("follow")) {
1080
1659
  if (asJson) {
1081
1660
  out.json(session);
@@ -1086,41 +1665,78 @@ async function runCommand(parsed) {
1086
1665
  out.line(out.paint(` hepha sessions logs ${session.id} --follow`, "dim"));
1087
1666
  return;
1088
1667
  }
1089
- if (!asJson) {
1668
+ const quiet = asJson || parsed.booleans.has("quiet");
1669
+ if (!quiet) {
1090
1670
  out.line(out.paint(`session ${session.id}`, "dim"));
1091
1671
  out.line();
1092
1672
  }
1093
- const final = await followSession(session.id, { quiet: asJson });
1673
+ const final = await followSession(session.id, { quiet });
1674
+ const outcome = sessionOutcome(final);
1094
1675
  if (asJson) {
1095
- out.json(final);
1676
+ if (!outcome) {
1677
+ out.json(final);
1678
+ }
1096
1679
  } else {
1097
1680
  out.line();
1098
1681
  printSession(final);
1099
1682
  }
1100
- const outcome = sessionOutcome(final);
1101
1683
  if (outcome) {
1102
1684
  throw outcome;
1103
1685
  }
1104
1686
  }
1105
1687
 
1106
1688
  // src/commands/sessions.ts
1689
+ var ACTIONS4 = [
1690
+ "list",
1691
+ "get",
1692
+ "logs",
1693
+ "send",
1694
+ "pause",
1695
+ "resume",
1696
+ "kill",
1697
+ "archive",
1698
+ "checks",
1699
+ "pr",
1700
+ "verify"
1701
+ ];
1107
1702
  var CONTROL_PATHS = {
1108
1703
  pause: "pause",
1109
1704
  resume: "resume",
1110
1705
  kill: "kill",
1111
1706
  archive: "archive"
1112
1707
  };
1708
+ var STATUS_GROUPS = ["active", "waiting", "done"];
1709
+ var SESSION_FIELDS = {
1710
+ id: is.string,
1711
+ title: is.string,
1712
+ status: is.string,
1713
+ taskType: is.string,
1714
+ repos: is.array,
1715
+ deliverables: is.array
1716
+ };
1717
+ function detail(payload, endpoint) {
1718
+ return expectShape(payload, endpoint, SESSION_FIELDS);
1719
+ }
1113
1720
  async function listSessions(parsed) {
1114
- const result = await api("/sessions", {
1115
- query: {
1116
- statusGroup: parsed.flags.status,
1117
- repoFullName: parsed.flags.repo,
1118
- search: parsed.flags.search,
1119
- page: args.number(parsed, "page"),
1120
- limit: args.number(parsed, "limit") ?? 20,
1121
- scope: parsed.booleans.has("all") ? "all" : void 0
1122
- }
1721
+ args.expect(parsed, {
1722
+ flags: ["status", "repo", "search", "page", "limit"],
1723
+ booleans: ["all"],
1724
+ positional: 1
1123
1725
  });
1726
+ const result = expectShape(
1727
+ await api("/sessions", {
1728
+ query: {
1729
+ statusGroup: args.choice(parsed, "status", STATUS_GROUPS),
1730
+ repoFullName: parsed.flags.repo,
1731
+ search: parsed.flags.search,
1732
+ page: args.integer(parsed, "page", { min: 1 }),
1733
+ limit: args.integer(parsed, "limit", { min: 1, max: 100 }),
1734
+ scope: parsed.booleans.has("all") ? "all" : void 0
1735
+ }
1736
+ }),
1737
+ "GET /sessions",
1738
+ { data: is.array, "pagination.page": is.number }
1739
+ );
1124
1740
  if (parsed.booleans.has("json")) {
1125
1741
  out.json(result);
1126
1742
  return;
@@ -1148,34 +1764,42 @@ async function listSessions(parsed) {
1148
1764
  );
1149
1765
  }
1150
1766
  async function showLogs(parsed) {
1767
+ args.expect(parsed, {
1768
+ flags: ["after", "limit"],
1769
+ booleans: ["follow"],
1770
+ positional: 2
1771
+ });
1151
1772
  const sessionId = args.required(parsed, 1, "sessionId");
1152
1773
  const asJson = parsed.booleans.has("json");
1153
- const after = args.number(parsed, "after");
1774
+ const after = args.integer(parsed, "after", { min: 0 });
1154
1775
  if (parsed.booleans.has("follow")) {
1155
1776
  const session = await followSession(sessionId, {
1156
- quiet: asJson,
1777
+ quiet: asJson || parsed.booleans.has("quiet"),
1157
1778
  ...after !== void 0 ? { after } : {}
1158
1779
  });
1780
+ const outcome = sessionOutcome(session);
1159
1781
  if (asJson) {
1160
- out.json(session);
1782
+ if (!outcome) {
1783
+ out.json(session);
1784
+ }
1161
1785
  } else {
1162
1786
  out.line();
1163
1787
  printSession(session);
1164
1788
  }
1165
- const outcome = sessionOutcome(session);
1166
1789
  if (outcome) {
1167
1790
  throw outcome;
1168
1791
  }
1169
1792
  return;
1170
1793
  }
1171
- const page = await api(
1172
- `/sessions/${sessionId}/events`,
1173
- {
1794
+ const page = expectShape(
1795
+ await api(path`/sessions/${sessionId}/events`, {
1174
1796
  query: {
1175
1797
  after,
1176
- limit: args.number(parsed, "limit") ?? 100
1798
+ limit: args.integer(parsed, "limit", { min: 1, max: 200 })
1177
1799
  }
1178
- }
1800
+ }),
1801
+ "GET /sessions/{id}/events",
1802
+ { items: is.array }
1179
1803
  );
1180
1804
  if (asJson) {
1181
1805
  out.json(page);
@@ -1198,7 +1822,7 @@ async function showLogs(parsed) {
1198
1822
  }
1199
1823
  }
1200
1824
  async function sessionsCommand(parsed) {
1201
- const action = parsed.positional[0] ?? "list";
1825
+ const action = args.action(parsed, "sessions", ACTIONS4, "list");
1202
1826
  const asJson = parsed.booleans.has("json");
1203
1827
  if (action === "list") {
1204
1828
  return await listSessions(parsed);
@@ -1206,21 +1830,17 @@ async function sessionsCommand(parsed) {
1206
1830
  if (action === "logs") {
1207
1831
  return await showLogs(parsed);
1208
1832
  }
1209
- const sessionId = args.required(parsed, 1, "sessionId");
1210
- if (action === "get") {
1211
- const session = await api(`/sessions/${sessionId}`);
1212
- if (asJson) {
1213
- out.json(session);
1214
- return;
1215
- }
1216
- printSession(session);
1217
- return;
1218
- }
1219
1833
  if (action === "send") {
1834
+ args.expect(parsed, { positional: 3 });
1835
+ const sessionId2 = args.required(parsed, 1, "sessionId");
1220
1836
  const message = args.required(parsed, 2, "message");
1221
- const result = await api(
1222
- `/sessions/${sessionId}/messages`,
1223
- { method: "POST", body: { message } }
1837
+ const result = expectShape(
1838
+ await api(path`/sessions/${sessionId2}/messages`, {
1839
+ method: "POST",
1840
+ body: { message }
1841
+ }),
1842
+ "POST /sessions/{id}/messages",
1843
+ { delivery: is.string }
1224
1844
  );
1225
1845
  if (asJson) {
1226
1846
  out.json(result);
@@ -1231,13 +1851,31 @@ async function sessionsCommand(parsed) {
1231
1851
  );
1232
1852
  return;
1233
1853
  }
1854
+ args.expect(parsed, {
1855
+ positional: 2,
1856
+ ...action === "kill" ? { flags: ["reason"] } : {}
1857
+ });
1858
+ const sessionId = args.required(parsed, 1, "sessionId");
1859
+ if (action === "get") {
1860
+ const session = detail(
1861
+ await api(path`/sessions/${sessionId}`),
1862
+ "GET /sessions/{id}"
1863
+ );
1864
+ if (asJson) {
1865
+ out.json(session);
1866
+ return;
1867
+ }
1868
+ printSession(session);
1869
+ return;
1870
+ }
1234
1871
  if (Object.hasOwn(CONTROL_PATHS, action)) {
1235
- const session = await api(
1236
- `/sessions/${sessionId}/${CONTROL_PATHS[action]}`,
1237
- {
1872
+ const controlPath = CONTROL_PATHS[action];
1873
+ const session = detail(
1874
+ await api(path`/sessions/${sessionId}/${controlPath}`, {
1238
1875
  method: "POST",
1239
1876
  body: action === "kill" && parsed.flags.reason ? { reason: parsed.flags.reason } : {}
1240
- }
1877
+ }),
1878
+ `POST /sessions/{id}/${controlPath}`
1241
1879
  );
1242
1880
  if (asJson) {
1243
1881
  out.json(session);
@@ -1247,35 +1885,42 @@ async function sessionsCommand(parsed) {
1247
1885
  return;
1248
1886
  }
1249
1887
  if (action === "checks") {
1250
- const result = await api(
1251
- `/sessions/${sessionId}/checks`,
1252
- { method: "POST", body: {} }
1253
- );
1254
- out.json(result);
1255
- return;
1256
- }
1257
- if (action === "pr") {
1258
1888
  out.json(
1259
1889
  await api(
1260
- `/sessions/${sessionId}/pr-checks`
1890
+ path`/sessions/${sessionId}/checks`,
1891
+ { method: "POST", body: {} }
1261
1892
  )
1262
1893
  );
1263
1894
  return;
1264
1895
  }
1265
- if (action === "verify") {
1896
+ if (action === "pr") {
1266
1897
  out.json(
1267
1898
  await api(
1268
- `/sessions/${sessionId}/verification`
1899
+ path`/sessions/${sessionId}/pr-checks`
1269
1900
  )
1270
1901
  );
1271
1902
  return;
1272
1903
  }
1273
- throw new CliError(`Unknown sessions command "${action}".`, 2);
1904
+ out.json(
1905
+ await api(
1906
+ path`/sessions/${sessionId}/verification`
1907
+ )
1908
+ );
1274
1909
  }
1275
1910
 
1276
1911
  // src/commands/whoami.ts
1277
1912
  async function whoamiCommand(parsed) {
1278
- const identity = await api("/me");
1913
+ args.expect(parsed, {});
1914
+ const identity = expectShape(
1915
+ await api("/me"),
1916
+ "GET /me",
1917
+ {
1918
+ "user.name": is.string,
1919
+ "user.email": is.string,
1920
+ "auth.method": is.string,
1921
+ "auth.scopes": is.array
1922
+ }
1923
+ );
1279
1924
  if (parsed.booleans.has("json")) {
1280
1925
  out.json(identity);
1281
1926
  return;
@@ -1297,6 +1942,19 @@ async function whoamiCommand(parsed) {
1297
1942
  ]);
1298
1943
  }
1299
1944
 
1945
+ // src/lib/wants-json.ts
1946
+ function wantsJson(argv) {
1947
+ for (const token of argv) {
1948
+ if (token === "--") {
1949
+ return false;
1950
+ }
1951
+ if (token === "--json") {
1952
+ return true;
1953
+ }
1954
+ }
1955
+ return false;
1956
+ }
1957
+
1300
1958
  // src/index.ts
1301
1959
  var CATALOG_TOPICS = /* @__PURE__ */ new Set(["repos", "branches", "models", "skills"]);
1302
1960
  async function dispatch(command, argv) {
@@ -1331,19 +1989,27 @@ async function dispatch(command, argv) {
1331
1989
  }
1332
1990
  }
1333
1991
  async function main() {
1334
- const [command, ...argv] = process.argv.slice(2);
1992
+ const argv = process.argv.slice(2);
1993
+ out.setJson(wantsJson(argv));
1994
+ const [command, ...rest] = argv;
1335
1995
  if (!command || command === "help" || command === "--help") {
1336
1996
  helpCommand();
1337
1997
  return;
1338
1998
  }
1339
- await dispatch(command, argv);
1999
+ if (command === "--version" || command === "-v" || command === "version") {
2000
+ args.expect(args.parse(rest), {});
2001
+ if (out.isJson()) {
2002
+ out.json({ version: VERSION });
2003
+ } else {
2004
+ out.line(VERSION);
2005
+ }
2006
+ return;
2007
+ }
2008
+ await dispatch(command, rest);
1340
2009
  }
1341
2010
  main().catch((error) => {
1342
- if (error instanceof CliError) {
1343
- out.error(error.message);
1344
- process.exit(error.exitCode);
1345
- }
1346
- out.error(error instanceof Error ? error.message : String(error));
1347
- process.exit(1);
2011
+ const failure = error instanceof CliError ? error : new CliError(error instanceof Error ? error.message : String(error));
2012
+ out.failure(failure);
2013
+ process.exit(failure.exitCode);
1348
2014
  });
1349
2015
  //# sourceMappingURL=index.js.map