@impulselab/hepha 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/dist/index.js +1349 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1349 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/lib/config.ts
|
|
4
|
+
import {
|
|
5
|
+
chmodSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync
|
|
10
|
+
} from "fs";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { dirname, join } from "path";
|
|
13
|
+
|
|
14
|
+
// src/lib/errors.ts
|
|
15
|
+
var CliError = class extends Error {
|
|
16
|
+
exitCode;
|
|
17
|
+
constructor(message, exitCode = 1) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "CliError";
|
|
20
|
+
this.exitCode = exitCode;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// src/lib/is-http-url.ts
|
|
25
|
+
function isHttpUrl(value) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(value);
|
|
28
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/lib/config.ts
|
|
35
|
+
var CONFIG_PATH = join(homedir(), ".hepha", "config.json");
|
|
36
|
+
var DEFAULT_BASE_URL = "https://hephaistos.impulselab.ai";
|
|
37
|
+
function readFileAsObject() {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
40
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
return parsed;
|
|
44
|
+
} catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function readString(value) {
|
|
49
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
50
|
+
}
|
|
51
|
+
function readUser(value) {
|
|
52
|
+
if (!value || typeof value !== "object") {
|
|
53
|
+
return void 0;
|
|
54
|
+
}
|
|
55
|
+
const candidate = value;
|
|
56
|
+
const user = {
|
|
57
|
+
id: readString(candidate.id),
|
|
58
|
+
name: readString(candidate.name),
|
|
59
|
+
email: readString(candidate.email)
|
|
60
|
+
};
|
|
61
|
+
return user.id && user.name && user.email ? { id: user.id, name: user.name, email: user.email } : void 0;
|
|
62
|
+
}
|
|
63
|
+
function readHttpUrl(value) {
|
|
64
|
+
const raw = readString(value);
|
|
65
|
+
return raw && isHttpUrl(raw) ? raw : void 0;
|
|
66
|
+
}
|
|
67
|
+
function isSameOrigin(a, b) {
|
|
68
|
+
try {
|
|
69
|
+
return new URL(a).origin === new URL(b).origin;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
var config = {
|
|
75
|
+
path: CONFIG_PATH,
|
|
76
|
+
load() {
|
|
77
|
+
const stored = readFileAsObject();
|
|
78
|
+
const storedBaseUrl = readHttpUrl(stored.baseUrl);
|
|
79
|
+
const override = readString(process.env.HEPHA_URL);
|
|
80
|
+
if (override && !readHttpUrl(override)) {
|
|
81
|
+
throw new CliError(
|
|
82
|
+
`HEPHA_URL is not a valid http(s) URL: "${override}".`,
|
|
83
|
+
2
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const baseUrl = readHttpUrl(override) ?? storedBaseUrl ?? DEFAULT_BASE_URL;
|
|
87
|
+
const token = storedBaseUrl && isSameOrigin(storedBaseUrl, baseUrl) ? readString(stored.token) : void 0;
|
|
88
|
+
const user = token ? readUser(stored.user) : void 0;
|
|
89
|
+
return {
|
|
90
|
+
baseUrl,
|
|
91
|
+
...token ? { token } : {},
|
|
92
|
+
...user ? { user } : {}
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
save(next) {
|
|
96
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true, mode: 448 });
|
|
97
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}
|
|
98
|
+
`, {
|
|
99
|
+
mode: 384
|
|
100
|
+
});
|
|
101
|
+
chmodSync(CONFIG_PATH, 384);
|
|
102
|
+
},
|
|
103
|
+
clear() {
|
|
104
|
+
rmSync(CONFIG_PATH, { force: true });
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// src/lib/api.ts
|
|
109
|
+
function authHeaders(as) {
|
|
110
|
+
if (as) {
|
|
111
|
+
return { authorization: `Bearer ${as.token}` };
|
|
112
|
+
}
|
|
113
|
+
const apiKey = process.env.HEPHA_API_KEY;
|
|
114
|
+
if (apiKey) {
|
|
115
|
+
return { "x-api-key": apiKey };
|
|
116
|
+
}
|
|
117
|
+
const token = process.env.HEPHA_TOKEN ?? config.load().token;
|
|
118
|
+
if (token) {
|
|
119
|
+
return { authorization: `Bearer ${token}` };
|
|
120
|
+
}
|
|
121
|
+
throw new CliError(
|
|
122
|
+
"Not signed in. Run `hepha login`, or set HEPHA_API_KEY to act as an agent."
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
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
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (status === 401) {
|
|
133
|
+
return "Unauthorized \u2014 your credential is missing, expired or revoked.";
|
|
134
|
+
}
|
|
135
|
+
return `Request failed with status ${status}.`;
|
|
136
|
+
}
|
|
137
|
+
async function api(path, options = {}) {
|
|
138
|
+
const baseUrl = options.as?.baseUrl ?? config.load().baseUrl;
|
|
139
|
+
const url = new URL(`${baseUrl.replace(/\/$/, "")}/api/v1${path}`);
|
|
140
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
141
|
+
if (value !== void 0) {
|
|
142
|
+
url.searchParams.set(key, String(value));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const headers = {
|
|
146
|
+
...authHeaders(options.as),
|
|
147
|
+
...options.body === void 0 ? {} : { "content-type": "application/json" }
|
|
148
|
+
};
|
|
149
|
+
let response;
|
|
150
|
+
try {
|
|
151
|
+
response = await fetch(url, {
|
|
152
|
+
method: options.method ?? "GET",
|
|
153
|
+
headers,
|
|
154
|
+
...options.body === void 0 ? {} : { body: JSON.stringify(options.body) }
|
|
155
|
+
});
|
|
156
|
+
} catch (cause) {
|
|
157
|
+
throw new CliError(
|
|
158
|
+
`Cannot reach ${baseUrl}. Set HEPHA_URL if the platform lives elsewhere. (${String(cause)})`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const text = await response.text();
|
|
162
|
+
let payload = null;
|
|
163
|
+
if (text) {
|
|
164
|
+
try {
|
|
165
|
+
payload = JSON.parse(text);
|
|
166
|
+
} catch {
|
|
167
|
+
throw new CliError(
|
|
168
|
+
`${url.origin} answered ${response.status} with a non-JSON body. Is HEPHA_URL pointing at a Hephaistos deployment?`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
throw new CliError(messageFrom(response.status, payload));
|
|
174
|
+
}
|
|
175
|
+
return payload;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// src/lib/args.ts
|
|
179
|
+
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["json", "follow", "help", "all", "quiet"]);
|
|
180
|
+
var args = {
|
|
181
|
+
parse(argv) {
|
|
182
|
+
const parsed = {
|
|
183
|
+
positional: [],
|
|
184
|
+
flags: {},
|
|
185
|
+
repeated: {},
|
|
186
|
+
booleans: /* @__PURE__ */ new Set()
|
|
187
|
+
};
|
|
188
|
+
let flagsEnded = false;
|
|
189
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
190
|
+
const token = argv[index];
|
|
191
|
+
if (!flagsEnded && token === "--") {
|
|
192
|
+
flagsEnded = true;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (flagsEnded || !token.startsWith("--")) {
|
|
196
|
+
parsed.positional.push(token);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const withoutDashes = token.slice(2);
|
|
200
|
+
const equalsAt = withoutDashes.indexOf("=");
|
|
201
|
+
let name;
|
|
202
|
+
let value;
|
|
203
|
+
if (equalsAt >= 0) {
|
|
204
|
+
name = withoutDashes.slice(0, equalsAt);
|
|
205
|
+
value = withoutDashes.slice(equalsAt + 1);
|
|
206
|
+
} else {
|
|
207
|
+
name = withoutDashes;
|
|
208
|
+
const next = argv[index + 1];
|
|
209
|
+
if (!BOOLEAN_FLAGS.has(name) && next !== void 0 && !next.startsWith("--")) {
|
|
210
|
+
value = next;
|
|
211
|
+
index += 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (value === void 0) {
|
|
215
|
+
if (!BOOLEAN_FLAGS.has(name)) {
|
|
216
|
+
throw new CliError(`--${name} expects a value.`, 2);
|
|
217
|
+
}
|
|
218
|
+
parsed.booleans.add(name);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
parsed.flags[name] = value;
|
|
222
|
+
(parsed.repeated[name] ??= []).push(value);
|
|
223
|
+
}
|
|
224
|
+
return parsed;
|
|
225
|
+
},
|
|
226
|
+
required(parsed, index, name) {
|
|
227
|
+
const value = parsed.positional[index];
|
|
228
|
+
if (!value) {
|
|
229
|
+
throw new CliError(`Missing <${name}>.`, 2);
|
|
230
|
+
}
|
|
231
|
+
return value;
|
|
232
|
+
},
|
|
233
|
+
number(parsed, name) {
|
|
234
|
+
const raw = parsed.flags[name];
|
|
235
|
+
if (raw === void 0) {
|
|
236
|
+
return void 0;
|
|
237
|
+
}
|
|
238
|
+
const trimmed = raw.trim();
|
|
239
|
+
const value = Number(trimmed);
|
|
240
|
+
if (trimmed === "" || !Number.isFinite(value)) {
|
|
241
|
+
throw new CliError(`--${name} expects a number.`, 2);
|
|
242
|
+
}
|
|
243
|
+
return value;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
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;
|
|
291
|
+
}
|
|
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;
|
|
318
|
+
}
|
|
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());
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
function stripAnsi(text) {
|
|
332
|
+
return text.replace(/\u001b\[\d+m/g, "");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// src/commands/approvals.ts
|
|
336
|
+
async function approvalsCommand(parsed) {
|
|
337
|
+
const action = parsed.positional[0] ?? "list";
|
|
338
|
+
const asJson = parsed.booleans.has("json");
|
|
339
|
+
if (action === "list") {
|
|
340
|
+
const result = await api("/approvals");
|
|
341
|
+
if (asJson) {
|
|
342
|
+
out.json(result);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (result.items.length === 0) {
|
|
346
|
+
out.line("No guardrail waiting on you.");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
out.table(
|
|
350
|
+
result.items.map((item) => [
|
|
351
|
+
out.cell(item.id),
|
|
352
|
+
out.cell(item.guardrailKind),
|
|
353
|
+
out.cell(item.repoName ?? "\u2014"),
|
|
354
|
+
out.cell(item.summary)
|
|
355
|
+
])
|
|
356
|
+
);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const guardrailId = args.required(parsed, 1, "guardrailRequestId");
|
|
360
|
+
if (action === "show") {
|
|
361
|
+
const detail = await api(
|
|
362
|
+
`/approvals/${guardrailId}`
|
|
363
|
+
);
|
|
364
|
+
if (asJson) {
|
|
365
|
+
out.json(detail);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
out.line(
|
|
369
|
+
`${out.paint(out.cell(detail.guardrailKind), "bold")} ${detail.status}`
|
|
370
|
+
);
|
|
371
|
+
out.line(out.safe(detail.summary));
|
|
372
|
+
out.line();
|
|
373
|
+
out.json(detail.payload);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (action === "approve") {
|
|
377
|
+
const result = await api(
|
|
378
|
+
`/approvals/${guardrailId}/approve`,
|
|
379
|
+
{ method: "POST", body: {} }
|
|
380
|
+
);
|
|
381
|
+
if (asJson) {
|
|
382
|
+
out.json(result);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
out.line(`${out.paint("\u2713", "green")} Approved ${out.cell(guardrailId)}`);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
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})`
|
|
415
|
+
);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
throw new CliError(`Unknown approvals command "${action}".`, 2);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/commands/catalog.ts
|
|
422
|
+
async function catalogCommand(topic, parsed) {
|
|
423
|
+
const asJson = parsed.booleans.has("json");
|
|
424
|
+
if (topic === "repos") {
|
|
425
|
+
const result = await api("/repos");
|
|
426
|
+
if (asJson) {
|
|
427
|
+
out.json(result);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
for (const repo of result.repos) {
|
|
431
|
+
out.line(out.cell(repo));
|
|
432
|
+
}
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (topic === "branches") {
|
|
436
|
+
const repoFullName = args.required(parsed, 0, "owner/repo");
|
|
437
|
+
const segments = repoFullName.split("/");
|
|
438
|
+
const [owner, repo] = segments;
|
|
439
|
+
if (segments.length !== 2 || !owner || !repo) {
|
|
440
|
+
throw new CliError(
|
|
441
|
+
`Expected a repository as "owner/repo", got "${repoFullName}".`,
|
|
442
|
+
2
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
const result = await api(
|
|
446
|
+
`/repos/${owner}/${repo}/branches`
|
|
447
|
+
);
|
|
448
|
+
if (asJson) {
|
|
449
|
+
out.json(result);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (!result.result) {
|
|
453
|
+
throw new CliError(`No GitHub installation covers ${repoFullName}.`);
|
|
454
|
+
}
|
|
455
|
+
for (const branch of result.result.branches) {
|
|
456
|
+
out.line(
|
|
457
|
+
branch === result.result.defaultBranch ? `${out.cell(branch)} ${out.paint("(default)", "dim")}` : out.cell(branch)
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (topic === "models") {
|
|
463
|
+
const result = await api("/models");
|
|
464
|
+
if (asJson) {
|
|
465
|
+
out.json(result);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
out.line(out.paint("harnesses", "dim"));
|
|
469
|
+
for (const harness of result.harnesses) {
|
|
470
|
+
out.line(` ${out.cell(harness)}`);
|
|
471
|
+
}
|
|
472
|
+
out.line();
|
|
473
|
+
out.line(out.paint("gateway models", "dim"));
|
|
474
|
+
for (const model of result.gatewayModels) {
|
|
475
|
+
out.line(` ${out.cell(model.id)}`);
|
|
476
|
+
}
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (topic === "skills") {
|
|
480
|
+
const result = await api("/skills");
|
|
481
|
+
if (asJson) {
|
|
482
|
+
out.json(result);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
out.table(
|
|
486
|
+
result.skills.map((skill) => [
|
|
487
|
+
out.cell(skill.id),
|
|
488
|
+
out.cell(skill.name),
|
|
489
|
+
out.cell(skill.description ?? "")
|
|
490
|
+
])
|
|
491
|
+
);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
throw new CliError(`Unknown catalog topic "${topic}".`, 2);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// src/commands/help.ts
|
|
498
|
+
var HELP = `hepha \u2014 orchestrate Hephaistos sessions from a terminal or an agent.
|
|
499
|
+
|
|
500
|
+
USAGE
|
|
501
|
+
hepha <command> [args] [--flags]
|
|
502
|
+
|
|
503
|
+
THE LOOP
|
|
504
|
+
hepha run "<prompt>" --repo owner/repo --follow
|
|
505
|
+
Start a session and follow it until it finishes or asks you something.
|
|
506
|
+
Exit 0 completed \xB7 3 waiting on a human \xB7 1 failed.
|
|
507
|
+
hepha questions list [--session <id>]
|
|
508
|
+
hepha questions answer <batchId> --option <questionId>=1,2 --text <questionId>="\u2026"
|
|
509
|
+
hepha approvals list | show <id> | approve <id> | reject <id> --mode rework --feedback "\u2026"
|
|
510
|
+
|
|
511
|
+
SESSIONS
|
|
512
|
+
hepha sessions list [--status active|waiting|done] [--repo owner/repo]
|
|
513
|
+
[--page N] [--limit N] [--all]
|
|
514
|
+
hepha sessions get <id>
|
|
515
|
+
hepha sessions logs <id> [--after <sequence>] [--follow]
|
|
516
|
+
hepha sessions send <id> "<message>"
|
|
517
|
+
hepha sessions pause|resume|kill|archive <id> [--reason "\u2026"]
|
|
518
|
+
hepha sessions checks|pr|verify <id>
|
|
519
|
+
|
|
520
|
+
CATALOG
|
|
521
|
+
hepha repos
|
|
522
|
+
hepha branches <owner/repo>
|
|
523
|
+
hepha models
|
|
524
|
+
hepha skills
|
|
525
|
+
|
|
526
|
+
AUTH
|
|
527
|
+
hepha login [--url https://\u2026] Device authorization: approve it in a browser.
|
|
528
|
+
hepha logout
|
|
529
|
+
hepha whoami
|
|
530
|
+
hepha keys list
|
|
531
|
+
hepha keys create <name> [--scope sessions:read]\u2026 [--expires-in-days 90]
|
|
532
|
+
hepha keys revoke <keyId>
|
|
533
|
+
|
|
534
|
+
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.
|
|
538
|
+
|
|
539
|
+
GLOBAL FLAGS
|
|
540
|
+
--json Machine-readable output on stdout. Every command that
|
|
541
|
+
returns data honours it; login is interactive and does not.
|
|
542
|
+
|
|
543
|
+
`;
|
|
544
|
+
function helpCommand() {
|
|
545
|
+
const baseUrl = config.load().baseUrl.replace(/\/$/, "");
|
|
546
|
+
out.line(`${HELP}Full reference: ${baseUrl}/api/v1/docs`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/commands/keys.ts
|
|
550
|
+
async function keysCommand(parsed) {
|
|
551
|
+
const action = parsed.positional[0] ?? "list";
|
|
552
|
+
const asJson = parsed.booleans.has("json");
|
|
553
|
+
if (action === "list") {
|
|
554
|
+
const result = await api("/keys");
|
|
555
|
+
if (asJson) {
|
|
556
|
+
out.json(result);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (result.keys.length === 0) {
|
|
560
|
+
out.line("No keys. Create one with `hepha keys create <name>`.");
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
out.table([
|
|
564
|
+
[
|
|
565
|
+
out.paint("ID", "dim"),
|
|
566
|
+
out.paint("NAME", "dim"),
|
|
567
|
+
out.paint("PREFIX", "dim"),
|
|
568
|
+
out.paint("SCOPES", "dim")
|
|
569
|
+
],
|
|
570
|
+
...result.keys.map((key) => [
|
|
571
|
+
out.cell(key.id),
|
|
572
|
+
out.cell(key.name ?? "\u2014"),
|
|
573
|
+
out.cell(key.start ?? "\u2014"),
|
|
574
|
+
key.scopes.join(" ")
|
|
575
|
+
])
|
|
576
|
+
]);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (action === "create") {
|
|
580
|
+
const name = args.required(parsed, 1, "name");
|
|
581
|
+
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
|
+
}
|
|
590
|
+
});
|
|
591
|
+
if (asJson) {
|
|
592
|
+
out.json(created);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
out.line(
|
|
596
|
+
`${out.paint("\u2713", "green")} Created "${out.cell(created.name ?? name)}"`
|
|
597
|
+
);
|
|
598
|
+
out.line();
|
|
599
|
+
out.line(` ${out.paint(created.key, "bold")}`);
|
|
600
|
+
out.line();
|
|
601
|
+
out.line(
|
|
602
|
+
out.paint(
|
|
603
|
+
" Copy it now \u2014 it is hashed at rest and never shown again.",
|
|
604
|
+
"dim"
|
|
605
|
+
)
|
|
606
|
+
);
|
|
607
|
+
out.line(out.paint(` Scopes: ${created.scopes.join(" ")}`, "dim"));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
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)}`);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
throw new CliError(`Unknown keys command "${action}".`, 2);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// src/commands/login.ts
|
|
626
|
+
import { createAuthClient } from "better-auth/client";
|
|
627
|
+
import { deviceAuthorizationClient } from "better-auth/client/plugins";
|
|
628
|
+
|
|
629
|
+
// ../../packages/database/constants.ts
|
|
630
|
+
var USER_ROLES = {
|
|
631
|
+
ADMIN: "admin",
|
|
632
|
+
MEMBER: "member"
|
|
633
|
+
};
|
|
634
|
+
var ALL_USER_ROLES = Object.values(USER_ROLES);
|
|
635
|
+
var API_SCOPES = [
|
|
636
|
+
"sessions:read",
|
|
637
|
+
"sessions:write",
|
|
638
|
+
"questions:read",
|
|
639
|
+
"questions:answer",
|
|
640
|
+
"approvals:read",
|
|
641
|
+
"catalog:read",
|
|
642
|
+
"pull_requests:read"
|
|
643
|
+
];
|
|
644
|
+
var DEFAULT_API_SCOPES = API_SCOPES.filter(
|
|
645
|
+
(scope) => scope.endsWith(":read")
|
|
646
|
+
);
|
|
647
|
+
var DEVICE_AUTHORIZATION = {
|
|
648
|
+
/** Only these clients may open a device flow. */
|
|
649
|
+
CLIENT_IDS: ["hepha-cli"],
|
|
650
|
+
/** Lifetime of an unapproved user code. */
|
|
651
|
+
EXPIRES_IN: "15m",
|
|
652
|
+
/** Minimum delay the CLI must wait between two token polls. */
|
|
653
|
+
INTERVAL: "5s",
|
|
654
|
+
/** Page a human visits to approve a pending device. */
|
|
655
|
+
VERIFICATION_PATH: "/device"
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
// src/lib/open-browser.ts
|
|
659
|
+
import { spawn } from "child_process";
|
|
660
|
+
function openBrowser(url) {
|
|
661
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
662
|
+
const commandArgs = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
663
|
+
try {
|
|
664
|
+
const child = spawn(command, commandArgs, {
|
|
665
|
+
stdio: "ignore",
|
|
666
|
+
detached: true
|
|
667
|
+
});
|
|
668
|
+
child.on("error", () => {
|
|
669
|
+
});
|
|
670
|
+
child.unref();
|
|
671
|
+
} catch {
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/commands/login.ts
|
|
676
|
+
var CLIENT_ID = DEVICE_AUTHORIZATION.CLIENT_IDS[0];
|
|
677
|
+
function sleep(seconds) {
|
|
678
|
+
return new Promise((resolve) => setTimeout(resolve, seconds * 1e3));
|
|
679
|
+
}
|
|
680
|
+
async function loginCommand(parsed) {
|
|
681
|
+
const requested = parsed.flags.url?.trim();
|
|
682
|
+
if (requested && !isHttpUrl(requested)) {
|
|
683
|
+
throw new CliError(`--url is not a valid http(s) URL: "${requested}".`, 2);
|
|
684
|
+
}
|
|
685
|
+
const baseUrl = (requested ?? config.load().baseUrl).replace(/\/$/, "");
|
|
686
|
+
const authClient = createAuthClient({
|
|
687
|
+
baseURL: baseUrl,
|
|
688
|
+
plugins: [deviceAuthorizationClient()]
|
|
689
|
+
});
|
|
690
|
+
const { data: device, error: deviceError } = await authClient.device.code({
|
|
691
|
+
client_id: CLIENT_ID,
|
|
692
|
+
scope: "openid profile email"
|
|
693
|
+
});
|
|
694
|
+
if (deviceError || !device) {
|
|
695
|
+
throw new CliError(
|
|
696
|
+
deviceError?.error_description ?? `Could not start a device login against ${baseUrl}.`
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
const verificationUrl = device.verification_uri_complete ?? device.verification_uri;
|
|
700
|
+
out.line();
|
|
701
|
+
out.line(` Code ${out.paint(out.cell(device.user_code), "bold")}`);
|
|
702
|
+
out.line(` Open ${out.cell(verificationUrl)}`);
|
|
703
|
+
out.line();
|
|
704
|
+
out.line(out.paint(" Waiting for approval\u2026", "dim"));
|
|
705
|
+
openBrowser(verificationUrl);
|
|
706
|
+
let interval = device.interval ?? 5;
|
|
707
|
+
const deadline = Date.now() + (device.expires_in ?? 900) * 1e3;
|
|
708
|
+
while (Date.now() < deadline) {
|
|
709
|
+
await sleep(interval);
|
|
710
|
+
const { data: token, error } = await authClient.device.token({
|
|
711
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
712
|
+
device_code: device.device_code,
|
|
713
|
+
client_id: CLIENT_ID
|
|
714
|
+
});
|
|
715
|
+
if (token?.access_token) {
|
|
716
|
+
const identity = await api("/me", {
|
|
717
|
+
as: { baseUrl, token: token.access_token }
|
|
718
|
+
});
|
|
719
|
+
config.save({
|
|
720
|
+
baseUrl,
|
|
721
|
+
token: token.access_token,
|
|
722
|
+
user: {
|
|
723
|
+
id: identity.user.id,
|
|
724
|
+
name: identity.user.name,
|
|
725
|
+
email: identity.user.email
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
out.line();
|
|
729
|
+
out.line(
|
|
730
|
+
`${out.paint("\u2713", "green")} Signed in as ${identity.user.email} on ${baseUrl}`
|
|
731
|
+
);
|
|
732
|
+
out.line(out.paint(` Session stored in ${config.path}`, "dim"));
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (!error) {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
switch (error.error) {
|
|
739
|
+
case "authorization_pending":
|
|
740
|
+
continue;
|
|
741
|
+
case "slow_down":
|
|
742
|
+
interval += 5;
|
|
743
|
+
continue;
|
|
744
|
+
case "access_denied":
|
|
745
|
+
throw new CliError("Access denied \u2014 the request was rejected.");
|
|
746
|
+
default:
|
|
747
|
+
throw new CliError(
|
|
748
|
+
error.error_description || "Device authorization failed."
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
throw new CliError("The code expired before it was approved. Try again.");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// src/commands/logout.ts
|
|
756
|
+
function logoutCommand(parsed) {
|
|
757
|
+
config.clear();
|
|
758
|
+
if (parsed.booleans.has("json")) {
|
|
759
|
+
out.json({ signedOut: true });
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
out.line("Signed out.");
|
|
763
|
+
}
|
|
764
|
+
|
|
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"));
|
|
780
|
+
}
|
|
781
|
+
out.line(
|
|
782
|
+
`${out.paint(out.cell(question.id), "dim")} ${out.safe(question.body)}`
|
|
783
|
+
);
|
|
784
|
+
if (question.image) {
|
|
785
|
+
out.line(out.paint(` (image) ${out.cell(question.image)}`, "dim"));
|
|
786
|
+
}
|
|
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") : ""}`
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
if (question.allowFreeText) {
|
|
795
|
+
out.line(out.paint(" free text accepted", "dim"));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
function collectAnswers(parsed) {
|
|
800
|
+
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
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const byQuestion = /* @__PURE__ */ new Map();
|
|
808
|
+
const put = (pair, apply) => {
|
|
809
|
+
const equalsAt = pair.indexOf("=");
|
|
810
|
+
if (equalsAt < 0) {
|
|
811
|
+
throw new CliError(`Expected <questionId>=<value>, got "${pair}".`, 2);
|
|
812
|
+
}
|
|
813
|
+
const questionId = pair.slice(0, equalsAt);
|
|
814
|
+
const entry = byQuestion.get(questionId) ?? { questionId };
|
|
815
|
+
apply(entry);
|
|
816
|
+
byQuestion.set(questionId, entry);
|
|
817
|
+
};
|
|
818
|
+
for (const pair of parsed.repeated.option ?? []) {
|
|
819
|
+
put(pair, (entry) => {
|
|
820
|
+
entry.selectedOptionIds = pair.slice(pair.indexOf("=") + 1).split(",").map((id) => id.trim()).filter(Boolean);
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
for (const pair of parsed.repeated.text ?? []) {
|
|
824
|
+
put(pair, (entry) => {
|
|
825
|
+
entry.freeText = pair.slice(pair.indexOf("=") + 1);
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
const answers = [...byQuestion.values()];
|
|
829
|
+
if (answers.length === 0) {
|
|
830
|
+
throw new CliError(
|
|
831
|
+
"Nothing to submit. Use --option <questionId>=<ids>, --text <questionId>=<answer>, or --answers <json>.",
|
|
832
|
+
2
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
return answers;
|
|
836
|
+
}
|
|
837
|
+
async function questionsCommand(parsed) {
|
|
838
|
+
const action = parsed.positional[0] ?? "list";
|
|
839
|
+
const asJson = parsed.booleans.has("json");
|
|
840
|
+
if (action === "list") {
|
|
841
|
+
const path = parsed.flags.session ? `/sessions/${parsed.flags.session}/questions` : "/questions";
|
|
842
|
+
const result = await api(path);
|
|
843
|
+
if (asJson) {
|
|
844
|
+
out.json(result);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (result.items.length === 0) {
|
|
848
|
+
out.line("Nothing waiting on you.");
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
out.table(
|
|
852
|
+
result.items.map((batch) => [
|
|
853
|
+
out.cell(batch.id),
|
|
854
|
+
out.cell(batch.sessionId),
|
|
855
|
+
`${batch.questions.length} question(s)`,
|
|
856
|
+
out.cell(batch.sessionTitle)
|
|
857
|
+
])
|
|
858
|
+
);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const batchId = args.required(parsed, 1, "batchId");
|
|
862
|
+
if (action === "show") {
|
|
863
|
+
const batch = await api(`/questions/${batchId}`);
|
|
864
|
+
if (asJson) {
|
|
865
|
+
out.json(batch);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
printBatch(batch);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (action === "answer") {
|
|
872
|
+
const result = await api(
|
|
873
|
+
`/questions/${batchId}/submit`,
|
|
874
|
+
{ method: "POST", body: { answers: collectAnswers(parsed) } }
|
|
875
|
+
);
|
|
876
|
+
if (asJson) {
|
|
877
|
+
out.json(result);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
out.line(
|
|
881
|
+
`${out.paint("\u2713", "green")} Answered ${result.answeredCount} question(s); the session resumes.`
|
|
882
|
+
);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
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
|
+
);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
throw new CliError(`Unknown questions command "${action}".`, 2);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// src/lib/format-event.ts
|
|
903
|
+
function truncate(text, max = 2e3) {
|
|
904
|
+
const trimmed = out.safe(text).trim();
|
|
905
|
+
return trimmed.length > max ? `${trimmed.slice(0, max)}\u2026` : trimmed;
|
|
906
|
+
}
|
|
907
|
+
function formatEvent(event) {
|
|
908
|
+
const part = event.part;
|
|
909
|
+
switch (part.kind) {
|
|
910
|
+
case "text":
|
|
911
|
+
if (part.authorUserId) {
|
|
912
|
+
return `${out.paint("you", "magenta")} ${truncate(part.text)}`;
|
|
913
|
+
}
|
|
914
|
+
if (part.source) {
|
|
915
|
+
return out.paint(`system ${truncate(part.text, 400)}`, "dim");
|
|
916
|
+
}
|
|
917
|
+
return `${out.paint("agent", "blue")} ${truncate(part.text)}`;
|
|
918
|
+
case "reasoning":
|
|
919
|
+
return out.paint(`think ${truncate(part.text, 400)}`, "dim");
|
|
920
|
+
case "tool":
|
|
921
|
+
return `${out.paint("tool", "dim")} ${out.cell(part.toolName)} ${out.paint(`(${part.status})`, "dim")}`;
|
|
922
|
+
case "file_change":
|
|
923
|
+
return `${out.paint("file", "dim")} ${part.changeType.padEnd(8)} ${out.cell(part.path)}`;
|
|
924
|
+
case "question":
|
|
925
|
+
return out.paint(
|
|
926
|
+
`ask question round ${part.questionBatchId} \u2014 answer with \`hepha questions answer ${part.questionBatchId}\``,
|
|
927
|
+
"yellow"
|
|
928
|
+
);
|
|
929
|
+
case "approval":
|
|
930
|
+
return out.paint(
|
|
931
|
+
`gate guardrail ${part.guardrailRequestId} needs a human \u2014 \`hepha approvals show ${part.guardrailRequestId}\``,
|
|
932
|
+
"yellow"
|
|
933
|
+
);
|
|
934
|
+
case "media": {
|
|
935
|
+
const target = part.url ?? part.pathname;
|
|
936
|
+
return `${out.paint("media", "dim")} ${part.mediaType}${part.caption ? ` \u2014 ${out.cell(part.caption)}` : ""}${target ? `
|
|
937
|
+
${out.cell(target)}` : ""}`;
|
|
938
|
+
}
|
|
939
|
+
case "error":
|
|
940
|
+
return out.paint(`error ${truncate(part.message)}`, "red");
|
|
941
|
+
case "external_event":
|
|
942
|
+
return `${out.paint("event", "dim")} ${out.cell(part.title)}${part.conclusion ? ` \u2014 ${out.cell(part.conclusion)}` : ""}`;
|
|
943
|
+
case "system":
|
|
944
|
+
return out.paint(`system ${truncate(part.text, 300)}`, "dim");
|
|
945
|
+
default:
|
|
946
|
+
return null;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// src/lib/follow.ts
|
|
951
|
+
var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed", "killed", "abstained"]);
|
|
952
|
+
var BLOCKED = /* @__PURE__ */ new Set(["waiting_human", "awaiting_approval"]);
|
|
953
|
+
var POLL_SECONDS = 3;
|
|
954
|
+
function sleep2(seconds) {
|
|
955
|
+
return new Promise((resolve) => setTimeout(resolve, seconds * 1e3));
|
|
956
|
+
}
|
|
957
|
+
async function followSession(sessionId, options = {}) {
|
|
958
|
+
let cursor = options.after ?? 0;
|
|
959
|
+
for (; ; ) {
|
|
960
|
+
const page = await api(
|
|
961
|
+
`/sessions/${sessionId}/events`,
|
|
962
|
+
{ query: { after: cursor || void 0, limit: 200 } }
|
|
963
|
+
);
|
|
964
|
+
for (const event of page.items) {
|
|
965
|
+
cursor = Math.max(cursor, event.sequence);
|
|
966
|
+
if (options.quiet) {
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
const line = formatEvent(event);
|
|
970
|
+
if (line) {
|
|
971
|
+
out.line(line);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
if (page.nextCursor !== null) {
|
|
975
|
+
cursor = Math.max(cursor, page.nextCursor);
|
|
976
|
+
}
|
|
977
|
+
if (page.hasMore) {
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
const session = await api(`/sessions/${sessionId}`);
|
|
981
|
+
if (TERMINAL.has(session.status) || BLOCKED.has(session.status)) {
|
|
982
|
+
return session;
|
|
983
|
+
}
|
|
984
|
+
await sleep2(POLL_SECONDS);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// src/lib/print-session.ts
|
|
989
|
+
function printSession(session) {
|
|
990
|
+
out.line(
|
|
991
|
+
`${out.paint(out.cell(session.title), "bold")} ${out.status(session.status)}`
|
|
992
|
+
);
|
|
993
|
+
const rows = [["id", out.cell(session.id)]];
|
|
994
|
+
if (session.statusReason) {
|
|
995
|
+
rows.push(["reason", out.cell(session.statusReason)]);
|
|
996
|
+
}
|
|
997
|
+
if (session.repoFullName) {
|
|
998
|
+
const branch = session.repos.find(
|
|
999
|
+
(repo) => repo.repoFullName === session.repoFullName
|
|
1000
|
+
)?.branch;
|
|
1001
|
+
rows.push([
|
|
1002
|
+
"repo",
|
|
1003
|
+
out.cell(session.repoFullName + (branch ? ` @ ${branch}` : ""))
|
|
1004
|
+
]);
|
|
1005
|
+
}
|
|
1006
|
+
rows.push(["task", session.taskType]);
|
|
1007
|
+
if (session.model) {
|
|
1008
|
+
rows.push(["model", out.cell(session.model)]);
|
|
1009
|
+
}
|
|
1010
|
+
if (session.prUrl) {
|
|
1011
|
+
rows.push([
|
|
1012
|
+
"pr",
|
|
1013
|
+
out.cell(
|
|
1014
|
+
`${session.prUrl}${session.prState ? ` (${session.prState})` : ""}`
|
|
1015
|
+
)
|
|
1016
|
+
]);
|
|
1017
|
+
}
|
|
1018
|
+
for (const deliverable of session.deliverables) {
|
|
1019
|
+
if (deliverable.url && deliverable.url !== session.prUrl) {
|
|
1020
|
+
rows.push([out.cell(deliverable.type), out.cell(deliverable.url)]);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
out.table(rows.map(([label, value]) => [out.paint(label, "dim"), value]));
|
|
1024
|
+
const steps = session.playbook?.steps ?? [];
|
|
1025
|
+
if (steps.length > 0) {
|
|
1026
|
+
out.line();
|
|
1027
|
+
for (const step of steps) {
|
|
1028
|
+
const done = step.todos.filter(
|
|
1029
|
+
(todo) => todo.status === "completed"
|
|
1030
|
+
).length;
|
|
1031
|
+
const marker = done === step.todos.length && step.todos.length > 0 ? "\u2713" : "\xB7";
|
|
1032
|
+
out.line(
|
|
1033
|
+
` ${marker} ${out.cell(step.title)} ${out.paint(`${done}/${step.todos.length}`, "dim")}`
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// src/lib/session-outcome.ts
|
|
1040
|
+
function sessionOutcome(session) {
|
|
1041
|
+
switch (session.status) {
|
|
1042
|
+
case "completed":
|
|
1043
|
+
return null;
|
|
1044
|
+
case "waiting_human":
|
|
1045
|
+
return new CliError(
|
|
1046
|
+
`Waiting on an answer \u2014 \`hepha questions list --session ${session.id}\``,
|
|
1047
|
+
3
|
|
1048
|
+
);
|
|
1049
|
+
case "awaiting_approval":
|
|
1050
|
+
return new CliError(
|
|
1051
|
+
"Waiting on a guardrail approval \u2014 `hepha approvals list`",
|
|
1052
|
+
3
|
|
1053
|
+
);
|
|
1054
|
+
default:
|
|
1055
|
+
return new CliError(
|
|
1056
|
+
`Session ended ${session.status}${session.statusReason ? `: ${session.statusReason}` : "."}`
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// src/commands/run.ts
|
|
1062
|
+
async function runCommand(parsed) {
|
|
1063
|
+
const prompt = args.required(parsed, 0, "prompt");
|
|
1064
|
+
const asJson = parsed.booleans.has("json");
|
|
1065
|
+
const body = {
|
|
1066
|
+
prompt,
|
|
1067
|
+
...parsed.flags.title ? { title: parsed.flags.title } : {},
|
|
1068
|
+
...parsed.flags["task-type"] ? { taskType: parsed.flags["task-type"] } : {},
|
|
1069
|
+
...parsed.flags.model ? { modelSelection: parsed.flags.model } : {},
|
|
1070
|
+
...parsed.flags.harness ? { harnessPin: parsed.flags.harness } : {},
|
|
1071
|
+
...parsed.flags.repo ? { repoFullName: parsed.flags.repo } : {},
|
|
1072
|
+
...parsed.flags.branch ? { branch: parsed.flags.branch } : {},
|
|
1073
|
+
...parsed.repeated.skill ? { pinnedSkillIds: parsed.repeated.skill } : {}
|
|
1074
|
+
};
|
|
1075
|
+
const session = await api("/sessions", {
|
|
1076
|
+
method: "POST",
|
|
1077
|
+
body
|
|
1078
|
+
});
|
|
1079
|
+
if (!parsed.booleans.has("follow")) {
|
|
1080
|
+
if (asJson) {
|
|
1081
|
+
out.json(session);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
printSession(session);
|
|
1085
|
+
out.line();
|
|
1086
|
+
out.line(out.paint(` hepha sessions logs ${session.id} --follow`, "dim"));
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (!asJson) {
|
|
1090
|
+
out.line(out.paint(`session ${session.id}`, "dim"));
|
|
1091
|
+
out.line();
|
|
1092
|
+
}
|
|
1093
|
+
const final = await followSession(session.id, { quiet: asJson });
|
|
1094
|
+
if (asJson) {
|
|
1095
|
+
out.json(final);
|
|
1096
|
+
} else {
|
|
1097
|
+
out.line();
|
|
1098
|
+
printSession(final);
|
|
1099
|
+
}
|
|
1100
|
+
const outcome = sessionOutcome(final);
|
|
1101
|
+
if (outcome) {
|
|
1102
|
+
throw outcome;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// src/commands/sessions.ts
|
|
1107
|
+
var CONTROL_PATHS = {
|
|
1108
|
+
pause: "pause",
|
|
1109
|
+
resume: "resume",
|
|
1110
|
+
kill: "kill",
|
|
1111
|
+
archive: "archive"
|
|
1112
|
+
};
|
|
1113
|
+
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
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
if (parsed.booleans.has("json")) {
|
|
1125
|
+
out.json(result);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
if (result.data.length === 0) {
|
|
1129
|
+
out.line("No sessions.");
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
if (result.pagination.hasNextPage) {
|
|
1133
|
+
out.line(
|
|
1134
|
+
out.paint(
|
|
1135
|
+
`page ${result.pagination.page}/${result.pagination.totalPages} \u2014 --page ${result.pagination.page + 1} for more`,
|
|
1136
|
+
"dim"
|
|
1137
|
+
)
|
|
1138
|
+
);
|
|
1139
|
+
out.line();
|
|
1140
|
+
}
|
|
1141
|
+
out.table(
|
|
1142
|
+
result.data.map((session) => [
|
|
1143
|
+
out.cell(session.id),
|
|
1144
|
+
out.status(session.status),
|
|
1145
|
+
out.cell(session.repoFullName ?? "\u2014"),
|
|
1146
|
+
out.cell(session.title)
|
|
1147
|
+
])
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
async function showLogs(parsed) {
|
|
1151
|
+
const sessionId = args.required(parsed, 1, "sessionId");
|
|
1152
|
+
const asJson = parsed.booleans.has("json");
|
|
1153
|
+
const after = args.number(parsed, "after");
|
|
1154
|
+
if (parsed.booleans.has("follow")) {
|
|
1155
|
+
const session = await followSession(sessionId, {
|
|
1156
|
+
quiet: asJson,
|
|
1157
|
+
...after !== void 0 ? { after } : {}
|
|
1158
|
+
});
|
|
1159
|
+
if (asJson) {
|
|
1160
|
+
out.json(session);
|
|
1161
|
+
} else {
|
|
1162
|
+
out.line();
|
|
1163
|
+
printSession(session);
|
|
1164
|
+
}
|
|
1165
|
+
const outcome = sessionOutcome(session);
|
|
1166
|
+
if (outcome) {
|
|
1167
|
+
throw outcome;
|
|
1168
|
+
}
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
const page = await api(
|
|
1172
|
+
`/sessions/${sessionId}/events`,
|
|
1173
|
+
{
|
|
1174
|
+
query: {
|
|
1175
|
+
after,
|
|
1176
|
+
limit: args.number(parsed, "limit") ?? 100
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
);
|
|
1180
|
+
if (asJson) {
|
|
1181
|
+
out.json(page);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
for (const event of page.items) {
|
|
1185
|
+
const line = formatEvent(event);
|
|
1186
|
+
if (line) {
|
|
1187
|
+
out.line(line);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
if (page.hasMore && page.nextCursor !== null) {
|
|
1191
|
+
out.line();
|
|
1192
|
+
out.line(
|
|
1193
|
+
out.paint(
|
|
1194
|
+
`more events \u2014 hepha sessions logs ${sessionId} --after ${page.nextCursor}`,
|
|
1195
|
+
"dim"
|
|
1196
|
+
)
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
async function sessionsCommand(parsed) {
|
|
1201
|
+
const action = parsed.positional[0] ?? "list";
|
|
1202
|
+
const asJson = parsed.booleans.has("json");
|
|
1203
|
+
if (action === "list") {
|
|
1204
|
+
return await listSessions(parsed);
|
|
1205
|
+
}
|
|
1206
|
+
if (action === "logs") {
|
|
1207
|
+
return await showLogs(parsed);
|
|
1208
|
+
}
|
|
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
|
+
if (action === "send") {
|
|
1220
|
+
const message = args.required(parsed, 2, "message");
|
|
1221
|
+
const result = await api(
|
|
1222
|
+
`/sessions/${sessionId}/messages`,
|
|
1223
|
+
{ method: "POST", body: { message } }
|
|
1224
|
+
);
|
|
1225
|
+
if (asJson) {
|
|
1226
|
+
out.json(result);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
out.line(
|
|
1230
|
+
result.delivery === "interrupt" ? "Delivered to the running agent." : "Queued \u2014 the agent picks it up at the end of its turn."
|
|
1231
|
+
);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (Object.hasOwn(CONTROL_PATHS, action)) {
|
|
1235
|
+
const session = await api(
|
|
1236
|
+
`/sessions/${sessionId}/${CONTROL_PATHS[action]}`,
|
|
1237
|
+
{
|
|
1238
|
+
method: "POST",
|
|
1239
|
+
body: action === "kill" && parsed.flags.reason ? { reason: parsed.flags.reason } : {}
|
|
1240
|
+
}
|
|
1241
|
+
);
|
|
1242
|
+
if (asJson) {
|
|
1243
|
+
out.json(session);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
printSession(session);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
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
|
+
out.json(
|
|
1259
|
+
await api(
|
|
1260
|
+
`/sessions/${sessionId}/pr-checks`
|
|
1261
|
+
)
|
|
1262
|
+
);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
if (action === "verify") {
|
|
1266
|
+
out.json(
|
|
1267
|
+
await api(
|
|
1268
|
+
`/sessions/${sessionId}/verification`
|
|
1269
|
+
)
|
|
1270
|
+
);
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
throw new CliError(`Unknown sessions command "${action}".`, 2);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// src/commands/whoami.ts
|
|
1277
|
+
async function whoamiCommand(parsed) {
|
|
1278
|
+
const identity = await api("/me");
|
|
1279
|
+
if (parsed.booleans.has("json")) {
|
|
1280
|
+
out.json(identity);
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
out.table([
|
|
1284
|
+
[
|
|
1285
|
+
out.paint("user", "dim"),
|
|
1286
|
+
out.cell(`${identity.user.name} <${identity.user.email}>`)
|
|
1287
|
+
],
|
|
1288
|
+
[out.paint("role", "dim"), out.cell(identity.user.role ?? "member")],
|
|
1289
|
+
[
|
|
1290
|
+
out.paint("auth", "dim"),
|
|
1291
|
+
out.cell(
|
|
1292
|
+
identity.auth.method === "api_key" ? `api key${identity.auth.keyName ? ` "${identity.auth.keyName}"` : ""}` : "session"
|
|
1293
|
+
)
|
|
1294
|
+
],
|
|
1295
|
+
[out.paint("scopes", "dim"), identity.auth.scopes.join(" ")],
|
|
1296
|
+
[out.paint("url", "dim"), config.load().baseUrl]
|
|
1297
|
+
]);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// src/index.ts
|
|
1301
|
+
var CATALOG_TOPICS = /* @__PURE__ */ new Set(["repos", "branches", "models", "skills"]);
|
|
1302
|
+
async function dispatch(command, argv) {
|
|
1303
|
+
const parsed = args.parse(argv);
|
|
1304
|
+
if (parsed.booleans.has("help")) {
|
|
1305
|
+
helpCommand();
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
if (CATALOG_TOPICS.has(command)) {
|
|
1309
|
+
return await catalogCommand(command, parsed);
|
|
1310
|
+
}
|
|
1311
|
+
switch (command) {
|
|
1312
|
+
case "login":
|
|
1313
|
+
return await loginCommand(parsed);
|
|
1314
|
+
case "logout":
|
|
1315
|
+
return logoutCommand(parsed);
|
|
1316
|
+
case "whoami":
|
|
1317
|
+
return await whoamiCommand(parsed);
|
|
1318
|
+
case "keys":
|
|
1319
|
+
return await keysCommand(parsed);
|
|
1320
|
+
case "run":
|
|
1321
|
+
return await runCommand(parsed);
|
|
1322
|
+
case "sessions":
|
|
1323
|
+
return await sessionsCommand(parsed);
|
|
1324
|
+
case "questions":
|
|
1325
|
+
return await questionsCommand(parsed);
|
|
1326
|
+
case "approvals":
|
|
1327
|
+
return await approvalsCommand(parsed);
|
|
1328
|
+
default:
|
|
1329
|
+
helpCommand();
|
|
1330
|
+
throw new CliError(`Unknown command "${command}".`, 2);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
async function main() {
|
|
1334
|
+
const [command, ...argv] = process.argv.slice(2);
|
|
1335
|
+
if (!command || command === "help" || command === "--help") {
|
|
1336
|
+
helpCommand();
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
await dispatch(command, argv);
|
|
1340
|
+
}
|
|
1341
|
+
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);
|
|
1348
|
+
});
|
|
1349
|
+
//# sourceMappingURL=index.js.map
|