@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 +980 -314
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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${
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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/
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
647
|
+
const action = args.action(parsed, "approvals", ACTIONS, "list");
|
|
338
648
|
const asJson = parsed.booleans.has("json");
|
|
339
649
|
if (action === "list") {
|
|
340
|
-
|
|
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(
|
|
657
|
+
out.json(result2);
|
|
343
658
|
return;
|
|
344
659
|
}
|
|
345
|
-
if (
|
|
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
|
-
|
|
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
|
|
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(
|
|
686
|
+
out.json(detail2);
|
|
366
687
|
return;
|
|
367
688
|
}
|
|
368
689
|
out.line(
|
|
369
|
-
`${out.paint(out.cell(
|
|
690
|
+
`${out.paint(out.cell(detail2.guardrailKind), "bold")} ${detail2.status}`
|
|
370
691
|
);
|
|
371
|
-
out.line(out.safe(
|
|
692
|
+
out.line(out.safe(detail2.summary));
|
|
372
693
|
out.line();
|
|
373
|
-
out.json(
|
|
694
|
+
out.json(detail2.payload);
|
|
374
695
|
return;
|
|
375
696
|
}
|
|
376
697
|
if (action === "approve") {
|
|
377
|
-
const
|
|
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(
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
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
|
|
536
|
-
|
|
537
|
-
|
|
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}
|
|
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
|
|
899
|
+
const action = args.action(parsed, "keys", ACTIONS2, "list");
|
|
552
900
|
const asJson = parsed.booleans.has("json");
|
|
553
901
|
if (action === "list") {
|
|
554
|
-
|
|
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(
|
|
909
|
+
out.json(result2);
|
|
557
910
|
return;
|
|
558
911
|
}
|
|
559
|
-
if (
|
|
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
|
-
...
|
|
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.
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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/
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
782
|
-
|
|
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
|
-
|
|
785
|
-
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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 (
|
|
795
|
-
|
|
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
|
-
|
|
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
|
|
1367
|
+
const action = args.action(parsed, "questions", ACTIONS3, "list");
|
|
839
1368
|
const asJson = parsed.booleans.has("json");
|
|
840
1369
|
if (action === "list") {
|
|
841
|
-
|
|
842
|
-
const
|
|
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(
|
|
1378
|
+
out.json(result2);
|
|
845
1379
|
return;
|
|
846
1380
|
}
|
|
847
|
-
if (
|
|
1381
|
+
if (result2.items.length === 0) {
|
|
848
1382
|
out.line("Nothing waiting on you.");
|
|
849
1383
|
return;
|
|
850
1384
|
}
|
|
851
1385
|
out.table(
|
|
852
|
-
|
|
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 =
|
|
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
|
|
873
|
-
`/questions/${batchId}/submit`,
|
|
874
|
-
|
|
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(
|
|
1423
|
+
out.json(result2);
|
|
878
1424
|
return;
|
|
879
1425
|
}
|
|
880
1426
|
out.line(
|
|
881
|
-
`${out.paint("\u2713", "green")} Answered ${
|
|
1427
|
+
`${out.paint("\u2713", "green")} Answered ${result2.answeredCount} question(s); the session resumes.`
|
|
882
1428
|
);
|
|
883
1429
|
return;
|
|
884
1430
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
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 =
|
|
961
|
-
`/sessions/${sessionId}/events`,
|
|
962
|
-
|
|
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
|
-
...
|
|
1646
|
+
...taskType ? { taskType } : {},
|
|
1069
1647
|
...parsed.flags.model ? { modelSelection: parsed.flags.model } : {},
|
|
1070
|
-
...
|
|
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 =
|
|
1076
|
-
method: "POST",
|
|
1077
|
-
|
|
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
|
-
|
|
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
|
|
1673
|
+
const final = await followSession(session.id, { quiet });
|
|
1674
|
+
const outcome = sessionOutcome(final);
|
|
1094
1675
|
if (asJson) {
|
|
1095
|
-
|
|
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
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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 =
|
|
1222
|
-
`/sessions/${
|
|
1223
|
-
|
|
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
|
|
1236
|
-
|
|
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}/
|
|
1890
|
+
path`/sessions/${sessionId}/checks`,
|
|
1891
|
+
{ method: "POST", body: {} }
|
|
1261
1892
|
)
|
|
1262
1893
|
);
|
|
1263
1894
|
return;
|
|
1264
1895
|
}
|
|
1265
|
-
if (action === "
|
|
1896
|
+
if (action === "pr") {
|
|
1266
1897
|
out.json(
|
|
1267
1898
|
await api(
|
|
1268
|
-
`/sessions/${sessionId}/
|
|
1899
|
+
path`/sessions/${sessionId}/pr-checks`
|
|
1269
1900
|
)
|
|
1270
1901
|
);
|
|
1271
1902
|
return;
|
|
1272
1903
|
}
|
|
1273
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|