@skillcdn/console 0.1.15 → 0.1.17

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 (113) hide show
  1. package/README.md +2 -1
  2. package/dist/api.d.ts +3 -3
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +2 -2
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts +2 -0
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +91 -9
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/format.d.ts +7 -2
  11. package/dist/cli/format.d.ts.map +1 -1
  12. package/dist/cli/format.js +23 -2
  13. package/dist/cli/format.js.map +1 -1
  14. package/dist/cli/help.d.ts.map +1 -1
  15. package/dist/cli/help.js +28 -4
  16. package/dist/cli/help.js.map +1 -1
  17. package/dist/cli/main.js +2 -1
  18. package/dist/cli/main.js.map +1 -1
  19. package/dist/cli/terminal.d.ts +5 -0
  20. package/dist/cli/terminal.d.ts.map +1 -1
  21. package/dist/cli/terminal.js +11 -1
  22. package/dist/cli/terminal.js.map +1 -1
  23. package/dist/client.d.ts +28 -3
  24. package/dist/client.d.ts.map +1 -1
  25. package/dist/client.js +62 -2
  26. package/dist/client.js.map +1 -1
  27. package/dist/components/calendar.d.ts +35 -2
  28. package/dist/components/calendar.d.ts.map +1 -1
  29. package/dist/components/calendar.js +58 -19
  30. package/dist/components/calendar.js.map +1 -1
  31. package/dist/components/comments.d.ts +59 -0
  32. package/dist/components/comments.d.ts.map +1 -0
  33. package/dist/components/comments.js +138 -0
  34. package/dist/components/comments.js.map +1 -0
  35. package/dist/components/event-feed.d.ts.map +1 -1
  36. package/dist/components/event-feed.js +2 -0
  37. package/dist/components/event-feed.js.map +1 -1
  38. package/dist/components/markdown.d.ts +6 -0
  39. package/dist/components/markdown.d.ts.map +1 -1
  40. package/dist/components/markdown.js +2 -1
  41. package/dist/components/markdown.js.map +1 -1
  42. package/dist/components/projects.d.ts +4 -1
  43. package/dist/components/projects.d.ts.map +1 -1
  44. package/dist/components/projects.js +10 -2
  45. package/dist/components/projects.js.map +1 -1
  46. package/dist/components/task-form.d.ts.map +1 -1
  47. package/dist/components/task-form.js +1 -1
  48. package/dist/components/task-form.js.map +1 -1
  49. package/dist/components/task-view.d.ts +8 -3
  50. package/dist/components/task-view.d.ts.map +1 -1
  51. package/dist/components/task-view.js +6 -4
  52. package/dist/components/task-view.js.map +1 -1
  53. package/dist/console.css +180 -32
  54. package/dist/console.d.ts.map +1 -1
  55. package/dist/console.js +23 -5
  56. package/dist/console.js.map +1 -1
  57. package/dist/data.d.ts +5 -1
  58. package/dist/data.d.ts.map +1 -1
  59. package/dist/data.js +2 -0
  60. package/dist/data.js.map +1 -1
  61. package/dist/i18n/en.d.ts +15 -2
  62. package/dist/i18n/en.d.ts.map +1 -1
  63. package/dist/i18n/en.js +17 -4
  64. package/dist/i18n/en.js.map +1 -1
  65. package/dist/i18n/ko.d.ts.map +1 -1
  66. package/dist/i18n/ko.js +17 -4
  67. package/dist/i18n/ko.js.map +1 -1
  68. package/dist/index.d.ts +1 -0
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +1 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/limits.d.ts +3 -0
  73. package/dist/limits.d.ts.map +1 -1
  74. package/dist/limits.js +3 -0
  75. package/dist/limits.js.map +1 -1
  76. package/dist/rules.js +2 -2
  77. package/dist/rules.js.map +1 -1
  78. package/dist/schemas.d.ts +105 -0
  79. package/dist/schemas.d.ts.map +1 -1
  80. package/dist/schemas.js +32 -1
  81. package/dist/schemas.js.map +1 -1
  82. package/dist/vocabulary.d.ts +1 -1
  83. package/dist/vocabulary.d.ts.map +1 -1
  84. package/dist/vocabulary.js +1 -0
  85. package/dist/vocabulary.js.map +1 -1
  86. package/docs/specs/cli.md +8 -4
  87. package/docs/specs/rest.md +9 -2
  88. package/package.json +1 -1
  89. package/skills/working-the-board/SKILL.md +6 -4
  90. package/src/api.ts +12 -0
  91. package/src/cli/cli.ts +106 -9
  92. package/src/cli/format.ts +28 -1
  93. package/src/cli/help.ts +32 -3
  94. package/src/cli/main.ts +2 -1
  95. package/src/cli/terminal.ts +12 -1
  96. package/src/client.ts +112 -3
  97. package/src/components/calendar.tsx +216 -28
  98. package/src/components/comments.tsx +342 -0
  99. package/src/components/event-feed.tsx +2 -0
  100. package/src/components/markdown.tsx +9 -1
  101. package/src/components/projects.tsx +19 -6
  102. package/src/components/task-form.tsx +1 -0
  103. package/src/components/task-view.tsx +32 -33
  104. package/src/console.tsx +27 -3
  105. package/src/data.ts +8 -0
  106. package/src/i18n/en.ts +19 -4
  107. package/src/i18n/ko.ts +19 -4
  108. package/src/index.ts +12 -0
  109. package/src/limits.ts +3 -0
  110. package/src/rules.ts +2 -2
  111. package/src/schemas.ts +40 -1
  112. package/src/styles/console.css +180 -32
  113. package/src/vocabulary.ts +1 -0
package/src/cli/cli.ts CHANGED
@@ -41,6 +41,7 @@ import type { DirectoryStore } from "./directory.js";
41
41
  import {
42
42
  formatAnswer,
43
43
  formatCategoryLine,
44
+ formatComment,
44
45
  formatDecision,
45
46
  formatDecisionLine,
46
47
  formatDocument,
@@ -107,6 +108,8 @@ export interface CliIo {
107
108
  readonly readFile: (path: string) => Promise<string>;
108
109
  /** A file handed in, as bytes. */
109
110
  readonly readBytes: (path: string) => Promise<Uint8Array>;
111
+ /** A file read from the console, written at a path, or into a directory under its name; says where. */
112
+ readonly writeBytes: (path: string, bytes: Uint8Array, name: string) => Promise<string>;
110
113
  readonly store: CredentialStore;
111
114
  /** What the working directory says of the project, and the way to write it. */
112
115
  readonly directory: DirectoryStore;
@@ -952,12 +955,94 @@ const task: Command = async (args, io) => {
952
955
  const session = await open(io, values);
953
956
  const project = await session.project();
954
957
  const found = await project.task(ref);
955
- const [decisions, runs] = await Promise.all([
958
+ const [decisions, runs, comments] = await Promise.all([
956
959
  project.decisions({ task: found.id }),
957
960
  project.runs({ task: found.id }),
961
+ project.comments(found.id),
958
962
  ]);
959
- answer(io, session, { task: found, decisions: decisions.items, runs: runs.items }, () =>
960
- formatTask(found, decisions.items, runs.items),
963
+ answer(
964
+ io,
965
+ session,
966
+ { task: found, decisions: decisions.items, runs: runs.items, comments: comments.items },
967
+ () => formatTask(found, decisions.items, runs.items, comments.items),
968
+ );
969
+ return EXIT.ok;
970
+ };
971
+
972
+ /**
973
+ * A word in the discussion on a task (ADR-0024): what is said, as given, from a file or from
974
+ * standard input, with the files named by --attach put in it; one request, one comment.
975
+ */
976
+ const comment: Command = async (args, io) => {
977
+ const { values, positionals } = parse(args, {
978
+ file: { type: "string" },
979
+ attach: { type: "string", multiple: true },
980
+ });
981
+ const ask =
982
+ 'Say which task, and what: console comment <number|id> "<markdown>" [--file <path> | -] [--attach <path>]...';
983
+ if (positionals.length === 0 || positionals.length > 2) {
984
+ throw misuse(ask);
985
+ }
986
+ const ref = positionals[0] ?? "";
987
+ const body = (await bodyOf(io, values, positionals[1])) ?? "";
988
+ const paths = texts(values, "attach");
989
+ if (body.trim().length === 0 && paths.length === 0) {
990
+ throw misuse(`Say something, or attach a file. ${ask}`);
991
+ }
992
+ const files = [];
993
+ for (const path of paths) {
994
+ let bytes: Uint8Array;
995
+ try {
996
+ bytes = await io.readBytes(path);
997
+ } catch {
998
+ throw failed(`No file could be read at ${path}. Name the path of a file to attach.`);
999
+ }
1000
+ const name = basename(path);
1001
+ const contentType = CONTENT_TYPES[extname(name).toLowerCase()];
1002
+ files.push({ name, bytes, ...(contentType === undefined ? {} : { contentType }) });
1003
+ }
1004
+ const session = await open(io, values);
1005
+ const project = await session.project();
1006
+ const task = await project.task(ref);
1007
+ const written = await project.comment(task.id, { body, files });
1008
+ answer(io, session, written, () =>
1009
+ [
1010
+ `Commented on #${task.number} ${task.title}${written.files.length === 0 ? "" : `, with ${written.files.length} file${written.files.length === 1 ? "" : "s"}`}.`,
1011
+ formatComment(written),
1012
+ ].join("\n"),
1013
+ );
1014
+ return EXIT.ok;
1015
+ };
1016
+
1017
+ /**
1018
+ * A file of the project, read from the console and written on this machine: one a run handed
1019
+ * in, one put in a comment, or one attached to a page, by its id, so that an agent can look at
1020
+ * a picture a person put on the board.
1021
+ */
1022
+ const file: Command = async (args, io) => {
1023
+ const { values, positionals } = parse(args, { out: { type: "string" } });
1024
+ const id = theOne(positionals, "Say which file: console file <id> [--out <path>]");
1025
+ const session = await open(io, values);
1026
+ const project = await session.project();
1027
+ const read = await project.readFile(id);
1028
+ const target = text(values, "out") ?? read.name;
1029
+ let saved: string;
1030
+ try {
1031
+ saved = await io.writeBytes(target, read.bytes, read.name);
1032
+ } catch {
1033
+ throw failed(`The file could not be written at ${target}. Name a path with --out <path>.`);
1034
+ }
1035
+ answer(
1036
+ io,
1037
+ session,
1038
+ {
1039
+ id,
1040
+ name: read.name,
1041
+ contentType: read.contentType,
1042
+ size: read.bytes.byteLength,
1043
+ path: saved,
1044
+ },
1045
+ () => `Saved ${read.name} (${read.bytes.byteLength} bytes, ${read.contentType}) at ${saved}`,
961
1046
  );
962
1047
  return EXIT.ok;
963
1048
  };
@@ -974,22 +1059,32 @@ const take: Command = async (args, io) => {
974
1059
  ...(agent === undefined ? {} : { agent }),
975
1060
  });
976
1061
  // The rules come with the run, as they read for the person: everything from here acts
977
- // within them (ADR-0023).
978
- const [ruling, me] = await Promise.all([project.rules(), session.client.me()]);
1062
+ // within them (ADR-0023); the task is read again, as it stands now that it is taken.
1063
+ const [ruling, me, taken, comments] = await Promise.all([
1064
+ project.rules(),
1065
+ session.client.me(),
1066
+ project.task(ref),
1067
+ project.comments(found.id),
1068
+ ]);
1069
+ // Taking a task with a run open on it ends that run (ADR-0020): said when it happened.
1070
+ const tookOver =
1071
+ found.openRuns > 0
1072
+ ? " A run that was open on it is over now: the board cannot know whether its agent still ran, so taking it was deciding."
1073
+ : "";
979
1074
  const rules = {
980
1075
  workspace: forPerson(ruling.workspace, me.language),
981
1076
  project: forPerson(ruling.project, me.language),
982
1077
  };
983
- answer(io, session, { run, task: found, rules }, () =>
1078
+ answer(io, session, { run, task: taken, comments: comments.items, rules }, () =>
984
1079
  [
985
- `Run ${run.id} began on #${found.number} ${found.title}, as "${run.agent}" for ${run.person.login}. The task is in progress and theirs. A run that was open on it is over now: the board cannot know whether its agent still ran, so taking it was deciding.`,
1080
+ `Run ${run.id} began on #${found.number} ${found.title}, as "${run.agent}" for ${run.person.login}. The task is in progress and theirs.${tookOver}`,
986
1081
  "",
987
- formatTask(found),
1082
+ formatTask(taken, undefined, undefined, comments.items),
988
1083
  "",
989
1084
  formatRules(rules),
990
1085
  "",
991
1086
  'Say how it goes: console report "<what you found or did>". Hand in what you make: console hand-in <https url | file path>.',
992
- 'Ask when a person must decide: console ask "<question>" --option "..." --option "...". End with: console finish --summary "<what was done, what is left>".',
1087
+ 'Ask when a person must decide: console ask "<question>" --option "..." --option "...". A word for the task\'s people that is not a report: console comment <number> "<markdown>". End with: console finish --summary "<what was done, what is left>".',
993
1088
  ].join("\n"),
994
1089
  );
995
1090
  return EXIT.ok;
@@ -1895,6 +1990,8 @@ const COMMANDS: Readonly<Record<string, Command>> = {
1895
1990
  take,
1896
1991
  report,
1897
1992
  "hand-in": handIn,
1993
+ comment,
1994
+ file,
1898
1995
  ask,
1899
1996
  decision,
1900
1997
  decisions,
package/src/cli/format.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  RestArtifact,
3
3
  RestCategory,
4
+ RestComment,
4
5
  RestDecision,
5
6
  RestDocument,
6
7
  RestDocumentSummary,
@@ -131,6 +132,7 @@ export function formatTask(
131
132
  task: RestTask,
132
133
  decisions?: readonly RestDecision[],
133
134
  runs?: readonly RestRun[],
135
+ comments?: readonly RestComment[],
134
136
  ): string {
135
137
  const lines = [
136
138
  `#${task.number} ${task.title}`,
@@ -159,6 +161,31 @@ export function formatTask(
159
161
  lines.push(` ${formatRunLine(run)}`);
160
162
  }
161
163
  }
164
+ if (comments !== undefined) {
165
+ lines.push("", comments.length === 0 ? "comments: none" : "comments:");
166
+ for (const comment of comments) {
167
+ lines.push(formatComment(comment));
168
+ }
169
+ }
170
+ return lines.join("\n");
171
+ }
172
+
173
+ /**
174
+ * One comment of the discussion, indented under the list: who said it, through which agent,
175
+ * when, the whole word, and each file put in it with the command that reads it.
176
+ */
177
+ export function formatComment(comment: RestComment): string {
178
+ const who =
179
+ comment.agent === null ? comment.author.login : `${comment.author.login} as ${comment.agent}`;
180
+ const lines = [` ${who}, ${comment.createdAt}, comment ${comment.id}:`];
181
+ if (comment.body.trim().length > 0) {
182
+ lines.push(indent(comment.body));
183
+ }
184
+ for (const attached of comment.files) {
185
+ lines.push(
186
+ ` file: ${attached.file.name} (${attached.file.size} bytes, ${attached.file.contentType}): console file ${attached.id}`,
187
+ );
188
+ }
162
189
  return lines.join("\n");
163
190
  }
164
191
 
@@ -205,7 +232,7 @@ export function formatArtifact(
205
232
  const what =
206
233
  artifact.file === null
207
234
  ? (artifact.url ?? "")
208
- : `${artifact.file.name} (${artifact.file.size} bytes, ${artifact.file.contentType}${fileUrl === undefined ? "" : `; read at ${fileUrl(artifact.id)}`})`;
235
+ : `${artifact.file.name} (${artifact.file.size} bytes, ${artifact.file.contentType}${fileUrl === undefined ? "" : `; read at ${fileUrl(artifact.id)}`}; console file ${artifact.id})`;
209
236
  return artifact.label === null ? what : `${artifact.label}: ${what}`;
210
237
  }
211
238
 
package/src/cli/help.ts CHANGED
@@ -6,9 +6,11 @@ import {
6
6
  MAX_CATEGORIES,
7
7
  MAX_CATEGORY_KEY_LENGTH,
8
8
  MAX_CATEGORY_NAME_LENGTH,
9
+ MAX_COMMENTS_PER_TASK,
9
10
  MAX_DOCUMENT_LENGTH,
10
11
  MAX_DOCUMENT_PATH_LENGTH,
11
12
  MAX_FILE_BYTES,
13
+ MAX_FILES_PER_COMMENT,
12
14
  MAX_FILES_PER_DOCUMENT,
13
15
  MAX_LINK_LABEL_LENGTH,
14
16
  MAX_OPTION_LABEL_LENGTH,
@@ -124,9 +126,9 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
124
126
  task: {
125
127
  usage: `task <number|id> | task new <title> [--body <markdown>|--file <path>] ${TASK_FIELD_FLAGS} | task edit <number|id> [--title <title>] [--body <markdown>|--file <path>] ${TASK_FIELD_FLAGS}`,
126
128
  about:
127
- "One task in full, with its plan (category, goal, start and due days), what happened to it (when it was started and done, which the board keeps itself), the decisions about it and the runs on it; or a new task written down; or a task changed, only in what the flags say, where none clears a field (priorities: low, normal, high, urgent).",
129
+ "One task in full, with its plan (category, goal, start and due days), what happened to it (when it was started and done, which the board keeps itself), the decisions about it, the runs on it and the discussion on it, each comment whole with its files; or a new task written down; or a task changed, only in what the flags say, where none clears a field (priorities: low, normal, high, urgent).",
128
130
  limits: [
129
- `title: one line, up to ${MAX_TITLE_LENGTH} characters`,
131
+ `title: one line, up to ${MAX_TITLE_LENGTH} characters, saying what is to be done on its own; what it refers to goes in the body`,
130
132
  `body: ${MARKDOWN}`,
131
133
  ...TASK_FIELD_LIMITS,
132
134
  ],
@@ -336,6 +338,30 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
336
338
  about: "One run in full: its reports, what it handed in, what it waits for, how it ended.",
337
339
  refusals: ["run.not_found: no run has that id"],
338
340
  },
341
+ comment: {
342
+ usage:
343
+ 'comment <number|id> "<markdown>" [--attach <path>]... | comment <number|id> --file <path> | comment <number|id> -',
344
+ about:
345
+ "Says something in the discussion on a task, for its people: an answer to what they asked there, a question that is not a decision, a remark; with files from this machine put in it, which the console keeps and shows on the task, pictures in place. Needs no run. A report is the run's record; a comment is a word to people. Never rewritten: a mistake is corrected in the next one.",
346
+ limits: [
347
+ `the comment: ${MARKDOWN}; blank only when a file comes with it`,
348
+ `a file: up to ${MAX_FILE_BYTES} bytes, not empty; called by its name, without the path; a picture is placed in the text as ![words](its name)`,
349
+ `at most ${MAX_FILES_PER_COMMENT} files on one comment, together up to ${MAX_FILE_BYTES} bytes`,
350
+ `at most ${MAX_COMMENTS_PER_TASK} comments on one task`,
351
+ ],
352
+ refusals: [
353
+ "task.not_found: no task has that number or id",
354
+ "comment.too_many: the task carries as many comments as one may",
355
+ "comment.too_many_files: more files than one comment may carry",
356
+ "request.too_large: the files are over the limit together",
357
+ ],
358
+ },
359
+ file: {
360
+ usage: "file <id> [--out <path>]",
361
+ about:
362
+ "Reads a file of the project from the console and writes it on this machine: one a run handed in, one put in a comment, or one attached to a page, by the id the task, the run or the page shows. Written under its own name in the working directory, at --out <path>, or into --out <directory>. Then look at it with your own tools.",
363
+ refusals: ["file.not_found: no file of this project has that id"],
364
+ },
339
365
  docs: {
340
366
  usage: "docs [<folder>] [--search <words>] [--archived]",
341
367
  about:
@@ -447,7 +473,10 @@ const SECTIONS: readonly { readonly title: string; readonly commands: readonly s
447
473
  "rules",
448
474
  ],
449
475
  },
450
- { title: "Working", commands: ["take", "report", "hand-in", "ask", "finish", "fail", "abandon"] },
476
+ {
477
+ title: "Working",
478
+ commands: ["take", "report", "hand-in", "comment", "file", "ask", "finish", "fail", "abandon"],
479
+ },
451
480
  {
452
481
  title: "The documents: the project's pages of Markdown, in folders, by path",
453
482
  commands: ["docs", "doc", "write", "attach", "archive", "restore"],
package/src/cli/main.ts CHANGED
@@ -9,7 +9,7 @@ import { runCli } from "./cli.js";
9
9
  import { createFileStore, defaultConfigDir } from "./credentials.js";
10
10
  import { createDirectoryStore } from "./directory.js";
11
11
  import { listenOn } from "./serve.js";
12
- import { readBytesAt, readFileAt, readStdin } from "./terminal.js";
12
+ import { readBytesAt, readFileAt, readStdin, writeBytesAt } from "./terminal.js";
13
13
 
14
14
  // The `console` command: the only place the command reads the process. Everything it does is in
15
15
  // cli.ts, which takes what it needs and is what the tests run.
@@ -34,6 +34,7 @@ process.exitCode = await runCli(process.argv.slice(2), {
34
34
  hostname: hostname(),
35
35
  readFile: readFileAt,
36
36
  readBytes: readBytesAt,
37
+ writeBytes: writeBytesAt,
37
38
  store: createFileStore(defaultConfigDir(process.env)),
38
39
  directory: createDirectoryStore(process.cwd()),
39
40
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -1,4 +1,5 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, stat, writeFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
2
3
  import process from "node:process";
3
4
 
4
5
  // The terminal's side of the command: what is read from it. Nothing here is reached by tests,
@@ -26,3 +27,13 @@ export function readFileAt(path: string): Promise<string> {
26
27
  export function readBytesAt(path: string): Promise<Uint8Array> {
27
28
  return readFile(path);
28
29
  }
30
+
31
+ /**
32
+ * A file read from the console, written at the path, or into it under the file's name when
33
+ * the path is a directory; says where it went, in full.
34
+ */
35
+ export async function writeBytesAt(path: string, bytes: Uint8Array, name: string): Promise<string> {
36
+ const target = (await stat(path).catch(() => undefined))?.isDirectory() ? join(path, name) : path;
37
+ await writeFile(target, bytes);
38
+ return resolve(target);
39
+ }
package/src/client.ts CHANGED
@@ -18,6 +18,8 @@ import {
18
18
  type RestCategoryInput,
19
19
  type RestCategoryPatch,
20
20
  type RestClaim,
21
+ type RestComment,
22
+ type RestComments,
21
23
  type RestConnectInput,
22
24
  type RestConnection,
23
25
  type RestConnectRequest,
@@ -71,6 +73,8 @@ import {
71
73
  restCategoriesSchema,
72
74
  restCategorySchema,
73
75
  restClaimSchema,
76
+ restCommentSchema,
77
+ restCommentsSchema,
74
78
  restConnectionSchema,
75
79
  restConnectRequestSchema,
76
80
  restDecisionSchema,
@@ -123,6 +127,22 @@ export interface FileUpload {
123
127
  readonly label?: string | undefined;
124
128
  }
125
129
 
130
+ /** A comment to write on a task: the word, and the files put in it, which go in the one request. */
131
+ export interface CommentInput {
132
+ /** Markdown; may be blank when a file comes with it. */
133
+ readonly body: string;
134
+ readonly files?: readonly FileUpload[] | undefined;
135
+ }
136
+
137
+ /** The bytes of a file of the project, as the console hands them back, with what it calls them. */
138
+ export interface FileRead {
139
+ /** The file's name, as the console says it; the id when it says none. */
140
+ readonly name: string;
141
+ /** The media type the console answered with, without its parameters. */
142
+ readonly contentType: string;
143
+ readonly bytes: Uint8Array;
144
+ }
145
+
126
146
  export interface ClientOptions {
127
147
  /** The origin of the console, without a path. Empty: the origin of the page. */
128
148
  readonly baseUrl?: string;
@@ -245,8 +265,14 @@ export interface ProjectClient {
245
265
  handIn(runId: string, input: RestArtifactInput): Promise<RestRun>;
246
266
  /** Hands in a file, on a run of the asker's: the console keeps the bytes. */
247
267
  handInFile(runId: string, input: FileUpload): Promise<RestRun>;
248
- /** Where the bytes of a file handed in are read, by the artifact's id. */
249
- fileUrl(artifactId: string): string;
268
+ /** Where the bytes of a file of the project are read, by its id: handed in by a run, put in a comment, or attached to a page. */
269
+ fileUrl(fileId: string): string;
270
+ /** The bytes of a file of the project, by its id, with the name and the type the console says. */
271
+ readFile(fileId: string, signal?: AbortSignal): Promise<FileRead>;
272
+ /** The discussion on a task: its comments, oldest first, whole. */
273
+ comments(taskId: string, signal?: AbortSignal): Promise<RestComments>;
274
+ /** Writes a comment on a task, with its files when given, in one request. */
275
+ comment(taskId: string, input: CommentInput): Promise<RestComment>;
250
276
  /** Ends a run: finished or failed by the agent; abandoned by the person it is for, or an administrator. */
251
277
  endRun(runId: string, input: RestRunEndInput): Promise<RestRun>;
252
278
  /** What happened in the project after event number `after`; `0` for the beginning. Narrowed by `filter`. */
@@ -419,6 +445,37 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
419
445
  return parsed.data;
420
446
  };
421
447
 
448
+ /** The bytes at `url`, with the name and the type the console says, for a file of the project. */
449
+ const read = async (
450
+ url: string,
451
+ fallbackName: string,
452
+ signal?: AbortSignal,
453
+ ): Promise<FileRead> => {
454
+ let response: Response;
455
+ try {
456
+ response = await send(url, {
457
+ method: "GET",
458
+ ...(signal === undefined ? {} : { signal }),
459
+ credentials: token === undefined ? "same-origin" : "omit",
460
+ headers: token === undefined ? {} : { authorization: `Bearer ${token}` },
461
+ });
462
+ } catch (error) {
463
+ if (signal?.aborted === true) {
464
+ throw error;
465
+ }
466
+ throw new ApiError(0, "network", "The server could not be reached.");
467
+ }
468
+ if (!response.ok) {
469
+ throw await failure(response);
470
+ }
471
+ const type = response.headers.get("content-type") ?? "application/octet-stream";
472
+ return {
473
+ name: fileNameOf(response.headers.get("content-disposition") ?? "") ?? fallbackName,
474
+ contentType: type.split(";")[0]?.trim() ?? "application/octet-stream",
475
+ bytes: new Uint8Array(await response.arrayBuffer()),
476
+ };
477
+ };
478
+
422
479
  const flag = (value: boolean | undefined): string | undefined =>
423
480
  value === undefined ? undefined : String(value);
424
481
  const number = (value: number | undefined): string | undefined =>
@@ -440,6 +497,22 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
440
497
  return form;
441
498
  };
442
499
 
500
+ /** A comment with files as the parts of a form: the part `body`, and a part `file` for each file. */
501
+ const commentFormOf = (input: CommentInput): FormData => {
502
+ const form = new FormData();
503
+ form.set("body", input.body);
504
+ for (const file of input.files ?? []) {
505
+ const bytes =
506
+ file.bytes instanceof Blob ? file.bytes : new Blob([Uint8Array.from(file.bytes)]);
507
+ form.append(
508
+ "file",
509
+ new File([bytes], file.name, { type: file.contentType ?? "application/octet-stream" }),
510
+ file.name,
511
+ );
512
+ }
513
+ return form;
514
+ };
515
+
443
516
  const projectClient = (key: string): ProjectClient => {
444
517
  const at = (collection?: Parameters<typeof projectPath>[1], id?: string) =>
445
518
  projectPath(key, collection, id);
@@ -530,7 +603,23 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
530
603
  request("POST", `${base}${at("runs", runId)}/artifacts`, input, restRunSchema),
531
604
  handInFile: (runId, input) =>
532
605
  request("POST", `${base}${at("runs", runId)}/files`, formOf(input), restRunSchema),
533
- fileUrl: (artifactId) => `${base}${at("files", artifactId)}`,
606
+ fileUrl: (fileId) => `${base}${at("files", fileId)}`,
607
+ readFile: (fileId, signal) => read(`${base}${at("files", fileId)}`, fileId, signal),
608
+ comments: (taskId, signal) =>
609
+ request(
610
+ "GET",
611
+ `${base}${at("tasks", taskId)}/comments`,
612
+ undefined,
613
+ restCommentsSchema,
614
+ signal,
615
+ ),
616
+ comment: (taskId, input) =>
617
+ request(
618
+ "POST",
619
+ `${base}${at("tasks", taskId)}/comments`,
620
+ (input.files?.length ?? 0) > 0 ? commentFormOf(input) : { body: input.body },
621
+ restCommentSchema,
622
+ ),
534
623
  endRun: (runId, input) =>
535
624
  request("POST", `${base}${at("runs", runId)}/end`, input, restRunSchema),
536
625
  events: (after, filter = {}, signal) =>
@@ -668,3 +757,23 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
668
757
  signOut: () => request("POST", `${base}${AUTH_ROUTES.logout}`, undefined, undefined),
669
758
  };
670
759
  }
760
+
761
+ /**
762
+ * The file's name as a `content-disposition` header says it: the exact one (`filename*`, UTF-8,
763
+ * percent-encoded) when it is readable, else the plain one in quotes; nothing when neither is.
764
+ */
765
+ export function fileNameOf(disposition: string): string | undefined {
766
+ const exact = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
767
+ if (exact?.[1] !== undefined) {
768
+ try {
769
+ const decoded = decodeURIComponent(exact[1].trim());
770
+ if (decoded.length > 0) {
771
+ return decoded;
772
+ }
773
+ } catch {
774
+ // Not percent-encoded as it should be: the plain name below is what there is.
775
+ }
776
+ }
777
+ const plain = /filename="([^"]*)"/i.exec(disposition);
778
+ return plain?.[1] === undefined || plain[1].length === 0 ? undefined : plain[1];
779
+ }