@shipd-ai/cli 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 +133 -0
- package/dist/auth.d.ts +26 -0
- package/dist/auth.js +75 -0
- package/dist/client.d.ts +51 -0
- package/dist/client.js +70 -0
- package/dist/commands/auth.d.ts +22 -0
- package/dist/commands/auth.js +109 -0
- package/dist/commands/checks.d.ts +12 -0
- package/dist/commands/checks.js +669 -0
- package/dist/contract.d.ts +129 -0
- package/dist/contract.js +24 -0
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +49 -0
- package/dist/format.d.ts +19 -0
- package/dist/format.js +93 -0
- package/dist/harbor.d.ts +25 -0
- package/dist/harbor.js +137 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +95 -0
- package/dist/plan.d.ts +65 -0
- package/dist/plan.js +367 -0
- package/dist/quests.d.ts +24 -0
- package/dist/quests.js +181 -0
- package/dist/report.d.ts +62 -0
- package/dist/report.js +107 -0
- package/package.json +43 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { defineCommand } from "citty";
|
|
4
|
+
import { connectQuest } from "../client.js";
|
|
5
|
+
import { isPrecheckKey, precheckStageId, } from "../contract.js";
|
|
6
|
+
import { EXIT_CHECK_FAILED, UsageError } from "../errors.js";
|
|
7
|
+
import { dim, formatDuration, printJson, printTable, statusBadge, truncate } from "../format.js";
|
|
8
|
+
import { readHarborBundle } from "../harbor.js";
|
|
9
|
+
import { DEFAULT_MAX_STEP_MS, DEFAULT_POLL_MS, runChecks, } from "../plan.js";
|
|
10
|
+
import { DEFAULT_QUEST } from "../quests.js";
|
|
11
|
+
import { exitCodeFor, modeLabel, renderTable, writeReport, } from "../report.js";
|
|
12
|
+
const color = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
13
|
+
const questArg = {
|
|
14
|
+
quest: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: `Quest to talk to (default: ${DEFAULT_QUEST})`,
|
|
17
|
+
default: DEFAULT_QUEST,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
function parsePositiveNumber(raw, fallback, flag) {
|
|
21
|
+
if (raw === undefined || raw === "")
|
|
22
|
+
return fallback;
|
|
23
|
+
const parsed = Number(raw);
|
|
24
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
25
|
+
throw new UsageError(`--${flag} must be a positive number, got "${raw}".`);
|
|
26
|
+
}
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
/** "a + b → c": steps grouped by dependency depth. */
|
|
30
|
+
export function formatSteps(check) {
|
|
31
|
+
const depth = new Map();
|
|
32
|
+
const resolve = (id, seen = []) => {
|
|
33
|
+
const known = depth.get(id);
|
|
34
|
+
if (known !== undefined)
|
|
35
|
+
return known;
|
|
36
|
+
if (seen.includes(id))
|
|
37
|
+
return 0;
|
|
38
|
+
const step = check.steps.find((s) => s.id === id);
|
|
39
|
+
const d = step?.after.length
|
|
40
|
+
? 1 + Math.max(...step.after.map((a) => resolve(a, [...seen, id])))
|
|
41
|
+
: 0;
|
|
42
|
+
depth.set(id, d);
|
|
43
|
+
return d;
|
|
44
|
+
};
|
|
45
|
+
const levels = new Map();
|
|
46
|
+
for (const step of check.steps) {
|
|
47
|
+
const d = resolve(step.id);
|
|
48
|
+
levels.set(d, [...(levels.get(d) ?? []), step.id]);
|
|
49
|
+
}
|
|
50
|
+
return [...levels.keys()]
|
|
51
|
+
.sort((a, b) => a - b)
|
|
52
|
+
.map((d) => (levels.get(d) ?? []).join(" + "))
|
|
53
|
+
.join(" → ");
|
|
54
|
+
}
|
|
55
|
+
export function selectChecks(catalog, explicit, all) {
|
|
56
|
+
if (all)
|
|
57
|
+
return { checks: catalog.checks, selection: "all" };
|
|
58
|
+
const keys = (explicit ?? "")
|
|
59
|
+
.split(",")
|
|
60
|
+
.map((k) => k.trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
if (keys.length === 0)
|
|
63
|
+
throw new UsageError("Pass --check <key[,key...]> or --all.");
|
|
64
|
+
const byKey = new Map(catalog.checks.map((c) => [c.key, c]));
|
|
65
|
+
const unknown = keys.filter((k) => !byKey.has(k));
|
|
66
|
+
if (unknown.length > 0) {
|
|
67
|
+
throw new UsageError(`Unknown check${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.\n` +
|
|
68
|
+
` Valid keys for ${catalog.quest}: ${catalog.checks.map((c) => c.key).join(", ")}`);
|
|
69
|
+
}
|
|
70
|
+
const unique = [...new Set(keys)];
|
|
71
|
+
return { checks: unique.map((k) => byKey.get(k)), selection: "explicit" };
|
|
72
|
+
}
|
|
73
|
+
function skippedResult(check, reason) {
|
|
74
|
+
return {
|
|
75
|
+
key: check.key,
|
|
76
|
+
label: check.label,
|
|
77
|
+
kind: check.kind,
|
|
78
|
+
status: "skipped",
|
|
79
|
+
reason,
|
|
80
|
+
durationMs: 0,
|
|
81
|
+
steps: {},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function warnLive(message) {
|
|
85
|
+
console.error(`\n Writes to production: ${message}\n`);
|
|
86
|
+
}
|
|
87
|
+
function progressPrinter() {
|
|
88
|
+
const c = color();
|
|
89
|
+
return (event) => {
|
|
90
|
+
switch (event.type) {
|
|
91
|
+
case "step:start":
|
|
92
|
+
if (event.immediate) {
|
|
93
|
+
console.error(` ${dim("·", c)} ${event.checkKey}/${event.stepId} resolved immediately`);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const id = event.handle ? ` ${dim(`(${event.handle.modalFunctionCallId})`, c)}` : "";
|
|
97
|
+
console.error(` ${dim("▸", c)} ${event.checkKey}/${event.stepId} started${id}`);
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
case "step:end": {
|
|
101
|
+
const verdict = event.verdict ? ` (${event.verdict})` : "";
|
|
102
|
+
const detail = event.error ? `: ${event.error}` : event.reason ? `: ${event.reason}` : "";
|
|
103
|
+
console.error(` ${statusBadge(event.status, c)} ${event.checkKey}/${event.stepId}${verdict}${detail}`);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case "live:started":
|
|
107
|
+
console.error(event.started
|
|
108
|
+
? ` ${dim("▸", c)} ${event.checkKey} triggered`
|
|
109
|
+
: ` ${statusBadge("skipped", c)} ${event.checkKey} not started${event.detail ? `: ${event.detail}` : ""}`);
|
|
110
|
+
break;
|
|
111
|
+
case "live:nudge":
|
|
112
|
+
console.error(` ${dim("▸", c)} ${event.checkKey} nudging synthesis`);
|
|
113
|
+
break;
|
|
114
|
+
case "check:end": {
|
|
115
|
+
const r = event.result;
|
|
116
|
+
const verdict = r.verdict ? ` (${r.verdict})` : "";
|
|
117
|
+
console.error(` ${statusBadge(r.status, c)} ${r.key}${verdict} in ${formatDuration(r.durationMs)}`);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
default:
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// Never exits the process directly: every path aborts the run and lets the
|
|
126
|
+
// command finish writing the report (handles included) before exit 3.
|
|
127
|
+
function installInterruptHandler(controller, state) {
|
|
128
|
+
let asked = false;
|
|
129
|
+
const leaveRunning = () => {
|
|
130
|
+
state.mode = "leave";
|
|
131
|
+
console.error("\n Leaving in-flight steps running; their handles stay in the report. Exit 3.");
|
|
132
|
+
controller.abort();
|
|
133
|
+
};
|
|
134
|
+
const onSigint = () => {
|
|
135
|
+
if (asked || controller.signal.aborted) {
|
|
136
|
+
leaveRunning();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
asked = true;
|
|
140
|
+
if (!process.stdin.isTTY) {
|
|
141
|
+
console.error("\n Interrupted: cancelling in-flight steps...");
|
|
142
|
+
controller.abort();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
146
|
+
rl.on("SIGINT", () => {
|
|
147
|
+
rl.close();
|
|
148
|
+
leaveRunning();
|
|
149
|
+
});
|
|
150
|
+
rl.question("\n Cancel in-flight steps? [Y/n] ", (answer) => {
|
|
151
|
+
rl.close();
|
|
152
|
+
if (answer.trim().toLowerCase().startsWith("n")) {
|
|
153
|
+
leaveRunning();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
console.error(" Cancelling in-flight steps...");
|
|
157
|
+
controller.abort();
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
process.on("SIGINT", onSigint);
|
|
161
|
+
return () => {
|
|
162
|
+
process.off("SIGINT", onSigint);
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** Lines for `--dry-run --task`: what would be dispatched, without dispatching. */
|
|
166
|
+
export function dryRunTaskLines(task, checks) {
|
|
167
|
+
return [
|
|
168
|
+
`Task ${task.problemId} ("${task.title}", ${task.status}) — ${task.url}`,
|
|
169
|
+
`Nothing started (dry run). These checks would run live and re-point the task's visible results:`,
|
|
170
|
+
...checks.map((c) => ` ${c.key.padEnd(28)} ${c.kind.padEnd(9)} ${formatSteps(c)}`),
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
function printDryRun(report, bundle, adapt, checks, json, withDraft) {
|
|
174
|
+
const selected = Object.fromEntries(checks.map((c) => [c.key, adapt.checks[c.key] ?? { eligible: true, missing: [] }]));
|
|
175
|
+
if (json) {
|
|
176
|
+
printJson({
|
|
177
|
+
quest: report.quest,
|
|
178
|
+
mode: report.mode,
|
|
179
|
+
dryRun: true,
|
|
180
|
+
source: report.source,
|
|
181
|
+
fields: adapt.fields,
|
|
182
|
+
checks: selected,
|
|
183
|
+
...(withDraft ? { draft: adapt.draft } : {}),
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const c = color();
|
|
188
|
+
console.log(`\n Quest: ${report.quest} (dry run, nothing started)`);
|
|
189
|
+
console.log(` Bundle: ${bundle.dir}`);
|
|
190
|
+
console.log(` Input: ${bundle.inputHash}\n`);
|
|
191
|
+
console.log(" Adapted fields:");
|
|
192
|
+
for (const [key, value] of Object.entries(adapt.fields)) {
|
|
193
|
+
if (value === undefined || value === null || value === "")
|
|
194
|
+
continue;
|
|
195
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
196
|
+
const size = typeof value === "string" && value.length > 60 ? ` (${value.length} chars)` : "";
|
|
197
|
+
console.log(` ${key.padEnd(16)} ${truncate(text, 60)}${size}`);
|
|
198
|
+
}
|
|
199
|
+
console.log("");
|
|
200
|
+
printTable(checks.map((check) => {
|
|
201
|
+
const entry = selected[check.key];
|
|
202
|
+
return {
|
|
203
|
+
check: check.key,
|
|
204
|
+
kind: check.kind,
|
|
205
|
+
eligible: entry.eligible
|
|
206
|
+
? statusBadge("pass", c).replace("pass", "yes")
|
|
207
|
+
: statusBadge("fail", c).replace("fail", "no"),
|
|
208
|
+
missing: entry.missing.join(", "),
|
|
209
|
+
steps: formatSteps(check),
|
|
210
|
+
};
|
|
211
|
+
}), [
|
|
212
|
+
{ key: "check", label: "Check" },
|
|
213
|
+
{ key: "kind", label: "Kind" },
|
|
214
|
+
{ key: "eligible", label: "Eligible" },
|
|
215
|
+
{ key: "missing", label: "Missing" },
|
|
216
|
+
{ key: "steps", label: "Steps" },
|
|
217
|
+
]);
|
|
218
|
+
if (withDraft) {
|
|
219
|
+
console.log("\n Draft that --create would create (not created):");
|
|
220
|
+
for (const [key, value] of Object.entries(adapt.draft)) {
|
|
221
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
222
|
+
const size = typeof value === "string" && value.length > 60 ? ` (${value.length} chars)` : "";
|
|
223
|
+
console.log(` ${key.padEnd(16)} ${truncate(text, 60)}${size}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
console.log("");
|
|
227
|
+
}
|
|
228
|
+
async function startWithoutWaiting(api, target, checks, options) {
|
|
229
|
+
let failures = 0;
|
|
230
|
+
for (const check of checks) {
|
|
231
|
+
try {
|
|
232
|
+
if (target.kind === "live") {
|
|
233
|
+
const started = await api.runLiveCheck(target.task.versionId, target.task.problemId, check.key, options);
|
|
234
|
+
console.log(JSON.stringify({
|
|
235
|
+
checkKey: check.key,
|
|
236
|
+
mode: "live",
|
|
237
|
+
problemId: target.task.problemId,
|
|
238
|
+
versionId: target.task.versionId,
|
|
239
|
+
url: target.task.url,
|
|
240
|
+
...started,
|
|
241
|
+
}));
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (check.kind === "precheck" || isPrecheckKey(check.key)) {
|
|
245
|
+
const result = await api.runPrecheckStage(target.bundle, precheckStageId(check.key));
|
|
246
|
+
console.log(JSON.stringify({ checkKey: check.key, kind: "precheck", result }));
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const roots = check.steps.filter((s) => s.after.length === 0);
|
|
250
|
+
const dependents = check.steps.filter((s) => s.after.length > 0);
|
|
251
|
+
if (dependents.length > 0) {
|
|
252
|
+
console.error(` ${check.key}: only the root step${roots.length > 1 ? "s" : ""} ${roots.map((s) => s.id).join(", ")} ` +
|
|
253
|
+
`start without --wait; ${dependents.map((s) => s.id).join(", ")} need the earlier outputs.`);
|
|
254
|
+
}
|
|
255
|
+
for (const step of roots) {
|
|
256
|
+
const started = await api.startStep(target.bundle, check.key, step.id, {}, options);
|
|
257
|
+
console.log(JSON.stringify({ checkKey: check.key, stepId: step.id, ...started }));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
failures += 1;
|
|
262
|
+
console.log(JSON.stringify({
|
|
263
|
+
checkKey: check.key,
|
|
264
|
+
kind: "error",
|
|
265
|
+
error: error instanceof Error ? error.message : String(error),
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return failures > 0 ? EXIT_CHECK_FAILED : 0;
|
|
270
|
+
}
|
|
271
|
+
/** Parse `status`/`cancel` positionals: a handle JSON string or a report file path. */
|
|
272
|
+
async function readHandles(raw) {
|
|
273
|
+
if (raw.length === 0) {
|
|
274
|
+
throw new UsageError("Pass one or more handles (JSON from --no-wait) or a report file path.");
|
|
275
|
+
}
|
|
276
|
+
const handles = [];
|
|
277
|
+
const isHandle = (value) => !!value &&
|
|
278
|
+
typeof value === "object" &&
|
|
279
|
+
typeof value.modalFunctionCallId === "string" &&
|
|
280
|
+
typeof value.checkKey === "string";
|
|
281
|
+
const collect = (value) => {
|
|
282
|
+
if (isHandle(value)) {
|
|
283
|
+
handles.push(value);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (value && typeof value === "object") {
|
|
287
|
+
for (const inner of Object.values(value))
|
|
288
|
+
collect(inner);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
for (const arg of raw) {
|
|
292
|
+
let parsed;
|
|
293
|
+
const trimmed = arg.trim();
|
|
294
|
+
if (trimmed.startsWith("{")) {
|
|
295
|
+
try {
|
|
296
|
+
parsed = JSON.parse(trimmed);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
throw new UsageError(`Not a valid handle JSON: ${truncate(trimmed, 60)}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
try {
|
|
304
|
+
parsed = JSON.parse(await readFile(trimmed, "utf-8"));
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
throw new UsageError(`Could not read handles from ${trimmed}: not a handle and not a JSON file.`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const before = handles.length;
|
|
311
|
+
collect(parsed);
|
|
312
|
+
if (handles.length === before) {
|
|
313
|
+
throw new UsageError(`No handles found in ${truncate(trimmed, 60)}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return handles;
|
|
317
|
+
}
|
|
318
|
+
const list = defineCommand({
|
|
319
|
+
meta: { name: "list", description: "List the checks a quest offers for ad-hoc runs" },
|
|
320
|
+
args: {
|
|
321
|
+
...questArg,
|
|
322
|
+
json: { type: "boolean", description: "Output as JSON" },
|
|
323
|
+
},
|
|
324
|
+
run: async ({ args }) => {
|
|
325
|
+
const api = await connectQuest(args.quest);
|
|
326
|
+
const catalog = await api.listChecks();
|
|
327
|
+
if (args.json) {
|
|
328
|
+
printJson(catalog);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
console.log(`\n Checks for ${catalog.quest}\n`);
|
|
332
|
+
printTable(catalog.checks.map((check) => ({
|
|
333
|
+
key: check.key,
|
|
334
|
+
label: check.label,
|
|
335
|
+
kind: check.kind,
|
|
336
|
+
requires: check.requires.join(", "),
|
|
337
|
+
steps: formatSteps(check),
|
|
338
|
+
})), [
|
|
339
|
+
{ key: "key", label: "Key" },
|
|
340
|
+
{ key: "label", label: "Label" },
|
|
341
|
+
{ key: "kind", label: "Kind" },
|
|
342
|
+
{ key: "requires", label: "Requires" },
|
|
343
|
+
{ key: "steps", label: "Steps" },
|
|
344
|
+
]);
|
|
345
|
+
const notes = catalog.checks.filter((c) => c.notes);
|
|
346
|
+
if (notes.length > 0) {
|
|
347
|
+
console.log("\n Notes:");
|
|
348
|
+
for (const check of notes)
|
|
349
|
+
console.log(` ${check.key}: ${check.notes}`);
|
|
350
|
+
}
|
|
351
|
+
console.log("");
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
const run = defineCommand({
|
|
355
|
+
meta: {
|
|
356
|
+
name: "run",
|
|
357
|
+
description: "Run checks on a Harbor task directory (stateless or --create) or on a task (--task)",
|
|
358
|
+
},
|
|
359
|
+
args: {
|
|
360
|
+
dir: { type: "positional", description: "Harbor task directory", required: false },
|
|
361
|
+
...questArg,
|
|
362
|
+
task: { type: "string", description: "Run live on an existing task (problem id)" },
|
|
363
|
+
create: {
|
|
364
|
+
type: "boolean",
|
|
365
|
+
description: "Turn the Harbor directory into a real draft under your account, then run live",
|
|
366
|
+
default: false,
|
|
367
|
+
},
|
|
368
|
+
check: { type: "string", description: "Comma-separated check keys (see `checks list`)" },
|
|
369
|
+
all: {
|
|
370
|
+
type: "boolean",
|
|
371
|
+
description: "Run every check; ineligible ones are skipped with the reason",
|
|
372
|
+
},
|
|
373
|
+
"dry-run": {
|
|
374
|
+
type: "boolean",
|
|
375
|
+
description: "Adapt the bundle, print eligibility (and the draft for --create), start nothing",
|
|
376
|
+
},
|
|
377
|
+
wait: {
|
|
378
|
+
type: "boolean",
|
|
379
|
+
description: "Wait for results; --no-wait prints handles as JSON lines and exits",
|
|
380
|
+
default: true,
|
|
381
|
+
},
|
|
382
|
+
json: { type: "boolean", description: "Print the report as JSON" },
|
|
383
|
+
out: { type: "string", description: "Write the JSON report to this file" },
|
|
384
|
+
repo: { type: "string", description: "Override metadata.repository_url" },
|
|
385
|
+
commit: { type: "string", description: "Override metadata.base_commit_hash" },
|
|
386
|
+
title: { type: "string", description: "Override metadata.problem_title" },
|
|
387
|
+
category: { type: "string", description: "Override metadata.category" },
|
|
388
|
+
language: { type: "string", description: "Override metadata.language" },
|
|
389
|
+
difficulty: { type: "string", description: "Override metadata.difficulty" },
|
|
390
|
+
model: { type: "string", description: "Agent model override where the check honors it" },
|
|
391
|
+
effort: {
|
|
392
|
+
type: "enum",
|
|
393
|
+
description: "Reasoning effort override where the check honors it",
|
|
394
|
+
options: ["low", "medium", "high", "xhigh"],
|
|
395
|
+
},
|
|
396
|
+
poll: { type: "string", description: "Poll interval in seconds (default 15)" },
|
|
397
|
+
timeout: { type: "string", description: "Per-step ceiling in minutes (default 180)" },
|
|
398
|
+
},
|
|
399
|
+
run: async ({ args }) => {
|
|
400
|
+
const dir = args.dir;
|
|
401
|
+
if (!dir && !args.task)
|
|
402
|
+
throw new UsageError("Pass a Harbor directory or --task <problemId>.");
|
|
403
|
+
if (dir && args.task)
|
|
404
|
+
throw new UsageError("Pass either a Harbor directory or --task, not both.");
|
|
405
|
+
if (args.create && !dir)
|
|
406
|
+
throw new UsageError("--create needs a Harbor directory.");
|
|
407
|
+
if (args.check && args.all)
|
|
408
|
+
throw new UsageError("Pass either --check or --all, not both.");
|
|
409
|
+
if (!args.check && !args.all)
|
|
410
|
+
throw new UsageError("Pass --check <key[,key...]> or --all.");
|
|
411
|
+
const pollMs = parsePositiveNumber(args.poll, DEFAULT_POLL_MS / 1000, "poll") * 1000;
|
|
412
|
+
const maxStepMs = parsePositiveNumber(args.timeout, DEFAULT_MAX_STEP_MS / 60_000, "timeout") * 60_000;
|
|
413
|
+
const options = {};
|
|
414
|
+
if (args.model)
|
|
415
|
+
options.agentModel = args.model;
|
|
416
|
+
if (args.effort)
|
|
417
|
+
options.reasoningEffort = args.effort;
|
|
418
|
+
// Bundle errors exit 2 before any network call.
|
|
419
|
+
const overrides = {
|
|
420
|
+
repo: args.repo,
|
|
421
|
+
commit: args.commit,
|
|
422
|
+
title: args.title,
|
|
423
|
+
category: args.category,
|
|
424
|
+
language: args.language,
|
|
425
|
+
difficulty: args.difficulty,
|
|
426
|
+
};
|
|
427
|
+
const bundle = dir ? await readHarborBundle(dir, overrides) : null;
|
|
428
|
+
const api = await connectQuest(args.quest);
|
|
429
|
+
const catalog = await api.listChecks();
|
|
430
|
+
const { checks: selected, selection } = selectChecks(catalog, args.check, Boolean(args.all));
|
|
431
|
+
const mode = args.task ? "task" : args.create ? "harbor-create" : "harbor";
|
|
432
|
+
const report = {
|
|
433
|
+
quest: catalog.quest,
|
|
434
|
+
mode,
|
|
435
|
+
selection,
|
|
436
|
+
source: {},
|
|
437
|
+
generatedAt: new Date().toISOString(),
|
|
438
|
+
checks: {},
|
|
439
|
+
prechecks: {},
|
|
440
|
+
};
|
|
441
|
+
let runnable = selected;
|
|
442
|
+
if (bundle) {
|
|
443
|
+
report.source = { dir: bundle.dir, inputHash: bundle.inputHash };
|
|
444
|
+
const adapt = await api.adapt(bundle);
|
|
445
|
+
if (args["dry-run"]) {
|
|
446
|
+
printDryRun(report, bundle, adapt, selected, Boolean(args.json), Boolean(args.create));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
runnable = [];
|
|
450
|
+
for (const check of selected) {
|
|
451
|
+
const entry = adapt.checks[check.key];
|
|
452
|
+
if (entry && !entry.eligible) {
|
|
453
|
+
report.checks[check.key] = skippedResult(check, `missing: ${entry.missing.join(", ")}`);
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
runnable.push(check);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
let target;
|
|
461
|
+
if (args.task) {
|
|
462
|
+
const task = await api.resolveTask(args.task);
|
|
463
|
+
if (!task)
|
|
464
|
+
throw new UsageError(`Task not found or not readable: ${args.task}`);
|
|
465
|
+
if (args["dry-run"]) {
|
|
466
|
+
if (args.json) {
|
|
467
|
+
printJson({
|
|
468
|
+
...report,
|
|
469
|
+
source: { ...task },
|
|
470
|
+
dryRun: true,
|
|
471
|
+
checks: selected.map((c) => c.key),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
for (const line of dryRunTaskLines(task, selected))
|
|
476
|
+
console.log(` ${line}`);
|
|
477
|
+
console.log("");
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
report.source = {
|
|
482
|
+
problemId: task.problemId,
|
|
483
|
+
versionId: task.versionId,
|
|
484
|
+
title: task.title,
|
|
485
|
+
url: task.url,
|
|
486
|
+
};
|
|
487
|
+
warnLive(`checks run live on task ${task.problemId} ("${task.title}"); its visible results are re-pointed.`);
|
|
488
|
+
report.taskUrl = task.url;
|
|
489
|
+
target = { kind: "live", task };
|
|
490
|
+
}
|
|
491
|
+
else if (args.create && bundle) {
|
|
492
|
+
warnLive(`creating a draft task from ${bundle.dir} under your account, then running checks live on it.`);
|
|
493
|
+
const task = await api.createFromHarbor(bundle);
|
|
494
|
+
console.error(` Created draft ${task.problemId}: ${task.url}\n`);
|
|
495
|
+
report.taskUrl = task.url;
|
|
496
|
+
report.source = {
|
|
497
|
+
...report.source,
|
|
498
|
+
problemId: task.problemId,
|
|
499
|
+
versionId: task.versionId,
|
|
500
|
+
title: task.title,
|
|
501
|
+
url: task.url,
|
|
502
|
+
};
|
|
503
|
+
target = { kind: "live", task };
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
target = { kind: "harbor", bundle: bundle };
|
|
507
|
+
}
|
|
508
|
+
if (!args.wait) {
|
|
509
|
+
// Checks `adapt` marked ineligible never start; with an explicit
|
|
510
|
+
// --check list they still count as a failure, like in the waited run.
|
|
511
|
+
const skipped = Object.values(report.checks).filter((c) => c.status === "skipped");
|
|
512
|
+
for (const check of skipped) {
|
|
513
|
+
console.log(JSON.stringify({ checkKey: check.key, kind: "skipped", reason: check.reason }));
|
|
514
|
+
}
|
|
515
|
+
const startExit = await startWithoutWaiting(api, target, runnable, options);
|
|
516
|
+
const skipExit = skipped.length > 0 && selection === "explicit" ? EXIT_CHECK_FAILED : 0;
|
|
517
|
+
process.exitCode = Math.max(startExit, skipExit);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (!args.json) {
|
|
521
|
+
console.error(` Quest: ${report.quest} (${modeLabel(mode)})`);
|
|
522
|
+
if (target.kind === "live") {
|
|
523
|
+
console.error(` Task: ${target.task.title} — ${target.task.url}`);
|
|
524
|
+
}
|
|
525
|
+
console.error(` Running ${runnable.length} check${runnable.length === 1 ? "" : "s"}: ${runnable.map((c) => c.key).join(", ")}\n`);
|
|
526
|
+
}
|
|
527
|
+
const controller = new AbortController();
|
|
528
|
+
const interrupt = { mode: "cancel" };
|
|
529
|
+
const restore = installInterruptHandler(controller, interrupt);
|
|
530
|
+
const controls = {
|
|
531
|
+
pollMs,
|
|
532
|
+
maxStepMs,
|
|
533
|
+
signal: controller.signal,
|
|
534
|
+
onEvent: progressPrinter(),
|
|
535
|
+
onAbort: () => interrupt.mode,
|
|
536
|
+
};
|
|
537
|
+
let results;
|
|
538
|
+
try {
|
|
539
|
+
results = await runChecks(api, target, runnable, options, controls);
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
restore();
|
|
543
|
+
}
|
|
544
|
+
for (const result of results) {
|
|
545
|
+
report.checks[result.key] = result;
|
|
546
|
+
if (isPrecheckKey(result.key) && result.output && typeof result.output === "object") {
|
|
547
|
+
report.prechecks[precheckStageId(result.key)] = result.output;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (args.json) {
|
|
551
|
+
printJson(report);
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
console.log(`\n${renderTable(report, { color: color() })}\n`);
|
|
555
|
+
const failed = Object.values(report.checks).filter((c) => c.error);
|
|
556
|
+
for (const check of failed) {
|
|
557
|
+
console.log(` ${check.key}: ${check.error}`);
|
|
558
|
+
}
|
|
559
|
+
if (failed.length > 0)
|
|
560
|
+
console.log("");
|
|
561
|
+
if (target.kind === "live") {
|
|
562
|
+
console.log(` Task page (results live there): ${target.task.url}\n`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (args.out) {
|
|
566
|
+
const path = await writeReport(report, args.out);
|
|
567
|
+
console.error(` Report written to ${path}`);
|
|
568
|
+
if (target.kind === "live")
|
|
569
|
+
console.error(` Task page: ${target.task.url}`);
|
|
570
|
+
}
|
|
571
|
+
process.exitCode = exitCodeFor(report);
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
const status = defineCommand({
|
|
575
|
+
meta: { name: "status", description: "Poll handles printed by `checks run --no-wait`" },
|
|
576
|
+
args: {
|
|
577
|
+
handles: { type: "positional", description: "Handle JSON or a report file", required: false },
|
|
578
|
+
...questArg,
|
|
579
|
+
json: { type: "boolean", description: "Output as JSON lines" },
|
|
580
|
+
},
|
|
581
|
+
run: async ({ args }) => {
|
|
582
|
+
const handles = await readHandles(args._);
|
|
583
|
+
const api = await connectQuest(args.quest);
|
|
584
|
+
const rows = [];
|
|
585
|
+
for (const handle of handles) {
|
|
586
|
+
try {
|
|
587
|
+
const step = await api.getStep(handle);
|
|
588
|
+
if (args.json) {
|
|
589
|
+
console.log(JSON.stringify({ handle, ...step }));
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
rows.push({
|
|
593
|
+
handle,
|
|
594
|
+
status: step.status,
|
|
595
|
+
verdict: step.status === "running" ? undefined : step.verdict,
|
|
596
|
+
detail: step.status === "failed" ? step.error : undefined,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
601
|
+
if (args.json) {
|
|
602
|
+
console.log(JSON.stringify({ handle, status: "error", error: message }));
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
rows.push({ handle, status: "error", detail: message });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (!args.json) {
|
|
609
|
+
const c = color();
|
|
610
|
+
printTable(rows.map((row) => ({
|
|
611
|
+
step: `${row.handle.checkKey}/${row.handle.stepId}`,
|
|
612
|
+
call: row.handle.modalFunctionCallId,
|
|
613
|
+
status: statusBadge(row.status, c),
|
|
614
|
+
verdict: row.verdict ?? "",
|
|
615
|
+
detail: truncate(row.detail ?? "", 60),
|
|
616
|
+
})), [
|
|
617
|
+
{ key: "step", label: "Step" },
|
|
618
|
+
{ key: "call", label: "Call" },
|
|
619
|
+
{ key: "status", label: "Status" },
|
|
620
|
+
{ key: "verdict", label: "Verdict" },
|
|
621
|
+
{ key: "detail", label: "Detail" },
|
|
622
|
+
]);
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
const cancel = defineCommand({
|
|
627
|
+
meta: { name: "cancel", description: "Cancel handles printed by `checks run --no-wait`" },
|
|
628
|
+
args: {
|
|
629
|
+
handles: { type: "positional", description: "Handle JSON or a report file", required: false },
|
|
630
|
+
...questArg,
|
|
631
|
+
json: { type: "boolean", description: "Output as JSON lines" },
|
|
632
|
+
},
|
|
633
|
+
run: async ({ args }) => {
|
|
634
|
+
const handles = await readHandles(args._);
|
|
635
|
+
const api = await connectQuest(args.quest);
|
|
636
|
+
let failures = 0;
|
|
637
|
+
for (const handle of handles) {
|
|
638
|
+
const label = `${handle.checkKey}/${handle.stepId} (${handle.modalFunctionCallId})`;
|
|
639
|
+
try {
|
|
640
|
+
const result = await api.cancelStep(handle);
|
|
641
|
+
if (args.json) {
|
|
642
|
+
console.log(JSON.stringify({ handle, ...result }));
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
console.log(` ${result.cancelled ? "cancelled" : "not cancelled"} ${label}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
failures += 1;
|
|
650
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
651
|
+
if (args.json) {
|
|
652
|
+
console.log(JSON.stringify({ handle, cancelled: false, error: message }));
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
console.log(` error ${label}: ${message}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
if (failures > 0)
|
|
660
|
+
process.exitCode = EXIT_CHECK_FAILED;
|
|
661
|
+
},
|
|
662
|
+
});
|
|
663
|
+
export default defineCommand({
|
|
664
|
+
meta: {
|
|
665
|
+
name: "checks",
|
|
666
|
+
description: "List and run quest checks on Harbor tasks or existing tasks",
|
|
667
|
+
},
|
|
668
|
+
subCommands: { list, run, status, cancel },
|
|
669
|
+
});
|