@skillcdn/console 0.1.18 → 0.1.19

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 (121) hide show
  1. package/README.md +1 -1
  2. package/SKILLCDN.md +1 -1
  3. package/dist/api.d.ts +1 -1
  4. package/dist/api.d.ts.map +1 -1
  5. package/dist/api.js +1 -1
  6. package/dist/api.js.map +1 -1
  7. package/dist/cli/cli.d.ts +1 -1
  8. package/dist/cli/cli.d.ts.map +1 -1
  9. package/dist/cli/cli.js +353 -39
  10. package/dist/cli/cli.js.map +1 -1
  11. package/dist/cli/credentials.d.ts +102 -3
  12. package/dist/cli/credentials.d.ts.map +1 -1
  13. package/dist/cli/credentials.js +177 -8
  14. package/dist/cli/credentials.js.map +1 -1
  15. package/dist/cli/directory.d.ts +6 -0
  16. package/dist/cli/directory.d.ts.map +1 -1
  17. package/dist/cli/directory.js +37 -6
  18. package/dist/cli/directory.js.map +1 -1
  19. package/dist/cli/format.d.ts +10 -1
  20. package/dist/cli/format.d.ts.map +1 -1
  21. package/dist/cli/format.js +106 -2
  22. package/dist/cli/format.js.map +1 -1
  23. package/dist/cli/help.d.ts.map +1 -1
  24. package/dist/cli/help.js +42 -20
  25. package/dist/cli/help.js.map +1 -1
  26. package/dist/client.d.ts +21 -1
  27. package/dist/client.d.ts.map +1 -1
  28. package/dist/client.js +14 -1
  29. package/dist/client.js.map +1 -1
  30. package/dist/components/board.d.ts +9 -1
  31. package/dist/components/board.d.ts.map +1 -1
  32. package/dist/components/board.js +82 -5
  33. package/dist/components/board.js.map +1 -1
  34. package/dist/components/comments.d.ts.map +1 -1
  35. package/dist/components/comments.js +12 -4
  36. package/dist/components/comments.js.map +1 -1
  37. package/dist/components/event-feed.d.ts.map +1 -1
  38. package/dist/components/event-feed.js +4 -0
  39. package/dist/components/event-feed.js.map +1 -1
  40. package/dist/components/goals.d.ts +17 -1
  41. package/dist/components/goals.d.ts.map +1 -1
  42. package/dist/components/goals.js +34 -9
  43. package/dist/components/goals.js.map +1 -1
  44. package/dist/components/markdown.d.ts +6 -0
  45. package/dist/components/markdown.d.ts.map +1 -1
  46. package/dist/components/markdown.js +7 -0
  47. package/dist/components/markdown.js.map +1 -1
  48. package/dist/components/pictures.d.ts +68 -0
  49. package/dist/components/pictures.d.ts.map +1 -0
  50. package/dist/components/pictures.js +213 -0
  51. package/dist/components/pictures.js.map +1 -0
  52. package/dist/components/projects.d.ts +12 -0
  53. package/dist/components/projects.d.ts.map +1 -1
  54. package/dist/components/projects.js +15 -0
  55. package/dist/components/projects.js.map +1 -1
  56. package/dist/components/task-view.d.ts +3 -0
  57. package/dist/components/task-view.d.ts.map +1 -1
  58. package/dist/components/task-view.js +6 -4
  59. package/dist/components/task-view.js.map +1 -1
  60. package/dist/console.css +282 -3
  61. package/dist/console.d.ts.map +1 -1
  62. package/dist/console.js +124 -10
  63. package/dist/console.js.map +1 -1
  64. package/dist/data.d.ts +16 -1
  65. package/dist/data.d.ts.map +1 -1
  66. package/dist/data.js +47 -2
  67. package/dist/data.js.map +1 -1
  68. package/dist/i18n/en.d.ts +52 -0
  69. package/dist/i18n/en.d.ts.map +1 -1
  70. package/dist/i18n/en.js +50 -2
  71. package/dist/i18n/en.js.map +1 -1
  72. package/dist/i18n/ko.d.ts.map +1 -1
  73. package/dist/i18n/ko.js +50 -2
  74. package/dist/i18n/ko.js.map +1 -1
  75. package/dist/index.d.ts +5 -4
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +5 -4
  78. package/dist/index.js.map +1 -1
  79. package/dist/router.d.ts +25 -6
  80. package/dist/router.d.ts.map +1 -1
  81. package/dist/router.js +38 -7
  82. package/dist/router.js.map +1 -1
  83. package/dist/rules.js +5 -5
  84. package/dist/rules.js.map +1 -1
  85. package/dist/schemas.d.ts +158 -0
  86. package/dist/schemas.d.ts.map +1 -1
  87. package/dist/schemas.js +78 -0
  88. package/dist/schemas.js.map +1 -1
  89. package/dist/vocabulary.d.ts +2 -2
  90. package/dist/vocabulary.d.ts.map +1 -1
  91. package/dist/vocabulary.js +3 -0
  92. package/dist/vocabulary.js.map +1 -1
  93. package/docs/specs/cli.md +17 -9
  94. package/docs/specs/rest.md +22 -4
  95. package/package.json +1 -1
  96. package/skills/working-the-board/SKILL.md +10 -4
  97. package/src/api.ts +13 -0
  98. package/src/cli/cli.ts +441 -42
  99. package/src/cli/credentials.ts +276 -14
  100. package/src/cli/directory.ts +43 -6
  101. package/src/cli/format.ts +138 -2
  102. package/src/cli/help.ts +44 -20
  103. package/src/client.ts +60 -0
  104. package/src/components/board.tsx +154 -8
  105. package/src/components/comments.tsx +20 -10
  106. package/src/components/event-feed.tsx +4 -0
  107. package/src/components/goals.tsx +173 -106
  108. package/src/components/markdown.tsx +19 -0
  109. package/src/components/pictures.tsx +389 -0
  110. package/src/components/projects.tsx +68 -0
  111. package/src/components/task-view.tsx +43 -2
  112. package/src/console.tsx +259 -4
  113. package/src/data.ts +72 -1
  114. package/src/i18n/en.ts +53 -2
  115. package/src/i18n/ko.ts +53 -2
  116. package/src/index.ts +27 -0
  117. package/src/router.ts +57 -9
  118. package/src/rules.ts +5 -5
  119. package/src/schemas.ts +97 -0
  120. package/src/styles/console.css +282 -3
  121. package/src/vocabulary.ts +3 -0
@@ -1,31 +1,270 @@
1
- import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import process from "node:process";
4
5
  import * as z from "zod/mini";
5
6
 
6
- // Where the command keeps what `login` was given: the console's address and the token, in the
7
- // person's own configuration directory, readable by them alone where the system can say so.
8
- // The token is a secret the person made; the file is the one place it rests.
7
+ // Where the command keeps what `login` was given (ADR-0027): for every console a person signed
8
+ // it in to, the console's address, the account there and the token, in the person's own
9
+ // configuration directory, readable by them alone where the system can say so. A token is a
10
+ // secret the person made; this file is the one place it rests, and a token goes only to the
11
+ // console that issued it: the command resolves which console a command talks to first, and then
12
+ // looks for a login there, never the other way round.
9
13
 
10
- export interface Credentials {
14
+ /** One sign-in: a console, an account there, and the token the console handed over. */
15
+ export interface Login {
11
16
  /** The origin of the console, without a path. */
12
17
  readonly url: string;
13
- /** A token made on the console's Agents page. */
18
+ /** The person's login at the console, as the console named them; unknown for a login kept before accounts were. */
19
+ readonly account: string | undefined;
14
20
  readonly token: string;
21
+ /** What the token is called at the console, when known. */
22
+ readonly agent: string | undefined;
23
+ /** When the command was signed in, when known. */
24
+ readonly since: string | undefined;
15
25
  }
16
26
 
27
+ /** Everything the command keeps: the logins, and the console a directory that names none works at. */
28
+ export interface Credentials {
29
+ readonly default: string | undefined;
30
+ readonly logins: readonly Login[];
31
+ }
32
+
33
+ export const EMPTY_CREDENTIALS: Credentials = { default: undefined, logins: [] };
34
+
35
+ /** How many consoles one machine may be signed in to: far more than anyone has. */
36
+ export const MAX_LOGINS = 100;
37
+ /** How large the file may be before it is not read: far above a hundred logins. */
38
+ const MAX_FILE_BYTES = 64 * 1024;
39
+
17
40
  export interface CredentialStore {
41
+ /**
42
+ * What is kept, or nothing. A file that is there and cannot be read is a `CredentialsError`,
43
+ * so that the command stops and never writes over what it did not understand.
44
+ */
18
45
  load(): Promise<Credentials | undefined>;
19
- /** Keeps the credentials and answers where. */
46
+ /** Keeps the credentials, whole, and answers where. */
20
47
  save(credentials: Credentials): Promise<string>;
48
+ /** Forgets everything kept. */
21
49
  clear(): Promise<void>;
22
50
  }
23
51
 
24
- const credentialsSchema = z.object({
52
+ export class CredentialsError extends Error {
53
+ readonly code: "credentials.malformed" | "credentials.too_large";
54
+ readonly path: string;
55
+
56
+ constructor(code: CredentialsError["code"], path: string) {
57
+ super(
58
+ code === "credentials.too_large"
59
+ ? `The credentials file ${path} is larger than the command reads.`
60
+ : `The credentials file ${path} could not be read as one.`,
61
+ );
62
+ this.name = "CredentialsError";
63
+ this.code = code;
64
+ this.path = path;
65
+ }
66
+ }
67
+
68
+ const loginSchema = z.object({
69
+ url: z.string().check(z.minLength(1), z.maxLength(2048)),
70
+ account: z.optional(z.string().check(z.minLength(1), z.maxLength(254))),
71
+ token: z.string().check(z.minLength(1), z.maxLength(1024)),
72
+ agent: z.optional(z.string().check(z.maxLength(200))),
73
+ since: z.optional(z.string().check(z.maxLength(40))),
74
+ });
75
+ const fileSchema = z.object({
76
+ version: z.literal(2),
77
+ default: z.optional(z.string().check(z.minLength(1), z.maxLength(2048))),
78
+ logins: z.array(loginSchema).check(z.maxLength(MAX_LOGINS)),
79
+ });
80
+ /** The file as the command kept it before 2026-10: one console, one token. */
81
+ const firstFileSchema = z.object({
25
82
  url: z.string().check(z.minLength(1)),
26
83
  token: z.string().check(z.minLength(1)),
27
84
  });
28
85
 
86
+ /** What a file says, in the current shape; one in the first shape reads as one login, the default. Nothing for anything else. */
87
+ export function parseCredentials(json: unknown): Credentials | undefined {
88
+ const current = fileSchema.safeParse(json);
89
+ if (current.success) {
90
+ return {
91
+ default: current.data.default,
92
+ logins: current.data.logins.map((login) => ({
93
+ url: login.url,
94
+ account: login.account,
95
+ token: login.token,
96
+ agent: login.agent,
97
+ since: login.since,
98
+ })),
99
+ };
100
+ }
101
+ const first = firstFileSchema.safeParse(json);
102
+ if (first.success) {
103
+ return {
104
+ default: first.data.url,
105
+ logins: [
106
+ {
107
+ url: first.data.url,
108
+ account: undefined,
109
+ token: first.data.token,
110
+ agent: undefined,
111
+ since: undefined,
112
+ },
113
+ ],
114
+ };
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ /** The logins at one console. */
120
+ export function loginsAt(credentials: Credentials, url: string): readonly Login[] {
121
+ return credentials.logins.filter((login) => login.url === url);
122
+ }
123
+
124
+ /** The consoles signed in to, each once, in the order they were signed in to. */
125
+ export function consolesOf(credentials: Credentials): readonly string[] {
126
+ return [...new Set(credentials.logins.map((login) => login.url))];
127
+ }
128
+
129
+ /**
130
+ * With a login kept: in place of the one at the same console for the same account, else added.
131
+ * The first login kept is the default console, and one kept as the default is.
132
+ */
133
+ export function withLogin(
134
+ credentials: Credentials,
135
+ login: Login,
136
+ options: { readonly default?: boolean | undefined } = {},
137
+ ): Credentials {
138
+ const rest = credentials.logins.filter(
139
+ (kept) => !(kept.url === login.url && kept.account === login.account),
140
+ );
141
+ const first = credentials.logins.length === 0 ? login.url : undefined;
142
+ return {
143
+ default: options.default === true ? login.url : (credentials.default ?? first),
144
+ logins: [...rest, login],
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Without the login at a console, for one account or for every account there. The default
150
+ * stays while a login at it remains; else it moves to the one console left, or to none.
151
+ */
152
+ export function withoutLogin(credentials: Credentials, url: string, account?: string): Credentials {
153
+ const logins = credentials.logins.filter(
154
+ (kept) => !(kept.url === url && (account === undefined || kept.account === account)),
155
+ );
156
+ const consoles = [...new Set(logins.map((kept) => kept.url))];
157
+ const [only] = consoles;
158
+ const fallback = consoles.length === 1 ? only : undefined;
159
+ return {
160
+ default:
161
+ credentials.default !== undefined && consoles.includes(credentials.default)
162
+ ? credentials.default
163
+ : fallback,
164
+ logins,
165
+ };
166
+ }
167
+
168
+ /** Where a command is told which console to talk to, in the order the first one found wins. */
169
+ export type AddressSource = "flag" | "environment" | "directory" | "default";
170
+
171
+ export interface Lookup {
172
+ /** `--url`, as an origin. */
173
+ readonly flagUrl?: string | undefined;
174
+ /** `--account`. */
175
+ readonly flagAccount?: string | undefined;
176
+ /** `CONSOLE_URL`, as an origin, and `CONSOLE_TOKEN`: a pair, for a hook or a job. */
177
+ readonly envUrl?: string | undefined;
178
+ readonly envToken?: string | undefined;
179
+ /** What `.skillcdn-console.json` says of the console and the account, when it does. */
180
+ readonly directoryUrl?: string | undefined;
181
+ readonly directoryAccount?: string | undefined;
182
+ readonly credentials: Credentials | undefined;
183
+ }
184
+
185
+ export type Resolution =
186
+ | {
187
+ readonly ok: true;
188
+ readonly url: string;
189
+ readonly token: string;
190
+ readonly account: string | undefined;
191
+ readonly source: AddressSource;
192
+ /** Where the token came from: the environment's pair, or a login kept here. */
193
+ readonly via: "environment" | "login";
194
+ }
195
+ /** Nothing names a console and nothing is kept. */
196
+ | { readonly ok: false; readonly reason: "nothing" }
197
+ /** One half of the environment's pair is set. */
198
+ | {
199
+ readonly ok: false;
200
+ readonly reason: "environment_half";
201
+ readonly have: "CONSOLE_URL" | "CONSOLE_TOKEN";
202
+ }
203
+ /** A console is named, and nothing here is signed in to it, as that account where one is named. */
204
+ | {
205
+ readonly ok: false;
206
+ readonly reason: "no_login";
207
+ readonly url: string;
208
+ readonly account: string | undefined;
209
+ readonly source: AddressSource;
210
+ }
211
+ /** Several accounts are signed in at the console, and nothing says which. */
212
+ | {
213
+ readonly ok: false;
214
+ readonly reason: "ambiguous";
215
+ readonly url: string;
216
+ readonly accounts: readonly string[];
217
+ };
218
+
219
+ /**
220
+ * Which console a command talks to, and with what (ADR-0027). The address comes first: the
221
+ * flag, else the environment, else the directory, else the default login. The token is then
222
+ * the one for that address and no other: the environment's token where its address is that
223
+ * one, else the login kept for it, for the account named or the one account there.
224
+ */
225
+ export function resolveLogin(lookup: Lookup): Resolution {
226
+ const { envUrl, envToken } = lookup;
227
+ if ((envUrl === undefined) !== (envToken === undefined)) {
228
+ return {
229
+ ok: false,
230
+ reason: "environment_half",
231
+ have: envUrl === undefined ? "CONSOLE_TOKEN" : "CONSOLE_URL",
232
+ };
233
+ }
234
+ const credentials = lookup.credentials ?? EMPTY_CREDENTIALS;
235
+ const consoles = consolesOf(credentials);
236
+ const [source, url]: [AddressSource, string | undefined] =
237
+ lookup.flagUrl !== undefined
238
+ ? ["flag", lookup.flagUrl]
239
+ : envUrl !== undefined
240
+ ? ["environment", envUrl]
241
+ : lookup.directoryUrl !== undefined
242
+ ? ["directory", lookup.directoryUrl]
243
+ : ["default", credentials.default ?? (consoles.length === 1 ? consoles[0] : undefined)];
244
+ if (url === undefined) {
245
+ return { ok: false, reason: "nothing" };
246
+ }
247
+ const account = lookup.flagAccount ?? lookup.directoryAccount;
248
+ if (envUrl !== undefined && envToken !== undefined && envUrl === url) {
249
+ return { ok: true, url, token: envToken, account, source, via: "environment" };
250
+ }
251
+ const at = loginsAt(credentials, url);
252
+ const chosen = account === undefined ? at : at.filter((login) => login.account === account);
253
+ const [login] = chosen;
254
+ if (login === undefined) {
255
+ return { ok: false, reason: "no_login", url, account, source };
256
+ }
257
+ if (chosen.length > 1) {
258
+ return {
259
+ ok: false,
260
+ reason: "ambiguous",
261
+ url,
262
+ accounts: chosen.map((kept) => kept.account ?? "?"),
263
+ };
264
+ }
265
+ return { ok: true, url, token: login.token, account: login.account, source, via: "login" };
266
+ }
267
+
29
268
  const FILE_NAME = "credentials.json";
30
269
 
31
270
  /** The directory the file lives in: under `XDG_CONFIG_HOME`, or `~/.config`. */
@@ -41,22 +280,45 @@ export function createFileStore(directory: string): CredentialStore {
41
280
  const path = join(directory, FILE_NAME);
42
281
  return {
43
282
  async load() {
44
- let content: string;
283
+ let size: number;
45
284
  try {
46
- content = await readFile(path, "utf8");
285
+ size = (await stat(path)).size;
47
286
  } catch {
48
287
  return undefined;
49
288
  }
289
+ if (size > MAX_FILE_BYTES) {
290
+ throw new CredentialsError("credentials.too_large", path);
291
+ }
292
+ let json: unknown;
50
293
  try {
51
- const parsed = credentialsSchema.safeParse(JSON.parse(content));
52
- return parsed.success ? parsed.data : undefined;
294
+ json = JSON.parse(await readFile(path, "utf8"));
53
295
  } catch {
54
- return undefined;
296
+ throw new CredentialsError("credentials.malformed", path);
297
+ }
298
+ const parsed = parseCredentials(json);
299
+ if (parsed === undefined) {
300
+ throw new CredentialsError("credentials.malformed", path);
55
301
  }
302
+ return parsed;
56
303
  },
57
304
  async save(credentials) {
58
305
  await mkdir(directory, { recursive: true, mode: 0o700 });
59
- await writeFile(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
306
+ const file = {
307
+ version: 2,
308
+ default: credentials.default,
309
+ logins: credentials.logins.map((login) => ({
310
+ url: login.url,
311
+ account: login.account,
312
+ token: login.token,
313
+ agent: login.agent,
314
+ since: login.since,
315
+ })),
316
+ };
317
+ // Written beside the file and moved over it in one step, so that a command that stops
318
+ // half-way leaves the file as it was, whole.
319
+ const draft = `${path}.${process.pid}.tmp`;
320
+ await writeFile(draft, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
321
+ await rename(draft, path);
60
322
  // The mode above applies to a file made now; one that was there keeps its own, so it is set again.
61
323
  await chmod(path, 0o600).catch(() => undefined);
62
324
  return path;
@@ -4,15 +4,27 @@ import * as z from "zod/mini";
4
4
 
5
5
  // What a working directory says about the project it belongs to: `.skillcdn-console.json` in
6
6
  // it or in a directory above it, as `.nvmrc` and its kind are found. Written by `console use`,
7
- // and meant to be committed, so that everyone who works in the checkout, and every agent they
8
- // run in it, is in the same project without saying so.
7
+ // and meant to be committed in a private repository, so that everyone who works in the
8
+ // checkout, and every agent they run in it, is in the same project without saying so; left out
9
+ // of a public one, since it may name the organization's console. It names the project, and
10
+ // where the checkout is bound to a console other than the default login, that console and the
11
+ // account there (ADR-0027). Never a token, a code or anything secret: the token for the console
12
+ // it names is looked up in the credentials the command keeps, and a key this file does not
13
+ // know is dropped on reading.
9
14
 
10
15
  /** The file's name, in the working directory or one above it. */
11
16
  export const DIRECTORY_FILE = ".skillcdn-console.json";
12
17
 
18
+ /** How large the file may be before it is not read: a few names, never more. */
19
+ const MAX_FILE_CHARS = 16 * 1024;
20
+
13
21
  export interface DirectorySettings {
14
22
  /** The key of the project the directory's work belongs to. */
15
23
  readonly project: string;
24
+ /** The origin of the console the directory is bound to; the default login when left out. */
25
+ readonly url?: string | undefined;
26
+ /** The account at that console, where several are signed in to it. */
27
+ readonly account?: string | undefined;
16
28
  }
17
29
 
18
30
  export interface DirectoryStore {
@@ -22,7 +34,24 @@ export interface DirectoryStore {
22
34
  save(settings: DirectorySettings): Promise<string>;
23
35
  }
24
36
 
25
- const settingsSchema = z.object({ project: z.string().check(z.minLength(1)) });
37
+ const settingsSchema = z.object({
38
+ project: z.string().check(z.minLength(1), z.maxLength(100)),
39
+ url: z.optional(z.string().check(z.minLength(1), z.maxLength(2048))),
40
+ account: z.optional(z.string().check(z.minLength(1), z.maxLength(254))),
41
+ });
42
+
43
+ /** What a file says, with nothing but the three settings kept; nothing for anything else. */
44
+ export function parseDirectorySettings(json: unknown): DirectorySettings | undefined {
45
+ const parsed = settingsSchema.safeParse(json);
46
+ if (!parsed.success) {
47
+ return undefined;
48
+ }
49
+ return {
50
+ project: parsed.data.project,
51
+ ...(parsed.data.url === undefined ? {} : { url: parsed.data.url }),
52
+ ...(parsed.data.account === undefined ? {} : { account: parsed.data.account }),
53
+ };
54
+ }
26
55
 
27
56
  export function createDirectoryStore(cwd: string): DirectoryStore {
28
57
  return {
@@ -36,9 +65,11 @@ export function createDirectoryStore(cwd: string): DirectoryStore {
36
65
  content = undefined;
37
66
  }
38
67
  if (content !== undefined) {
68
+ if (content.length > MAX_FILE_CHARS) {
69
+ return undefined;
70
+ }
39
71
  try {
40
- const parsed = settingsSchema.safeParse(JSON.parse(content));
41
- return parsed.success ? parsed.data : undefined;
72
+ return parseDirectorySettings(JSON.parse(content));
42
73
  } catch {
43
74
  return undefined;
44
75
  }
@@ -52,7 +83,13 @@ export function createDirectoryStore(cwd: string): DirectoryStore {
52
83
  },
53
84
  async save(settings) {
54
85
  const path = join(cwd, DIRECTORY_FILE);
55
- await writeFile(path, `${JSON.stringify(settings, null, 2)}\n`);
86
+ // The three settings, named one by one: nothing else ever reaches this file.
87
+ const written = {
88
+ project: settings.project,
89
+ ...(settings.url === undefined ? {} : { url: settings.url }),
90
+ ...(settings.account === undefined ? {} : { account: settings.account }),
91
+ };
92
+ await writeFile(path, `${JSON.stringify(written, null, 2)}\n`);
56
93
  return path;
57
94
  },
58
95
  };
package/src/cli/format.ts CHANGED
@@ -15,6 +15,9 @@ import type {
15
15
  RestProjectRules,
16
16
  RestRules,
17
17
  RestRun,
18
+ RestSkillFile,
19
+ RestSkillLoad,
20
+ RestSkillPage,
18
21
  RestSkills,
19
22
  RestTask,
20
23
  RestVersion,
@@ -55,6 +58,7 @@ export function formatProject(project: RestProject): string {
55
58
  `project ${project.key}: ${project.name}`,
56
59
  `visibility: ${project.visibility} role: ${project.role} skills: ${project.skillsAddress ?? "the organization's"}`,
57
60
  `color: ${project.color ?? "none"} symbol: ${project.symbol ?? "none"} image: ${project.icon === null ? "none" : project.icon.contentType}`,
61
+ `pack token: ${project.packToken === null ? "none" : `set ${project.packToken.setAt}`}`,
58
62
  ];
59
63
  if (project.description.trim().length > 0) {
60
64
  lines.push("", project.description.trimEnd());
@@ -102,6 +106,9 @@ export function formatTaskLine(task: RestTask): string {
102
106
  if (task.openDecisions > 0) {
103
107
  notes.push(`${plural(task.openDecisions, "decision")} waiting`);
104
108
  }
109
+ if (task.archivedAt !== null) {
110
+ notes.push("archived");
111
+ }
105
112
  return `#${task.number} ${task.state} ${task.priority} ${task.title} (${notes.join("; ")})`;
106
113
  }
107
114
 
@@ -120,6 +127,7 @@ function scheduleLines(task: RestTask): string[] {
120
127
  const happened = [
121
128
  task.startedAt === null ? undefined : `started: ${task.startedAt}`,
122
129
  task.doneAt === null ? undefined : `done: ${task.doneAt}`,
130
+ task.archivedAt === null ? undefined : `archived: ${task.archivedAt}`,
123
131
  ].filter((part) => part !== undefined);
124
132
  if (happened.length > 0) {
125
133
  lines.push(happened.join(" "));
@@ -239,7 +247,10 @@ export function formatArtifact(
239
247
  const SKILLS_WORDS: Readonly<Record<Exclude<RestSkills["status"], "none" | "ready">, string>> = {
240
248
  indexing: "SkillCDN is still indexing the repository; ask again in a moment.",
241
249
  failed: "SkillCDN could not index the repository.",
242
- not_found: "SkillCDN does not serve this address to the console.",
250
+ not_found:
251
+ "SkillCDN does not serve this address to the console: nothing is there, or the pack is private and the project holds no pack token for it (console project pack-token <key> --stdin, for an owner).",
252
+ token_invalid:
253
+ "The pack token the project holds no longer works at SkillCDN: an owner sets a new one (console project pack-token <key> --stdin).",
243
254
  unavailable: "SkillCDN could not be reached; ask again later.",
244
255
  };
245
256
 
@@ -271,7 +282,7 @@ export function formatSkills(skills: RestSkills, ownSkills?: string): string {
271
282
  }
272
283
  lines.push(
273
284
  "",
274
- `Load a skill by its URI through your SkillCDN connection to this address; an agent without one is connected by a person at ${skills.page}. --json adds the page of each, for a person.`,
285
+ `Read a skill whole with console skill <path>, its rules first, and a file of the pack with console skill <path> --file <file>; or load it by its URI through your own SkillCDN connection to this address, which a person makes at ${skills.page}. --json adds the page of each, for a person.`,
275
286
  ...own,
276
287
  );
277
288
  return lines.join("\n");
@@ -495,3 +506,128 @@ export function formatVersion(path: string, version: RestVersion): string {
495
506
  }
496
507
  return lines.join("\n");
497
508
  }
509
+
510
+ /** The text of one path across the pages a skill came in: the fragments joined in order. */
511
+ function joinTexts(
512
+ pages: readonly RestSkillPage[],
513
+ pick: (page: RestSkillPage) => readonly { readonly path: string; readonly body: string }[],
514
+ ): { readonly path: string; readonly body: string }[] {
515
+ const joined = new Map<string, string>();
516
+ for (const page of pages) {
517
+ for (const text of pick(page)) {
518
+ joined.set(text.path, (joined.get(text.path) ?? "") + text.body);
519
+ }
520
+ }
521
+ return [...joined.entries()].map(([path, body]) => ({ path, body }));
522
+ }
523
+
524
+ const NO_ADDRESS = "No skills address: neither this project nor the console names one.";
525
+
526
+ /**
527
+ * One skill, as load_skill lays it out for a model (ADR-0028): the rules from the repository's
528
+ * root to the nearest manifest, the body, the included files, each under a line that names its
529
+ * path; then the files the skill has and what it refers to. A skill described only is named and
530
+ * pointed at, and nothing of its body is shown.
531
+ */
532
+ export function formatSkillLoad(loaded: RestSkillLoad, pages: readonly RestSkillPage[]): string {
533
+ if (loaded.status === "none" || loaded.address === null) {
534
+ return NO_ADDRESS;
535
+ }
536
+ const head = `skill at ${loaded.address}, served by ${loaded.source}: ${loaded.status}`;
537
+ if (loaded.status !== "ready") {
538
+ return [head, SKILLS_WORDS[loaded.status]].join("\n");
539
+ }
540
+ const [first] = pages;
541
+ if (first === undefined) {
542
+ return [head, "Nothing was read."].join("\n");
543
+ }
544
+ const last = pages[pages.length - 1] ?? first;
545
+ const lines = [
546
+ `${first.name} ${first.description}`,
547
+ `skill ${first.path} at ${loaded.address}, served by ${loaded.source}`,
548
+ ];
549
+ if (first.serving.license !== null) {
550
+ lines.push(`license: ${first.serving.license}`);
551
+ }
552
+ if (!first.serving.full) {
553
+ lines.push(
554
+ "",
555
+ `Described only: the license keeps the skill at its source. Read it there: ${first.serving.sourceUrl ?? loaded.page ?? loaded.source}`,
556
+ );
557
+ return lines.join("\n");
558
+ }
559
+ for (const rule of joinTexts(pages, (page) => page.rules)) {
560
+ lines.push("", `--- applicable rules: ${rule.path} ---`, rule.body.trimEnd());
561
+ }
562
+ lines.push(
563
+ "",
564
+ `--- skill: ${first.path} ---`,
565
+ pages
566
+ .map((page) => page.body)
567
+ .join("")
568
+ .trimEnd(),
569
+ );
570
+ for (const file of joinTexts(pages, (page) => page.included)) {
571
+ lines.push("", `--- included file: ${file.path} ---`, file.body.trimEnd());
572
+ }
573
+ if (!last.complete) {
574
+ lines.push("", "The skill did not come whole: the deployment stopped before the end.");
575
+ }
576
+ if (last.files.length > 0) {
577
+ lines.push(
578
+ "",
579
+ `Files of the skill, read with console skill ${first.path} --file <path>: ${last.files.join(", ")}`,
580
+ );
581
+ }
582
+ if (last.references.length > 0) {
583
+ lines.push(
584
+ `References: ${last.references
585
+ .map((reference) =>
586
+ reference.status === "available"
587
+ ? reference.path
588
+ : `${reference.path} (${reference.status})`,
589
+ )
590
+ .join(", ")}`,
591
+ );
592
+ }
593
+ if (last.warnings.length > 0) {
594
+ lines.push(`Warnings: ${last.warnings.join("; ")}`);
595
+ }
596
+ lines.push(
597
+ "",
598
+ "Paths from the repository's root are the only identifiers. The content is the pack's, under its license; what a private pack says stays in your work.",
599
+ );
600
+ return lines.join("\n");
601
+ }
602
+
603
+ /** A file of the pack as read: its text whole, or what the folder holds. */
604
+ export function formatSkillFile(read: RestSkillFile, content: string): string {
605
+ if (read.status === "none" || read.address === null) {
606
+ return NO_ADDRESS;
607
+ }
608
+ const head = `file at ${read.address}, served by ${read.source}: ${read.status}`;
609
+ if (read.status !== "ready") {
610
+ return [
611
+ head,
612
+ read.status === "failed"
613
+ ? "SkillCDN will not carry this file: it is too large, or not text. Read it at the repository."
614
+ : SKILLS_WORDS[read.status],
615
+ ].join("\n");
616
+ }
617
+ if (read.folder !== null) {
618
+ return [
619
+ `folder ${read.folder.path} at ${read.address}${read.folder.truncated ? " (cut)" : ""}`,
620
+ ...read.folder.entries.map(
621
+ (entry) =>
622
+ `${entry.kind === "directory" ? "d" : "f"} ${entry.path}${entry.size === null ? "" : ` ${entry.size}`}`,
623
+ ),
624
+ ].join("\n");
625
+ }
626
+ if (read.file === null) {
627
+ return `${head}: nothing was read.`;
628
+ }
629
+ return [
630
+ `--- file: ${read.file.path} (${read.file.totalLength} characters) ---`,
631
+ content.trimEnd(),
632
+ ].join("\n");
633
+ }