@skillcdn/console 0.1.5 → 0.1.7

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.
Files changed (89) hide show
  1. package/README.md +50 -2
  2. package/SKILLCDN.md +29 -0
  3. package/dist/cli/cli.d.ts +3 -0
  4. package/dist/cli/cli.d.ts.map +1 -1
  5. package/dist/cli/cli.js +61 -0
  6. package/dist/cli/cli.js.map +1 -1
  7. package/dist/cli/help.d.ts.map +1 -1
  8. package/dist/cli/help.js +5 -0
  9. package/dist/cli/help.js.map +1 -1
  10. package/dist/cli/main.js +2 -0
  11. package/dist/cli/main.js.map +1 -1
  12. package/dist/cli/serve.d.ts +37 -0
  13. package/dist/cli/serve.d.ts.map +1 -0
  14. package/dist/cli/serve.js +218 -0
  15. package/dist/cli/serve.js.map +1 -0
  16. package/dist/console.css +33 -10
  17. package/dist/console.d.ts +6 -0
  18. package/dist/console.d.ts.map +1 -1
  19. package/dist/console.js +78 -13
  20. package/dist/console.js.map +1 -1
  21. package/dist/data.d.ts.map +1 -1
  22. package/dist/data.js +5 -1
  23. package/dist/data.js.map +1 -1
  24. package/dist/i18n/en.d.ts +4 -0
  25. package/dist/i18n/en.d.ts.map +1 -1
  26. package/dist/i18n/en.js +4 -0
  27. package/dist/i18n/en.js.map +1 -1
  28. package/dist/i18n/ko.d.ts.map +1 -1
  29. package/dist/i18n/ko.js +4 -0
  30. package/dist/i18n/ko.js.map +1 -1
  31. package/dist/schemas.d.ts +1 -0
  32. package/dist/schemas.d.ts.map +1 -1
  33. package/dist/schemas.js +5 -0
  34. package/dist/schemas.js.map +1 -1
  35. package/dist/web.d.ts +16 -0
  36. package/dist/web.d.ts.map +1 -0
  37. package/dist/web.js +149 -0
  38. package/dist/web.js.map +1 -0
  39. package/docs/specs/cli.md +92 -0
  40. package/docs/specs/rest.md +220 -0
  41. package/package.json +14 -3
  42. package/skills/building-a-console/SKILL.md +79 -0
  43. package/skills/working-the-board/SKILL.md +78 -0
  44. package/src/api.ts +235 -0
  45. package/src/cli/cli.ts +1114 -0
  46. package/src/cli/credentials.ts +68 -0
  47. package/src/cli/directory.ts +59 -0
  48. package/src/cli/format.ts +284 -0
  49. package/src/cli/help.ts +326 -0
  50. package/src/cli/main.ts +39 -0
  51. package/src/cli/serve.ts +276 -0
  52. package/src/cli/terminal.ts +28 -0
  53. package/src/client.ts +533 -0
  54. package/src/components/board.tsx +112 -0
  55. package/src/components/connect.tsx +126 -0
  56. package/src/components/decision-form.tsx +136 -0
  57. package/src/components/decision-list.tsx +278 -0
  58. package/src/components/documents.tsx +569 -0
  59. package/src/components/event-feed.tsx +129 -0
  60. package/src/components/markdown.tsx +72 -0
  61. package/src/components/people.tsx +59 -0
  62. package/src/components/projects.tsx +364 -0
  63. package/src/components/runs.tsx +175 -0
  64. package/src/components/shell.tsx +284 -0
  65. package/src/components/sign-in.tsx +61 -0
  66. package/src/components/skills.tsx +78 -0
  67. package/src/components/task-form.tsx +229 -0
  68. package/src/components/task-view.tsx +272 -0
  69. package/src/components/tokens.tsx +223 -0
  70. package/src/components/ui.tsx +219 -0
  71. package/src/connect-page.tsx +148 -0
  72. package/src/connect.ts +27 -0
  73. package/src/console.tsx +1166 -0
  74. package/src/data.ts +493 -0
  75. package/src/docs-page.tsx +334 -0
  76. package/src/documents.ts +76 -0
  77. package/src/i18n/en.ts +537 -0
  78. package/src/i18n/index.ts +76 -0
  79. package/src/i18n/ko.ts +511 -0
  80. package/src/index.ts +142 -0
  81. package/src/limits.ts +61 -0
  82. package/src/router.ts +295 -0
  83. package/src/routes.ts +106 -0
  84. package/src/schemas.ts +776 -0
  85. package/src/styles/console.css +2432 -0
  86. package/src/text.ts +33 -0
  87. package/src/use-action.ts +37 -0
  88. package/src/vocabulary.ts +109 -0
  89. package/src/web.ts +170 -0
package/src/cli/cli.ts ADDED
@@ -0,0 +1,1114 @@
1
+ import { basename, extname } from "node:path";
2
+ import { parseArgs } from "node:util";
3
+ import {
4
+ ApiError,
5
+ type ConsoleClient,
6
+ createClient,
7
+ type FetchLike,
8
+ type ProjectClient,
9
+ } from "../client.js";
10
+ import { MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
11
+ import type { RestClaim, RestDecision } from "../schemas.js";
12
+ import { TASK_PRIORITIES, TASK_STATES, type TaskPriority, type TaskState } from "../vocabulary.js";
13
+ import { loadWebRoot, type WebRoot, WebRootError } from "../web.js";
14
+ import type { CredentialStore } from "./credentials.js";
15
+ import type { DirectoryStore } from "./directory.js";
16
+ import {
17
+ formatAnswer,
18
+ formatDecision,
19
+ formatDecisionLine,
20
+ formatDocument,
21
+ formatDocuments,
22
+ formatOptions,
23
+ formatProjectLine,
24
+ formatRun,
25
+ formatRunLine,
26
+ formatSkills,
27
+ formatTask,
28
+ formatTaskLine,
29
+ formatVersion,
30
+ formatVersionLine,
31
+ } from "./format.js";
32
+ import { COMMAND_HELP, helpFor, usage } from "./help.js";
33
+ import { createServeHandler, DEFAULT_SERVE_PORT, type Listen } from "./serve.js";
34
+
35
+ // The `console` command (docs/specs/cli.md): the agent's side of the console. A thin client of
36
+ // the REST API with no logic of its own: every command is one or two requests and the words to
37
+ // say what came back. It takes what it needs from `CliIo`, so that tests run it whole, against
38
+ // the app and no socket, with a store and readers of their own. The board is a project's: every
39
+ // command on it works in the project the flag, the environment or the working directory names.
40
+
41
+ /** The exit codes of the command, for whoever runs it: a shell, a hook, an agent. */
42
+ export const EXIT = {
43
+ ok: 0,
44
+ /** The console refused, or could not be reached; the reason is on stderr. */
45
+ failed: 1,
46
+ /** The command was not understood. */
47
+ usage: 2,
48
+ /** The decision still waits for a person. */
49
+ waiting: 3,
50
+ } as const;
51
+
52
+ /** How long one request for a decision's answer asks the console to hold it: what the server allows. */
53
+ const WAIT_STEP_SECONDS = 50;
54
+ /** A breath between two such requests, should the console answer at once. */
55
+ const WAIT_PAUSE_MS = 500;
56
+ /** How long `ask` waits when nothing is said: within what an agent's shell gives a command. */
57
+ const DEFAULT_ASK_WAIT_SECONDS = 60;
58
+ /** The longest wait a person may ask for: a day. */
59
+ const MAX_WAIT_SECONDS = 86_400;
60
+
61
+ export interface CliIo {
62
+ readonly env: Readonly<Record<string, string | undefined>>;
63
+ readonly stdout: (text: string) => void;
64
+ readonly stderr: (text: string) => void;
65
+ readonly fetch: FetchLike;
66
+ /** All of standard input, for a body given as `-`, or a token given to login. */
67
+ readonly readStdin: () => Promise<string>;
68
+ /** The name of the machine, for the agent to say where it runs; nothing when unknown. */
69
+ readonly hostname?: string | undefined;
70
+ /** A file named with `--file`, as text. */
71
+ readonly readFile: (path: string) => Promise<string>;
72
+ /** A file handed in, as bytes. */
73
+ readonly readBytes: (path: string) => Promise<Uint8Array>;
74
+ readonly store: CredentialStore;
75
+ /** What the working directory says of the project, and the way to write it. */
76
+ readonly directory: DirectoryStore;
77
+ readonly sleep: (ms: number) => Promise<void>;
78
+ readonly now: () => number;
79
+ /** The version of the package the command runs from, when the entry point knows it. */
80
+ readonly version?: string | undefined;
81
+ /** How `serve` listens on the loopback (ADR-0014); left out, the command cannot serve. */
82
+ readonly listen?: Listen | undefined;
83
+ }
84
+
85
+ /** The command stops with an exit code and a word on stderr. */
86
+ class CliExit extends Error {
87
+ readonly exit: number;
88
+
89
+ constructor(exit: number, message: string) {
90
+ super(message);
91
+ this.name = "CliExit";
92
+ this.exit = exit;
93
+ }
94
+ }
95
+
96
+ const failed = (message: string): CliExit => new CliExit(EXIT.failed, message);
97
+ const misuse = (message: string): CliExit => new CliExit(EXIT.usage, message);
98
+
99
+ type OptionSpec = Readonly<
100
+ Record<string, { readonly type: "string" | "boolean"; readonly multiple?: boolean }>
101
+ >;
102
+
103
+ const GLOBAL_OPTIONS: OptionSpec = {
104
+ json: { type: "boolean" },
105
+ url: { type: "string" },
106
+ project: { type: "string" },
107
+ };
108
+
109
+ function parse(args: readonly string[], options: OptionSpec, allowPositionals = true) {
110
+ try {
111
+ return parseArgs({
112
+ args: [...args],
113
+ options: { ...GLOBAL_OPTIONS, ...options },
114
+ allowPositionals,
115
+ strict: true,
116
+ });
117
+ } catch (error) {
118
+ throw misuse(error instanceof Error ? error.message : "The arguments were not understood.");
119
+ }
120
+ }
121
+
122
+ type Values = ReturnType<typeof parse>["values"];
123
+
124
+ const text = (values: Values, name: string): string | undefined => {
125
+ const value = values[name];
126
+ return typeof value === "string" ? value : undefined;
127
+ };
128
+ const on = (values: Values, name: string): boolean => values[name] === true;
129
+ const texts = (values: Values, name: string): string[] => {
130
+ const value = values[name];
131
+ if (Array.isArray(value)) {
132
+ return value.filter((item): item is string => typeof item === "string");
133
+ }
134
+ return typeof value === "string" ? [value] : [];
135
+ };
136
+
137
+ interface Session {
138
+ readonly client: ConsoleClient;
139
+ readonly url: string;
140
+ /** The token the client holds: what `serve` carries the page's requests with. */
141
+ readonly token: string;
142
+ readonly json: boolean;
143
+ /** The project the command works in: named by `--project`, the environment, or the working directory. */
144
+ project(): Promise<ProjectClient>;
145
+ /** The key of that project, or nothing when none is named. */
146
+ projectKey(): Promise<string | undefined>;
147
+ }
148
+
149
+ const NO_PROJECT =
150
+ "Say which project: --project <key>, CONSOLE_PROJECT in the environment, or console use <key> once in this directory. console projects lists the projects you may work in.";
151
+
152
+ /** The console and the token to talk to it with: the flags, the environment, then what login kept. */
153
+ async function open(io: CliIo, values: Values): Promise<Session> {
154
+ const stored = await io.store.load();
155
+ const url = text(values, "url") ?? io.env.CONSOLE_URL ?? stored?.url;
156
+ const token = io.env.CONSOLE_TOKEN ?? stored?.token;
157
+ if (url === undefined || token === undefined) {
158
+ throw failed(
159
+ "Not signed in. A person signs the command in: console login --url <the console's origin>",
160
+ );
161
+ }
162
+ const client = createClient({ baseUrl: url, token, fetch: io.fetch });
163
+ const projectKey = async () =>
164
+ text(values, "project") ?? io.env.CONSOLE_PROJECT ?? (await io.directory.load())?.project;
165
+ return {
166
+ client,
167
+ url,
168
+ token,
169
+ json: on(values, "json"),
170
+ projectKey,
171
+ async project() {
172
+ const key = await projectKey();
173
+ if (key === undefined) {
174
+ throw failed(NO_PROJECT);
175
+ }
176
+ return client.project(key);
177
+ },
178
+ };
179
+ }
180
+
181
+ /** Prints what came back: the console's JSON on `--json`, the words otherwise. */
182
+ function answer(io: CliIo, session: Session, value: unknown, words: () => string): void {
183
+ io.stdout(session.json ? `${JSON.stringify(value, null, 2)}\n` : `${words()}\n`);
184
+ }
185
+
186
+ /** The run a command on a run means: the one named, else the one open run of this token in the project. */
187
+ async function currentRun(project: ProjectClient, values: Values): Promise<string> {
188
+ const given = text(values, "run");
189
+ if (given !== undefined) {
190
+ return given;
191
+ }
192
+ const { items } = await project.runs({ open: true, mine: true });
193
+ const [only] = items;
194
+ if (only !== undefined && items.length === 1) {
195
+ return only.id;
196
+ }
197
+ if (items.length === 0) {
198
+ throw failed(
199
+ "No run of yours is open in this project. Take a task first: console take <number>",
200
+ );
201
+ }
202
+ throw failed(
203
+ `Several runs of yours are open; say which with --run <id>:\n${items.map((run) => ` ${formatRunLine(run)}`).join("\n")}`,
204
+ );
205
+ }
206
+
207
+ /** A Markdown body: from `--file`, from standard input for `-`, or as given. */
208
+ async function bodyOf(
209
+ io: CliIo,
210
+ values: Values,
211
+ given: string | undefined,
212
+ ): Promise<string | undefined> {
213
+ const file = text(values, "file");
214
+ if (file !== undefined) {
215
+ return io.readFile(file);
216
+ }
217
+ return given === "-" ? io.readStdin() : given;
218
+ }
219
+
220
+ function originOf(value: string): string {
221
+ const url = URL.canParse(value) ? new URL(value) : undefined;
222
+ if (
223
+ url === undefined ||
224
+ !/^https?:$/.test(url.protocol) ||
225
+ url.pathname !== "/" ||
226
+ url.search !== "" ||
227
+ url.hash !== ""
228
+ ) {
229
+ throw misuse(
230
+ `Not an origin: ${value}. Say the console's address without a path, such as https://console.example.com`,
231
+ );
232
+ }
233
+ return url.origin;
234
+ }
235
+
236
+ function stateOf(value: string | undefined): TaskState | undefined {
237
+ if (value === undefined) {
238
+ return undefined;
239
+ }
240
+ const found = TASK_STATES.find((state) => state === value);
241
+ if (found === undefined) {
242
+ throw misuse(`Not a state: ${value}. One of ${TASK_STATES.join(", ")}`);
243
+ }
244
+ return found;
245
+ }
246
+
247
+ function priorityOf(value: string | undefined): TaskPriority | undefined {
248
+ if (value === undefined) {
249
+ return undefined;
250
+ }
251
+ const found = TASK_PRIORITIES.find((priority) => priority === value);
252
+ if (found === undefined) {
253
+ throw misuse(`Not a priority: ${value}. One of ${TASK_PRIORITIES.join(", ")}`);
254
+ }
255
+ return found;
256
+ }
257
+
258
+ function secondsOf(value: string | undefined, fallback: number): number {
259
+ if (value === undefined) {
260
+ return fallback;
261
+ }
262
+ const seconds = Number(value);
263
+ if (!Number.isInteger(seconds) || seconds < 0 || seconds > MAX_WAIT_SECONDS) {
264
+ throw misuse(`Not a number of seconds to wait: ${value}`);
265
+ }
266
+ return seconds;
267
+ }
268
+
269
+ /** One positional, and nothing else. */
270
+ function theOne(positionals: readonly string[], ask: string): string {
271
+ const [first] = positionals;
272
+ if (first === undefined || positionals.length > 1) {
273
+ throw misuse(ask);
274
+ }
275
+ return first;
276
+ }
277
+
278
+ /**
279
+ * The decision once answered, or as it stands when the wait is over. Each request asks the
280
+ * console to hold it for a while, so the answer arrives as soon as a person gives it.
281
+ */
282
+ async function waitForAnswer(
283
+ io: CliIo,
284
+ project: ProjectClient,
285
+ decision: RestDecision,
286
+ seconds: number,
287
+ ): Promise<RestDecision> {
288
+ const deadline = io.now() + seconds * 1000;
289
+ let current = decision;
290
+ for (;;) {
291
+ const left = deadline - io.now();
292
+ if (current.answer !== null || left <= 0) {
293
+ return current;
294
+ }
295
+ current = await project.awaitDecision(
296
+ current.id,
297
+ Math.max(1, Math.min(Math.ceil(left / 1000), WAIT_STEP_SECONDS)),
298
+ );
299
+ if (current.answer === null) {
300
+ await io.sleep(WAIT_PAUSE_MS);
301
+ }
302
+ }
303
+ }
304
+
305
+ const stillWaiting = (id: string): string =>
306
+ `Decision ${id} still waits for a person; the run waits with it. Keep waiting with: console decision ${id} --wait 100, as often as needed. Meanwhile, go on only with work that does not depend on the answer.`;
307
+
308
+ /**
309
+ * Waits for the answer and says what it is, or that it is still to come. On `--json`, a decision
310
+ * printed already as it was raised (`shown`) is printed again only with its answer.
311
+ */
312
+ async function settle(
313
+ io: CliIo,
314
+ session: Session,
315
+ project: ProjectClient,
316
+ decision: RestDecision,
317
+ seconds: number,
318
+ shown = false,
319
+ ): Promise<number> {
320
+ const outcome =
321
+ decision.answer === null && seconds > 0
322
+ ? await waitForAnswer(io, project, decision, seconds)
323
+ : decision;
324
+ if (session.json) {
325
+ if (!shown || outcome.answer !== null) {
326
+ io.stdout(`${JSON.stringify(outcome, null, 2)}\n`);
327
+ }
328
+ } else {
329
+ io.stdout(`${outcome.answer === null ? stillWaiting(outcome.id) : formatAnswer(outcome)}\n`);
330
+ }
331
+ return outcome.answer === null ? EXIT.waiting : EXIT.ok;
332
+ }
333
+
334
+ type Command = (args: readonly string[], io: CliIo) => Promise<number>;
335
+
336
+ /** Checks a token against the console, keeps it with the address, and says who it is. */
337
+ async function keep(io: CliIo, url: string, token: string, agent?: string): Promise<number> {
338
+ const me = await createClient({ baseUrl: url, token, fetch: io.fetch }).me();
339
+ const person = me.person;
340
+ if (person === null) {
341
+ throw failed(
342
+ "The console does not know this token. Make one on your Agents page and try again.",
343
+ );
344
+ }
345
+ const kept = await io.store.save({ url, token });
346
+ io.stdout(
347
+ `${agent === undefined ? "Signed in" : `Connected as "${agent}"`} to ${me.workspace.name} at ${url} as ${person.login} (${person.role}). The token is kept in ${kept}.\nNext, say which project this directory works in: console use <key> (console projects lists them).\n`,
348
+ );
349
+ return EXIT.ok;
350
+ }
351
+
352
+ const login: Command = async (args, io) => {
353
+ const { values } = parse(
354
+ args,
355
+ { "token-stdin": { type: "boolean" }, agent: { type: "string" } },
356
+ false,
357
+ );
358
+ const stored = await io.store.load();
359
+ const wanted = text(values, "url") ?? io.env.CONSOLE_URL ?? stored?.url;
360
+ if (wanted === undefined) {
361
+ throw misuse("Say where the console is: console login --url https://console.example.com");
362
+ }
363
+ const url = originOf(wanted);
364
+ if (on(values, "token-stdin")) {
365
+ const token = (await io.readStdin()).trim();
366
+ if (token.length === 0) {
367
+ throw failed("No token was given.");
368
+ }
369
+ return keep(io, url, token);
370
+ }
371
+ // A connection (ADR-0011): a code a person approves on the console's own pages, and the token
372
+ // handed over here once, never shown.
373
+ const client = createClient({ baseUrl: url, fetch: io.fetch });
374
+ const agent = text(values, "agent") ?? "The console command";
375
+ const begun = await client.connect({
376
+ agent: io.hostname === undefined ? agent : `${agent} on ${io.hostname}`,
377
+ });
378
+ io.stdout(
379
+ `To connect this agent, a person opens ${begun.url} signed in, checks that the page shows the code ${begun.code}, names the agent and approves.\nWaiting for that, until ${begun.expiresAt}.\n`,
380
+ );
381
+ for (;;) {
382
+ await io.sleep(begun.interval * 1000);
383
+ let claim: RestClaim;
384
+ try {
385
+ claim = await client.claimConnection(begun.secret);
386
+ } catch (error) {
387
+ if (error instanceof ApiError && error.code === "connect.not_found") {
388
+ throw failed(
389
+ "The connection was not approved in time, or the person said it was not theirs. Run console login again for a new code.",
390
+ );
391
+ }
392
+ throw error;
393
+ }
394
+ if (claim.status === "connected") {
395
+ return keep(io, url, claim.secret, claim.token.name);
396
+ }
397
+ }
398
+ };
399
+
400
+ const logout: Command = async (args, io) => {
401
+ parse(args, {}, false);
402
+ await io.store.clear();
403
+ io.stdout("Signed out here. The token itself is removed on your Agents page.\n");
404
+ return EXIT.ok;
405
+ };
406
+
407
+ const whoami: Command = async (args, io) => {
408
+ const { values } = parse(args, {}, false);
409
+ const session = await open(io, values);
410
+ const me = await session.client.me();
411
+ const person = me.person;
412
+ if (person === null) {
413
+ throw failed("The console does not know this token. Sign in again: console login");
414
+ }
415
+ const key = await session.projectKey();
416
+ answer(
417
+ io,
418
+ session,
419
+ { ...me, project: key ?? null },
420
+ () =>
421
+ `${person.login} (${person.role}) at ${me.workspace.name}, ${session.url}${key === undefined ? "; no project named here" : `; project ${key}`}`,
422
+ );
423
+ return EXIT.ok;
424
+ };
425
+
426
+ const projects: Command = async (args, io) => {
427
+ const { values } = parse(args, {}, false);
428
+ const session = await open(io, values);
429
+ const found = await session.client.projects();
430
+ answer(io, session, found, () =>
431
+ found.items.length === 0
432
+ ? "No projects you may work in. A person makes one on the console's Projects page."
433
+ : found.items.map(formatProjectLine).join("\n"),
434
+ );
435
+ return EXIT.ok;
436
+ };
437
+
438
+ const use: Command = async (args, io) => {
439
+ const { values, positionals } = parse(args, {});
440
+ const key = theOne(positionals, "Say which project: console use <key>");
441
+ const session = await open(io, values);
442
+ const project = await session.client.project(key).get();
443
+ const kept = await io.directory.save({ project: project.key });
444
+ answer(
445
+ io,
446
+ session,
447
+ project,
448
+ () =>
449
+ `Working in ${project.name} (${project.key}) from this directory on: kept in ${kept}, which is meant to be committed.`,
450
+ );
451
+ return EXIT.ok;
452
+ };
453
+
454
+ const tasks: Command = async (args, io) => {
455
+ const { values } = parse(args, { state: { type: "string" } }, false);
456
+ const state = stateOf(text(values, "state"));
457
+ const session = await open(io, values);
458
+ const project = await session.project();
459
+ const found = await project.tasks(state === undefined ? {} : { state });
460
+ answer(io, session, found, () =>
461
+ found.items.length === 0 ? "No tasks." : found.items.map(formatTaskLine).join("\n"),
462
+ );
463
+ return EXIT.ok;
464
+ };
465
+
466
+ const skills: Command = async (args, io) => {
467
+ const { values } = parse(args, {}, false);
468
+ const session = await open(io, values);
469
+ const project = await session.project();
470
+ const found = await project.skills();
471
+ answer(io, session, found, () => formatSkills(found));
472
+ return EXIT.ok;
473
+ };
474
+
475
+ const newTask: Command = async (args, io) => {
476
+ const { values, positionals } = parse(args, {
477
+ body: { type: "string" },
478
+ file: { type: "string" },
479
+ state: { type: "string" },
480
+ priority: { type: "string" },
481
+ });
482
+ const title = theOne(positionals, 'Say the title: console task new "<title>"');
483
+ const state = stateOf(text(values, "state"));
484
+ const priority = priorityOf(text(values, "priority"));
485
+ const body = await bodyOf(io, values, text(values, "body"));
486
+ const session = await open(io, values);
487
+ const project = await session.project();
488
+ const made = await project.createTask({
489
+ title,
490
+ ...(body === undefined ? {} : { body }),
491
+ ...(state === undefined ? {} : { state }),
492
+ ...(priority === undefined ? {} : { priority }),
493
+ });
494
+ answer(io, session, made, () => `Task #${made.number} written: ${made.title}\nid: ${made.id}`);
495
+ return EXIT.ok;
496
+ };
497
+
498
+ const task: Command = async (args, io) => {
499
+ if (args[0] === "new") {
500
+ return newTask(args.slice(1), io);
501
+ }
502
+ const { values, positionals } = parse(args, {});
503
+ const ref = theOne(positionals, "Say which task: console task <number|id>");
504
+ const session = await open(io, values);
505
+ const project = await session.project();
506
+ const found = await project.task(ref);
507
+ const [decisions, runs] = await Promise.all([
508
+ project.decisions({ task: found.id }),
509
+ project.runs({ task: found.id }),
510
+ ]);
511
+ answer(io, session, { task: found, decisions: decisions.items, runs: runs.items }, () =>
512
+ formatTask(found, decisions.items, runs.items),
513
+ );
514
+ return EXIT.ok;
515
+ };
516
+
517
+ const take: Command = async (args, io) => {
518
+ const { values, positionals } = parse(args, { agent: { type: "string" } });
519
+ const ref = theOne(positionals, "Say which task: console take <number|id>");
520
+ const session = await open(io, values);
521
+ const project = await session.project();
522
+ const found = await project.task(ref);
523
+ const agent = text(values, "agent");
524
+ const run = await project.startRun({
525
+ taskId: found.id,
526
+ ...(agent === undefined ? {} : { agent }),
527
+ });
528
+ answer(io, session, { run, task: found }, () =>
529
+ [
530
+ `Run ${run.id} began on #${found.number} ${found.title}, as "${run.agent}" for ${run.person.login}. The task is in progress and theirs.`,
531
+ "",
532
+ formatTask(found),
533
+ "",
534
+ 'Say how it goes: console report "<what you found or did>". Hand in what you make: console hand-in <https url | file path>.',
535
+ 'Ask when a person must decide: console ask "<question>" --option "..." --option "...". End with: console finish --summary "<what was done, what is left>".',
536
+ ].join("\n"),
537
+ );
538
+ return EXIT.ok;
539
+ };
540
+
541
+ const report: Command = async (args, io) => {
542
+ const { values, positionals } = parse(args, {
543
+ file: { type: "string" },
544
+ run: { type: "string" },
545
+ });
546
+ if (positionals.length > 1) {
547
+ throw misuse('Say the report as one argument: console report "<markdown>"');
548
+ }
549
+ const body = await bodyOf(io, values, positionals[0]);
550
+ if (body === undefined) {
551
+ throw misuse(
552
+ 'Say what to report: console report "<markdown>", or --file <path>, or - to read standard input.',
553
+ );
554
+ }
555
+ const session = await open(io, values);
556
+ const project = await session.project();
557
+ const runId = await currentRun(project, values);
558
+ const run = await project.report(runId, { body });
559
+ answer(
560
+ io,
561
+ session,
562
+ run,
563
+ () =>
564
+ `Reported on run ${run.id}: ${run.reports.length} report${run.reports.length === 1 ? "" : "s"} so far.`,
565
+ );
566
+ return EXIT.ok;
567
+ };
568
+
569
+ /** The media types of the kinds of file an agent hands in, by extension; the rest is bytes. */
570
+ const CONTENT_TYPES: Readonly<Record<string, string>> = {
571
+ ".md": "text/markdown",
572
+ ".txt": "text/plain",
573
+ ".log": "text/plain",
574
+ ".csv": "text/csv",
575
+ ".json": "application/json",
576
+ ".yaml": "application/yaml",
577
+ ".yml": "application/yaml",
578
+ ".html": "text/html",
579
+ ".png": "image/png",
580
+ ".jpg": "image/jpeg",
581
+ ".jpeg": "image/jpeg",
582
+ ".gif": "image/gif",
583
+ ".webp": "image/webp",
584
+ ".svg": "image/svg+xml",
585
+ ".pdf": "application/pdf",
586
+ ".zip": "application/zip",
587
+ };
588
+
589
+ const soFar = (count: number): string => `${count} artifact${count === 1 ? "" : "s"} so far.`;
590
+
591
+ const handIn: Command = async (args, io) => {
592
+ const { values, positionals } = parse(args, {
593
+ label: { type: "string" },
594
+ run: { type: "string" },
595
+ });
596
+ const what = theOne(
597
+ positionals,
598
+ "Say what to hand in: console hand-in <https url | file path> [--label <words>]",
599
+ );
600
+ const label = text(values, "label");
601
+ const session = await open(io, values);
602
+ const project = await session.project();
603
+ const runId = await currentRun(project, values);
604
+ // A URL is handed in as a link, and the console says whether it takes it; anything else
605
+ // names a file on this machine.
606
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(what)) {
607
+ const run = await project.handIn(runId, {
608
+ url: what,
609
+ ...(label === undefined ? {} : { label }),
610
+ });
611
+ answer(
612
+ io,
613
+ session,
614
+ run,
615
+ () => `Handed in ${what} on run ${run.id}: ${soFar(run.artifacts.length)}`,
616
+ );
617
+ return EXIT.ok;
618
+ }
619
+ let bytes: Uint8Array;
620
+ try {
621
+ bytes = await io.readBytes(what);
622
+ } catch {
623
+ throw failed(`No file could be read at ${what}. Hand in an https link, or the path of a file.`);
624
+ }
625
+ const name = basename(what);
626
+ const contentType = CONTENT_TYPES[extname(name).toLowerCase()];
627
+ const run = await project.handInFile(runId, {
628
+ name,
629
+ bytes,
630
+ ...(contentType === undefined ? {} : { contentType }),
631
+ ...(label === undefined ? {} : { label }),
632
+ });
633
+ answer(
634
+ io,
635
+ session,
636
+ run,
637
+ () =>
638
+ `Handed in the file ${name} (${bytes.byteLength} bytes) on run ${run.id}: ${soFar(run.artifacts.length)}`,
639
+ );
640
+ return EXIT.ok;
641
+ };
642
+
643
+ const ask: Command = async (args, io) => {
644
+ const { values, positionals } = parse(args, {
645
+ option: { type: "string", multiple: true },
646
+ body: { type: "string" },
647
+ file: { type: "string" },
648
+ run: { type: "string" },
649
+ wait: { type: "string" },
650
+ });
651
+ const question = theOne(
652
+ positionals,
653
+ 'Say the question: console ask "<question>" --option "<label>" --option "<label>"',
654
+ );
655
+ const options = texts(values, "option");
656
+ if (options.length < MIN_OPTIONS || options.length > MAX_OPTIONS) {
657
+ throw misuse(`Give ${MIN_OPTIONS} to ${MAX_OPTIONS} options, one --option each.`);
658
+ }
659
+ const seconds = secondsOf(text(values, "wait"), DEFAULT_ASK_WAIT_SECONDS);
660
+ const body = await bodyOf(io, values, text(values, "body"));
661
+ const session = await open(io, values);
662
+ const project = await session.project();
663
+ const runId = await currentRun(project, values);
664
+ const raised = await project.raiseDecision({
665
+ question,
666
+ options: options.map((label) => ({ label })),
667
+ runId,
668
+ ...(body === undefined ? {} : { body }),
669
+ });
670
+ // The id is printed before any waiting, so that a wait cut short loses nothing.
671
+ io.stdout(
672
+ session.json
673
+ ? `${JSON.stringify(raised, null, 2)}\n`
674
+ : `Decision ${raised.id} raised on run ${runId}; the run waits for a person.\n${formatOptions(raised)}\n`,
675
+ );
676
+ return settle(io, session, project, raised, seconds, true);
677
+ };
678
+
679
+ const decision: Command = async (args, io) => {
680
+ const { values, positionals } = parse(args, {
681
+ wait: { type: "string" },
682
+ outcome: { type: "string" },
683
+ file: { type: "string" },
684
+ });
685
+ const id = theOne(
686
+ positionals,
687
+ "Say which decision: console decision <id> [--wait <seconds>] [--outcome <markdown>|--file <path>]",
688
+ );
689
+ const seconds = secondsOf(text(values, "wait"), 0);
690
+ const outcome = await bodyOf(io, values, text(values, "outcome"));
691
+ const session = await open(io, values);
692
+ const project = await session.project();
693
+ if (outcome !== undefined) {
694
+ // What followed the decision: the record grows, and is printed as it stands.
695
+ const grown = await project.updateDecision(id, { outcome });
696
+ answer(io, session, grown, () => formatDecision(grown));
697
+ return EXIT.ok;
698
+ }
699
+ const found = await project.decision(id);
700
+ if (seconds === 0) {
701
+ answer(io, session, found, () => formatDecision(found));
702
+ return EXIT.ok;
703
+ }
704
+ return settle(io, session, project, found, seconds);
705
+ };
706
+
707
+ const docs: Command = async (args, io) => {
708
+ const { values, positionals } = parse(args, {
709
+ search: { type: "string" },
710
+ archived: { type: "boolean" },
711
+ });
712
+ if (positionals.length > 1) {
713
+ throw misuse("Say one folder: console docs [<folder>] [--search <words>] [--archived]");
714
+ }
715
+ const search = text(values, "search");
716
+ const session = await open(io, values);
717
+ const project = await session.project();
718
+ const found = await project.documents({
719
+ ...(positionals[0] === undefined ? {} : { folder: positionals[0] }),
720
+ ...(search === undefined ? {} : { q: search }),
721
+ ...(on(values, "archived") ? { archived: true } : {}),
722
+ });
723
+ answer(io, session, found, () => formatDocuments(found));
724
+ return EXIT.ok;
725
+ };
726
+
727
+ const doc: Command = async (args, io) => {
728
+ const { values, positionals } = parse(args, {
729
+ version: { type: "string" },
730
+ versions: { type: "boolean" },
731
+ });
732
+ const path = theOne(positionals, "Say which page: console doc <path> [--version <n>|--versions]");
733
+ const session = await open(io, values);
734
+ const project = await session.project();
735
+ if (on(values, "versions")) {
736
+ const found = await project.documentVersions(path);
737
+ answer(io, session, found, () => found.items.map(formatVersionLine).join("\n"));
738
+ return EXIT.ok;
739
+ }
740
+ const wanted = text(values, "version");
741
+ if (wanted !== undefined) {
742
+ const number = Number(wanted);
743
+ if (!Number.isInteger(number) || number < 1) {
744
+ throw misuse(`Not a version number: ${wanted}`);
745
+ }
746
+ const found = await project.documentVersion(path, number);
747
+ answer(io, session, found, () => formatVersion(path, found));
748
+ return EXIT.ok;
749
+ }
750
+ const found = await project.document(path);
751
+ answer(io, session, found, () =>
752
+ formatDocument(found, (fileId) => project.documentFileUrl(path, fileId)),
753
+ );
754
+ return EXIT.ok;
755
+ };
756
+
757
+ const write: Command = async (args, io) => {
758
+ const { values, positionals } = parse(args, {
759
+ title: { type: "string" },
760
+ body: { type: "string" },
761
+ file: { type: "string" },
762
+ base: { type: "string" },
763
+ });
764
+ const path = theOne(
765
+ positionals,
766
+ 'Say which page: console write <path> --title "<title>" --body <markdown>|--file <path>',
767
+ );
768
+ const body = await bodyOf(io, values, text(values, "body"));
769
+ if (body === undefined) {
770
+ throw misuse(
771
+ "Say what the page is to say: --body <markdown>, --file <path>, or --body - to read standard input.",
772
+ );
773
+ }
774
+ const base = text(values, "base");
775
+ const baseVersion = base === undefined ? undefined : Number(base);
776
+ if (baseVersion !== undefined && (!Number.isInteger(baseVersion) || baseVersion < 1)) {
777
+ throw misuse(`Not a version number: ${base}`);
778
+ }
779
+ const session = await open(io, values);
780
+ const project = await session.project();
781
+ let title = text(values, "title");
782
+ if (title === undefined) {
783
+ // Without a title, the page keeps the one it has; a new page needs one.
784
+ try {
785
+ title = (await project.document(path)).title;
786
+ } catch (error) {
787
+ if (error instanceof ApiError && error.code === "document.not_found") {
788
+ throw misuse(`No page at ${path} yet. Say its title: --title "<title>"`);
789
+ }
790
+ throw error;
791
+ }
792
+ }
793
+ const written = await project.writeDocument(path, {
794
+ title,
795
+ body,
796
+ ...(baseVersion === undefined ? {} : { baseVersion }),
797
+ });
798
+ answer(
799
+ io,
800
+ session,
801
+ written,
802
+ () =>
803
+ `Wrote ${written.path} (${written.title}), version ${written.version}${written.links.length === 0 ? "" : `; refers to ${written.links.map((link) => link.path).join(", ")}`}`,
804
+ );
805
+ return EXIT.ok;
806
+ };
807
+
808
+ const attach: Command = async (args, io) => {
809
+ const { values, positionals } = parse(args, { label: { type: "string" } });
810
+ const [path, file] = positionals;
811
+ if (path === undefined || file === undefined || positionals.length > 2) {
812
+ throw misuse("Say the page and the file: console attach <path> <file> [--label <words>]");
813
+ }
814
+ const label = text(values, "label");
815
+ const session = await open(io, values);
816
+ const project = await session.project();
817
+ let bytes: Uint8Array;
818
+ try {
819
+ bytes = await io.readBytes(file);
820
+ } catch {
821
+ throw failed(`No file could be read at ${file}.`);
822
+ }
823
+ const name = basename(file);
824
+ const contentType = CONTENT_TYPES[extname(name).toLowerCase()];
825
+ const written = await project.attachFile(path, {
826
+ name,
827
+ bytes,
828
+ ...(contentType === undefined ? {} : { contentType }),
829
+ ...(label === undefined ? {} : { label }),
830
+ });
831
+ answer(
832
+ io,
833
+ session,
834
+ written,
835
+ () =>
836
+ `Attached ${name} (${bytes.byteLength} bytes) to ${written.path}: ${written.files.length} file${written.files.length === 1 ? "" : "s"} on the page.`,
837
+ );
838
+ return EXIT.ok;
839
+ };
840
+
841
+ const archive: Command = async (args, io) => {
842
+ const { values, positionals } = parse(args, {});
843
+ const path = theOne(positionals, "Say which page: console archive <path>");
844
+ const session = await open(io, values);
845
+ const project = await session.project();
846
+ const put = await project.archiveDocument(path);
847
+ answer(
848
+ io,
849
+ session,
850
+ put,
851
+ () =>
852
+ `Archived ${put.path} (${put.title}): out of the folders and the search, still readable here.`,
853
+ );
854
+ return EXIT.ok;
855
+ };
856
+
857
+ const restore: Command = async (args, io) => {
858
+ const { values, positionals } = parse(args, {});
859
+ const path = theOne(positionals, "Say which page: console restore <path>");
860
+ const session = await open(io, values);
861
+ const project = await session.project();
862
+ const back = await project.restoreDocument(path);
863
+ answer(io, session, back, () => `Restored ${back.path} (${back.title}).`);
864
+ return EXIT.ok;
865
+ };
866
+
867
+ const decisions: Command = async (args, io) => {
868
+ const { values } = parse(args, { open: { type: "boolean" } }, false);
869
+ const session = await open(io, values);
870
+ const project = await session.project();
871
+ const found = await project.decisions(on(values, "open") ? { open: true } : {});
872
+ answer(io, session, found, () =>
873
+ found.items.length === 0 ? "No decisions." : found.items.map(formatDecisionLine).join("\n"),
874
+ );
875
+ return EXIT.ok;
876
+ };
877
+
878
+ const ending =
879
+ (status: "finished" | "failed"): Command =>
880
+ async (args, io) => {
881
+ const { values } = parse(
882
+ args,
883
+ { summary: { type: "string" }, file: { type: "string" }, run: { type: "string" } },
884
+ false,
885
+ );
886
+ const summary = await bodyOf(io, values, text(values, "summary"));
887
+ const session = await open(io, values);
888
+ const project = await session.project();
889
+ const runId = await currentRun(project, values);
890
+ const run = await project.endRun(runId, {
891
+ status,
892
+ ...(summary === undefined ? {} : { summary }),
893
+ });
894
+ answer(io, session, run, () =>
895
+ status === "finished"
896
+ ? `Run ${run.id} finished; the task is up for review.`
897
+ : `Run ${run.id} failed; the task stays as it is.`,
898
+ );
899
+ return EXIT.ok;
900
+ };
901
+
902
+ const abandon: Command = async (args, io) => {
903
+ const { values, positionals } = parse(args, {
904
+ summary: { type: "string" },
905
+ file: { type: "string" },
906
+ run: { type: "string" },
907
+ });
908
+ if (positionals.length > 1) {
909
+ throw misuse("Say which run: console abandon [<run id>]");
910
+ }
911
+ const summary = await bodyOf(io, values, text(values, "summary"));
912
+ const session = await open(io, values);
913
+ const project = await session.project();
914
+ const runId = positionals[0] ?? (await currentRun(project, values));
915
+ const run = await project.endRun(runId, {
916
+ status: "abandoned",
917
+ ...(summary === undefined ? {} : { summary }),
918
+ });
919
+ answer(io, session, run, () => `Run ${run.id} abandoned; the task stays as it is.`);
920
+ return EXIT.ok;
921
+ };
922
+
923
+ const runs: Command = async (args, io) => {
924
+ const { values } = parse(
925
+ args,
926
+ { task: { type: "string" }, open: { type: "boolean" }, mine: { type: "boolean" } },
927
+ false,
928
+ );
929
+ const session = await open(io, values);
930
+ const project = await session.project();
931
+ const ref = text(values, "task");
932
+ const taskId = ref === undefined ? undefined : (await project.task(ref)).id;
933
+ const found = await project.runs({
934
+ ...(taskId === undefined ? {} : { task: taskId }),
935
+ ...(on(values, "open") ? { open: true } : {}),
936
+ ...(on(values, "mine") ? { mine: true } : {}),
937
+ });
938
+ answer(io, session, found, () =>
939
+ found.items.length === 0 ? "No runs." : found.items.map(formatRunLine).join("\n"),
940
+ );
941
+ return EXIT.ok;
942
+ };
943
+
944
+ const run: Command = async (args, io) => {
945
+ const { values, positionals } = parse(args, {});
946
+ const id = theOne(positionals, "Say which run: console run <id>");
947
+ const session = await open(io, values);
948
+ const project = await session.project();
949
+ const found = await project.run(id);
950
+ answer(io, session, found, () => formatRun(found, (artifactId) => project.fileUrl(artifactId)));
951
+ return EXIT.ok;
952
+ };
953
+
954
+ /** A port for `serve`: the default, or what was said; 0 for one the system picks. */
955
+ function portOf(value: string | undefined): number {
956
+ if (value === undefined) {
957
+ return DEFAULT_SERVE_PORT;
958
+ }
959
+ const port = Number(value);
960
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
961
+ throw misuse(`Not a port: ${value}`);
962
+ }
963
+ return port;
964
+ }
965
+
966
+ /**
967
+ * A console of the person's own, served from this machine (ADR-0014): the build's pages here,
968
+ * and everything under /api/ carried to the console with the token, which the pages never see.
969
+ */
970
+ const serve: Command = async (args, io) => {
971
+ const { values, positionals } = parse(args, { port: { type: "string" } });
972
+ if (positionals.length > 1) {
973
+ throw misuse("Say at most one directory: console serve [<dir>] [--port <n>]");
974
+ }
975
+ const [dir] = positionals;
976
+ const port = portOf(text(values, "port"));
977
+ const listen = io.listen;
978
+ if (listen === undefined) {
979
+ throw failed("Serving is not possible here.");
980
+ }
981
+ const session = await open(io, values);
982
+ const me = await session.client.me();
983
+ const person = me.person;
984
+ if (person === null) {
985
+ throw failed("The console does not know this token. Sign in again: console login");
986
+ }
987
+ let web: WebRoot | undefined;
988
+ if (dir !== undefined) {
989
+ try {
990
+ web = await loadWebRoot(dir);
991
+ } catch (error) {
992
+ if (error instanceof WebRootError) {
993
+ throw failed(
994
+ `Not a build to serve: ${error.message}. Build your console first, and name the directory that holds its index.html: console serve <dir>`,
995
+ );
996
+ }
997
+ throw error;
998
+ }
999
+ }
1000
+ const listening = await listen(
1001
+ (bound) =>
1002
+ createServeHandler({
1003
+ upstream: session.url,
1004
+ token: session.token,
1005
+ fetch: io.fetch,
1006
+ port: bound,
1007
+ web,
1008
+ }),
1009
+ port,
1010
+ );
1011
+ io.stdout(
1012
+ web === undefined
1013
+ ? `Serving the API of ${me.workspace.name} (${session.url}) at ${listening.url}api/ as ${person.login}, for a development server to send its /api/ requests to. Stop with Ctrl+C.\n`
1014
+ : `Serving ${dir} at ${listening.url} as ${person.login}: the pages are there, and everything under /api/ goes to ${me.workspace.name} (${session.url}) with your token, which the pages never see. Stop with Ctrl+C.\n`,
1015
+ );
1016
+ await listening.closed;
1017
+ return EXIT.ok;
1018
+ };
1019
+
1020
+ const COMMANDS: Readonly<Record<string, Command>> = {
1021
+ login,
1022
+ logout,
1023
+ whoami,
1024
+ serve,
1025
+ projects,
1026
+ use,
1027
+ tasks,
1028
+ task,
1029
+ skills,
1030
+ take,
1031
+ report,
1032
+ "hand-in": handIn,
1033
+ ask,
1034
+ decision,
1035
+ decisions,
1036
+ finish: ending("finished"),
1037
+ fail: ending("failed"),
1038
+ abandon,
1039
+ runs,
1040
+ run,
1041
+ docs,
1042
+ doc,
1043
+ write,
1044
+ attach,
1045
+ archive,
1046
+ restore,
1047
+ };
1048
+
1049
+ function help(io: CliIo, name: string | undefined): number {
1050
+ if (name === undefined) {
1051
+ io.stdout(usage(io.version));
1052
+ return EXIT.ok;
1053
+ }
1054
+ const found = helpFor(name);
1055
+ if (found === undefined) {
1056
+ throw misuse(`Not a command: ${name}. The commands: ${Object.keys(COMMAND_HELP).join(", ")}`);
1057
+ }
1058
+ io.stdout(found);
1059
+ return EXIT.ok;
1060
+ }
1061
+
1062
+ /** What a refusal of the console is followed by, when there is something to do about it. */
1063
+ function hintFor(error: ApiError): string {
1064
+ switch (error.code) {
1065
+ case "auth.required":
1066
+ return "The token is not one the console knows, any more or at all. A person signs the command in again: console login\n";
1067
+ case "project.not_found":
1068
+ return "No project has this key, or it is not yours to see. console projects lists the projects you may work in.\n";
1069
+ case "document.not_found":
1070
+ return "No page has this path, in this project. console docs lists the pages of a folder; console write <path> writes one.\n";
1071
+ case "document.conflict":
1072
+ return "The page has moved on since the version you started from. Read it again, and write from there.\n";
1073
+ case "document.archived":
1074
+ return "The page is archived. console restore <path> brings it back first.\n";
1075
+ case "connect.too_many":
1076
+ return "The console holds as many connections as it may at once. Try again in a few minutes.\n";
1077
+ case "network":
1078
+ return "Is the console's address right? console whoami --url <origin> says what the command uses.\n";
1079
+ default:
1080
+ return "";
1081
+ }
1082
+ }
1083
+
1084
+ /** Runs the command and answers with its exit code. What it prints goes through `io`. */
1085
+ export async function runCli(argv: readonly string[], io: CliIo): Promise<number> {
1086
+ const [name, ...rest] = argv;
1087
+ try {
1088
+ if (name === "--version" || name === "-v" || name === "version") {
1089
+ io.stdout(`console ${io.version ?? "(version unknown)"} (@skillcdn/console)\n`);
1090
+ return EXIT.ok;
1091
+ }
1092
+ if (name === undefined || name === "help" || name === "--help" || name === "-h") {
1093
+ return help(io, rest[0]);
1094
+ }
1095
+ if (rest.includes("--help") || rest.includes("-h")) {
1096
+ return help(io, name);
1097
+ }
1098
+ const command = COMMANDS[name];
1099
+ if (command === undefined) {
1100
+ throw misuse(`Not a command: ${name}. Run: console help`);
1101
+ }
1102
+ return await command(rest, io);
1103
+ } catch (error) {
1104
+ if (error instanceof CliExit) {
1105
+ io.stderr(`${error.message}\n`);
1106
+ return error.exit;
1107
+ }
1108
+ if (error instanceof ApiError) {
1109
+ io.stderr(`${error.code}: ${error.message}\n${hintFor(error)}`);
1110
+ return EXIT.failed;
1111
+ }
1112
+ throw error;
1113
+ }
1114
+ }