@skillcdn/console 0.3.0 → 0.3.1

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 (129) hide show
  1. package/README.md +3 -3
  2. package/dist/api.d.ts +2 -2
  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 +1 -1
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +185 -28
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/credentials.d.ts +5 -0
  11. package/dist/cli/credentials.d.ts.map +1 -1
  12. package/dist/cli/credentials.js +2 -0
  13. package/dist/cli/credentials.js.map +1 -1
  14. package/dist/cli/directory.d.ts +7 -0
  15. package/dist/cli/directory.d.ts.map +1 -1
  16. package/dist/cli/directory.js +7 -0
  17. package/dist/cli/directory.js.map +1 -1
  18. package/dist/cli/format.d.ts +10 -1
  19. package/dist/cli/format.d.ts.map +1 -1
  20. package/dist/cli/format.js +67 -13
  21. package/dist/cli/format.js.map +1 -1
  22. package/dist/cli/help.d.ts.map +1 -1
  23. package/dist/cli/help.js +30 -13
  24. package/dist/cli/help.js.map +1 -1
  25. package/dist/client.d.ts +26 -1
  26. package/dist/client.d.ts.map +1 -1
  27. package/dist/client.js +14 -1
  28. package/dist/client.js.map +1 -1
  29. package/dist/components/calendar.d.ts +5 -1
  30. package/dist/components/calendar.d.ts.map +1 -1
  31. package/dist/components/calendar.js +3 -3
  32. package/dist/components/calendar.js.map +1 -1
  33. package/dist/components/event-feed.d.ts.map +1 -1
  34. package/dist/components/event-feed.js +2 -0
  35. package/dist/components/event-feed.js.map +1 -1
  36. package/dist/components/pack-connect.d.ts +23 -0
  37. package/dist/components/pack-connect.d.ts.map +1 -0
  38. package/dist/components/pack-connect.js +86 -0
  39. package/dist/components/pack-connect.js.map +1 -0
  40. package/dist/components/pack-token.d.ts +27 -0
  41. package/dist/components/pack-token.d.ts.map +1 -0
  42. package/dist/components/pack-token.js +11 -0
  43. package/dist/components/pack-token.js.map +1 -0
  44. package/dist/components/projects.d.ts +13 -0
  45. package/dist/components/projects.d.ts.map +1 -1
  46. package/dist/components/projects.js +37 -5
  47. package/dist/components/projects.js.map +1 -1
  48. package/dist/components/work.d.ts +3 -1
  49. package/dist/components/work.d.ts.map +1 -1
  50. package/dist/components/work.js +1 -1
  51. package/dist/components/work.js.map +1 -1
  52. package/dist/components/workspace.d.ts +46 -0
  53. package/dist/components/workspace.d.ts.map +1 -0
  54. package/dist/components/workspace.js +124 -0
  55. package/dist/components/workspace.js.map +1 -0
  56. package/dist/console.css +156 -0
  57. package/dist/console.d.ts.map +1 -1
  58. package/dist/console.js +60 -21
  59. package/dist/console.js.map +1 -1
  60. package/dist/data.d.ts +16 -1
  61. package/dist/data.d.ts.map +1 -1
  62. package/dist/data.js +37 -0
  63. package/dist/data.js.map +1 -1
  64. package/dist/dates.d.ts +12 -2
  65. package/dist/dates.d.ts.map +1 -1
  66. package/dist/dates.js +20 -2
  67. package/dist/dates.js.map +1 -1
  68. package/dist/i18n/en.d.ts +53 -0
  69. package/dist/i18n/en.d.ts.map +1 -1
  70. package/dist/i18n/en.js +65 -8
  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 +60 -7
  74. package/dist/i18n/ko.js.map +1 -1
  75. package/dist/index.d.ts +3 -0
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +3 -0
  78. package/dist/index.js.map +1 -1
  79. package/dist/router.d.ts +4 -4
  80. package/dist/router.d.ts.map +1 -1
  81. package/dist/router.js +11 -2
  82. package/dist/router.js.map +1 -1
  83. package/dist/routes.d.ts +4 -0
  84. package/dist/routes.d.ts.map +1 -1
  85. package/dist/routes.js +4 -0
  86. package/dist/routes.js.map +1 -1
  87. package/dist/schemas.d.ts +182 -2
  88. package/dist/schemas.d.ts.map +1 -1
  89. package/dist/schemas.js +110 -13
  90. package/dist/schemas.js.map +1 -1
  91. package/dist/vocabulary.d.ts +19 -1
  92. package/dist/vocabulary.d.ts.map +1 -1
  93. package/dist/vocabulary.js +16 -0
  94. package/dist/vocabulary.js.map +1 -1
  95. package/docs/specs/cli.md +16 -11
  96. package/docs/specs/rest.md +31 -8
  97. package/package.json +2 -2
  98. package/skills/building-a-console/SKILL.md +5 -5
  99. package/skills/building-a-console/references/the-look-from-a-website.md +39 -0
  100. package/skills/deploying-a-console/SKILL.md +8 -7
  101. package/skills/working-the-board/SKILL.md +2 -2
  102. package/skills/working-the-board/references/setting-up.md +1 -1
  103. package/skills/working-the-board/references/terms.md +2 -0
  104. package/skills/working-the-board/references/the-sentence.md +1 -1
  105. package/src/api.ts +16 -0
  106. package/src/cli/cli.ts +253 -33
  107. package/src/cli/credentials.ts +7 -0
  108. package/src/cli/directory.ts +9 -0
  109. package/src/cli/format.ts +72 -13
  110. package/src/cli/help.ts +36 -13
  111. package/src/client.ts +84 -0
  112. package/src/components/calendar.tsx +8 -2
  113. package/src/components/event-feed.tsx +2 -0
  114. package/src/components/pack-connect.tsx +179 -0
  115. package/src/components/pack-token.tsx +74 -0
  116. package/src/components/projects.tsx +108 -9
  117. package/src/components/work.tsx +4 -1
  118. package/src/components/workspace.tsx +298 -0
  119. package/src/console.tsx +173 -29
  120. package/src/data.ts +57 -0
  121. package/src/dates.ts +23 -2
  122. package/src/i18n/en.ts +76 -8
  123. package/src/i18n/ko.ts +67 -7
  124. package/src/index.ts +16 -0
  125. package/src/router.ts +17 -5
  126. package/src/routes.ts +4 -0
  127. package/src/schemas.ts +129 -12
  128. package/src/styles/console.css +156 -0
  129. package/src/vocabulary.ts +25 -0
package/src/cli/cli.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  type RestClaim,
17
17
  type RestDecision,
18
18
  type RestMember,
19
+ type RestPackConnectStarted,
19
20
  type RestPerson,
20
21
  type RestSkillBrowse,
21
22
  type RestSkillFile,
@@ -28,7 +29,9 @@ import {
28
29
  type ColorName,
29
30
  GOAL_STATES,
30
31
  type GoalState,
32
+ PEOPLE_VISIBILITIES,
31
33
  PERSON_ROLES,
34
+ type PeopleVisibility,
32
35
  type PersonRole,
33
36
  PROJECT_ROLES,
34
37
  PROJECT_VISIBILITIES,
@@ -40,6 +43,7 @@ import {
40
43
  TASK_STATES,
41
44
  type TaskPriority,
42
45
  type TaskState,
46
+ WEEK_STARTS,
43
47
  } from "../vocabulary.js";
44
48
  import { loadWebRoot, type WebRoot, WebRootError } from "../web.js";
45
49
  import {
@@ -53,7 +57,7 @@ import {
53
57
  withLogin,
54
58
  withoutLogin,
55
59
  } from "./credentials.js";
56
- import type { DirectorySettings, DirectoryStore } from "./directory.js";
60
+ import { type DirectorySettings, type DirectoryStore, THE_SENTENCE } from "./directory.js";
57
61
  import {
58
62
  formatAnswer,
59
63
  formatCategoryLine,
@@ -80,6 +84,9 @@ import {
80
84
  formatVersion,
81
85
  formatVersionLine,
82
86
  formatWork,
87
+ formatWorkspace,
88
+ moment,
89
+ setMomentZone,
83
90
  } from "./format.js";
84
91
  import { COMMAND_HELP, helpFor, ownSkillPath, usage } from "./help.js";
85
92
  import { describeInstall, type Install, updateCommandFor } from "./install.js";
@@ -310,6 +317,14 @@ async function open(io: CliIo, values: Values): Promise<Session> {
310
317
  throw failed(wordsOf(resolved));
311
318
  }
312
319
  const { url, token } = resolved;
320
+ // Moments are printed in the workspace's time zone as the login kept it (ADR-0035); the
321
+ // environment's pair keeps nothing, and they stay as the API gives them.
322
+ setMomentZone(
323
+ resolved.via === "login"
324
+ ? credentials?.logins.find((kept) => kept.url === url && kept.account === resolved.account)
325
+ ?.timeZone
326
+ : undefined,
327
+ );
313
328
  const client = clientOf(io, url, token);
314
329
  const projectKey = async () =>
315
330
  text(values, "project") ?? io.env.CONSOLE_PROJECT ?? directory?.project;
@@ -484,18 +499,25 @@ async function personIdOf(
484
499
  return (await personOf(session, value)).id;
485
500
  }
486
501
 
487
- /** The person a login names, among the workspace's; the logins are as people sign in. */
502
+ /**
503
+ * The person a login names, among the workspace's, found by their exact login as people sign in
504
+ * (ADR-0035): whom the console shows the person, and, for an owner of a project, anyone of the
505
+ * workspace, so that someone the People page does not show can still be listed.
506
+ */
488
507
  async function personOf(session: Session, login: string): Promise<RestPerson> {
489
- const { items } = await session.client.people();
490
- const found = items.find((person) => person.login.toLowerCase() === login.toLowerCase());
508
+ const found = await session.client.findPerson(login);
491
509
  if (found === undefined) {
492
510
  throw misuse(
493
- `Nobody of the workspace is called ${login}. console people lists everyone, by login.`,
511
+ `Nobody of the workspace is called ${login}, or they are not yours to see. console people lists whom you see, by login.`,
494
512
  );
495
513
  }
496
514
  return found;
497
515
  }
498
516
 
517
+ /** Who sees whom, as the command takes it: with a hyphen or an underscore. */
518
+ const peopleVisibilityOf = (value: string): PeopleVisibility =>
519
+ wordOf(value.replaceAll("-", "_"), PEOPLE_VISIBILITIES, "who sees whom");
520
+
499
521
  /** One word of a vocabulary, or the refusal to understand. */
500
522
  function wordOf<T extends string>(value: string, words: readonly T[], what: string): T {
501
523
  const found = words.find((word) => word === value);
@@ -652,7 +674,14 @@ async function keep(
652
674
  }
653
675
  const next = withLogin(
654
676
  credentials,
655
- { url, account: person.login, token, agent, since: new Date(io.now()).toISOString() },
677
+ {
678
+ url,
679
+ account: person.login,
680
+ token,
681
+ agent,
682
+ since: new Date(io.now()).toISOString(),
683
+ timeZone: me.workspace.timeZone ?? undefined,
684
+ },
656
685
  { default: asDefault },
657
686
  );
658
687
  const kept = await io.store.save(next);
@@ -826,6 +855,24 @@ function agentOf(env: CliIo["env"]): string | undefined {
826
855
  return undefined;
827
856
  }
828
857
 
858
+ /**
859
+ * Keeps the workspace's time zone on the login the command used (ADR-0035), so that moments are
860
+ * printed in it from now on without asking; the environment's pair keeps nothing.
861
+ */
862
+ async function rememberZone(io: CliIo, session: Session, zone: string | undefined): Promise<void> {
863
+ setMomentZone(zone);
864
+ if (session.via !== "login" || session.credentials === undefined) {
865
+ return;
866
+ }
867
+ const kept = session.credentials.logins.find(
868
+ (login) => login.url === session.url && login.account === session.account,
869
+ );
870
+ if (kept === undefined || kept.timeZone === zone) {
871
+ return;
872
+ }
873
+ await io.store.save(withLogin(session.credentials, { ...kept, timeZone: zone }));
874
+ }
875
+
829
876
  const whoami: Command = async (args, io) => {
830
877
  const { values } = parse(args, {}, false);
831
878
  const session = await open(io, values);
@@ -834,6 +881,7 @@ const whoami: Command = async (args, io) => {
834
881
  if (person === null) {
835
882
  throw failed("The console does not know this token. Sign in again: console login");
836
883
  }
884
+ await rememberZone(io, session, me.workspace.timeZone ?? undefined);
837
885
  const key = await session.projectKey();
838
886
  const command = parseVersion(io.version);
839
887
  const server = parseVersion(me.console?.version);
@@ -914,12 +962,15 @@ const use: Command = async (args, io) => {
914
962
  const bound = pinUrl
915
963
  ? ` at ${session.url}${pinAccount && session.account !== undefined ? ` as ${session.account}` : ""}`
916
964
  : "";
917
- answer(
918
- io,
919
- session,
920
- project,
921
- () =>
965
+ answer(io, session, project, () =>
966
+ [
922
967
  `Working in ${project.name} (${project.key})${bound} from this directory on: kept in ${kept}, which is meant to be committed in a private repository and left out of a public one. What its people ask of agents: console rules.`,
968
+ // Nothing sends an agent to the console until the checkout says so; the sentence is the
969
+ // skill's (references/the-sentence.md), printed here as `skillcdn use` prints its own.
970
+ "Then put this sentence into the checkout's agreement (AGENTS.md, or CLAUDE.md where that is the file agents read), as it is, so that every agent working here is sent to the console:",
971
+ "",
972
+ THE_SENTENCE,
973
+ ].join("\n"),
923
974
  );
924
975
  return EXIT.ok;
925
976
  };
@@ -1333,7 +1384,12 @@ const newTask: Command = async (args, io) => {
1333
1384
  ...(body === undefined ? {} : { body }),
1334
1385
  ...fields,
1335
1386
  });
1336
- answer(io, session, made, () => `Task #${made.number} written: ${made.title}\nid: ${made.id}`);
1387
+ answer(
1388
+ io,
1389
+ session,
1390
+ made,
1391
+ () => `Task #${made.number} written: ${made.title}\ntask id: ${made.id}`,
1392
+ );
1337
1393
  return EXIT.ok;
1338
1394
  };
1339
1395
 
@@ -1556,6 +1612,9 @@ const take: Command = async (args, io) => {
1556
1612
  answer(io, session, { run, task: taken, comments: comments.items, rules }, () =>
1557
1613
  [
1558
1614
  `Run ${run.id} began on #${found.number} ${found.title}, as "${run.agent}" for ${run.person.login}. The task is in progress and theirs.${tookOver}${elsewhere}`,
1615
+ // The ids on a line of their own, each named: a run's and a task's begin alike, and a
1616
+ // task has a number for people as well.
1617
+ `run id: ${run.id} task id: ${found.id} (task #${found.number})`,
1559
1618
  "",
1560
1619
  formatTask(taken, undefined, undefined, comments.items),
1561
1620
  "",
@@ -1701,7 +1760,7 @@ const ask: Command = async (args, io) => {
1701
1760
  io.stdout(
1702
1761
  session.json
1703
1762
  ? `${JSON.stringify(raised, null, 2)}\n`
1704
- : `Decision ${raised.id} raised on run ${runId}; the run waits for a person.\n${formatOptions(raised)}\n`,
1763
+ : `Decision ${raised.id} raised on run ${runId}; the run waits for a person.\ndecision id: ${raised.id}\n${formatOptions(raised)}\n`,
1705
1764
  );
1706
1765
  return settle(io, session, project, raised, seconds, true);
1707
1766
  };
@@ -1950,7 +2009,7 @@ const newGoal: Command = async (args, io) => {
1950
2009
  ...(startsOn === undefined ? {} : { startsOn }),
1951
2010
  ...(dueOn === undefined ? {} : { dueOn }),
1952
2011
  });
1953
- answer(io, session, made, () => `Goal set: ${made.title}\nid: ${made.id}`);
2012
+ answer(io, session, made, () => `Goal set: ${made.title}\ngoal id: ${made.id}`);
1954
2013
  return EXIT.ok;
1955
2014
  };
1956
2015
 
@@ -2186,6 +2245,81 @@ const people: Command = async (args, io) => {
2186
2245
  return EXIT.ok;
2187
2246
  };
2188
2247
 
2248
+ // The workspace's settings (ADR-0035): read by everyone, changed by an administrator, themselves
2249
+ // on the Settings page or through their agent here (ADR-0021).
2250
+ const WORKSPACE_FLAGS =
2251
+ "--people-visibility <everyone|shared-projects>, --week-start <auto|monday|sunday>, --time-zone <zone|none>";
2252
+ const WORKSPACE_USAGE = `Say what to do: console workspace | console workspace edit [${WORKSPACE_FLAGS.replaceAll(", ", "] [")}] | console workspace pack-token --connect|--stdin|--remove`;
2253
+
2254
+ const workspace: Command = async (args, io) => {
2255
+ const [verb, ...rest] = args;
2256
+ if (verb === undefined || verb.startsWith("--")) {
2257
+ const { values } = parse(args, {}, false);
2258
+ const session = await open(io, values);
2259
+ const me = await session.client.me();
2260
+ await rememberZone(io, session, me.workspace.timeZone ?? undefined);
2261
+ answer(io, session, me.workspace, () => formatWorkspace(me.workspace));
2262
+ return EXIT.ok;
2263
+ }
2264
+ if (verb === "edit") {
2265
+ const { values } = parse(
2266
+ rest,
2267
+ {
2268
+ "people-visibility": { type: "string" },
2269
+ "week-start": { type: "string" },
2270
+ "time-zone": { type: "string" },
2271
+ },
2272
+ false,
2273
+ );
2274
+ const visibility = text(values, "people-visibility");
2275
+ const weekStart = text(values, "week-start");
2276
+ const zone = text(values, "time-zone");
2277
+ if (visibility === undefined && weekStart === undefined && zone === undefined) {
2278
+ throw misuse(`Say what to change: ${WORKSPACE_FLAGS}`);
2279
+ }
2280
+ const session = await open(io, values);
2281
+ const changed = await session.client.updateWorkspace({
2282
+ ...(visibility === undefined ? {} : { peopleVisibility: peopleVisibilityOf(visibility) }),
2283
+ ...(weekStart === undefined
2284
+ ? {}
2285
+ : { weekStart: wordOf(weekStart, WEEK_STARTS, "a day the week begins on") }),
2286
+ ...(zone === undefined ? {} : { timeZone: zone === "none" ? null : zone }),
2287
+ });
2288
+ await rememberZone(io, session, changed.timeZone ?? undefined);
2289
+ answer(io, session, changed, () => formatWorkspace(changed));
2290
+ return EXIT.ok;
2291
+ }
2292
+ if (verb === "pack-token") {
2293
+ const { values, positionals } = parse(rest, PACK_TOKEN_FLAGS);
2294
+ if (positionals.length > 0) {
2295
+ throw misuse(
2296
+ "The workspace's token names no project: console workspace pack-token --connect, --stdin or --remove",
2297
+ );
2298
+ }
2299
+ const way = wayOf(values);
2300
+ const session = await open(io, values);
2301
+ const { client } = session;
2302
+ return packToken(
2303
+ io,
2304
+ session,
2305
+ way,
2306
+ {
2307
+ set: (token) => client.setWorkspacePackToken({ token }),
2308
+ remove: () => client.removeWorkspacePackToken(),
2309
+ connect: () => client.connectWorkspacePackToken(),
2310
+ async standing() {
2311
+ const state = await client.workspacePackTokenConnection();
2312
+ return state.status === "connected"
2313
+ ? { status: "connected", held: state.workspace }
2314
+ : state;
2315
+ },
2316
+ },
2317
+ formatWorkspace,
2318
+ );
2319
+ }
2320
+ throw misuse(WORKSPACE_USAGE);
2321
+ };
2322
+
2189
2323
  const PERSON_USAGE =
2190
2324
  "Say what to do: console person role <login> <admin|member> | person add <login> [--host gh|google] | person remove <login> | person deactivate <login> | person reactivate <login>";
2191
2325
 
@@ -2309,35 +2443,118 @@ const editProject: Command = async (args, io) => {
2309
2443
  return EXIT.ok;
2310
2444
  };
2311
2445
 
2312
- /** The pack token (ADR-0028): read from standard input and never from an argument, so that no shell keeps it. */
2313
- const projectPackToken: Command = async (args, io) => {
2314
- const { values, positionals } = parse(args, {
2315
- stdin: { type: "boolean" },
2316
- remove: { type: "boolean" },
2317
- });
2318
- const key = theOne(
2319
- positionals,
2320
- "Say which project: console project pack-token <key> --stdin, or console project pack-token <key> --remove",
2321
- );
2322
- if (on(values, "stdin") === on(values, "remove")) {
2446
+ /** How a pack's connection ended short of a token (ADR-0036), in words for the person. */
2447
+ const PACK_CONNECT_ENDED = {
2448
+ expired: "The code expired before anyone approved it. Run the command again for a new one.",
2449
+ declined: "The request was declined at SkillCDN, or the code was spent.",
2450
+ refused:
2451
+ "SkillCDN refused the request: the repository is not one the person may open, the pack is public and needs no token, or they hold as many tokens as one may.",
2452
+ } as const;
2453
+
2454
+ /** How a SkillCDN token is to change, one of the three the flags name. */
2455
+ const wayOf = (values: Values): "connect" | "stdin" | "remove" => {
2456
+ const ways = (["connect", "stdin", "remove"] as const).filter((way) => on(values, way));
2457
+ const [way] = ways;
2458
+ if (way === undefined || ways.length !== 1) {
2323
2459
  throw misuse(
2324
- "Say one of --stdin, which reads the token from standard input, and --remove, which takes it away.",
2460
+ "Say one of --connect, which asks SkillCDN for a code a person approves; --stdin, which reads a token made by hand from standard input; and --remove, which takes the token away.",
2325
2461
  );
2326
2462
  }
2327
- const session = await open(io, values);
2328
- const project = session.client.project(key);
2329
- if (on(values, "remove")) {
2330
- const changed = await project.removePackToken();
2331
- answer(io, session, changed, () => formatProject(changed));
2463
+ return way;
2464
+ };
2465
+
2466
+ /** Whose SkillCDN token the command works: a project's, or the workspace's for the organization's pack. */
2467
+ interface TokenHolder<T> {
2468
+ set(token: string): Promise<T>;
2469
+ remove(): Promise<T>;
2470
+ connect(): Promise<RestPackConnectStarted>;
2471
+ standing(): Promise<
2472
+ | { readonly status: "none" }
2473
+ | { readonly status: "pending" }
2474
+ | { readonly status: "ended"; readonly reason: keyof typeof PACK_CONNECT_ENDED }
2475
+ | { readonly status: "connected"; readonly held: T }
2476
+ >;
2477
+ }
2478
+
2479
+ /**
2480
+ * The SkillCDN token (ADR-0028): connected with a code a person approves at SkillCDN (ADR-0036),
2481
+ * or read from standard input and never from an argument, so that no shell keeps it; or taken
2482
+ * away. The same for a project's and for the workspace's.
2483
+ */
2484
+ async function packToken<T>(
2485
+ io: CliIo,
2486
+ session: Session,
2487
+ way: "connect" | "stdin" | "remove",
2488
+ holder: TokenHolder<T>,
2489
+ format: (held: T) => string,
2490
+ ): Promise<number> {
2491
+ if (way === "remove") {
2492
+ const changed = await holder.remove();
2493
+ answer(io, session, changed, () => format(changed));
2332
2494
  return EXIT.ok;
2333
2495
  }
2496
+ if (way === "connect") {
2497
+ const started = await holder.connect();
2498
+ io.stdout(
2499
+ `To connect the pack, a person who may open its repository opens ${started.url} signed in at SkillCDN, checks that the page shows the code ${started.code}, and approves; the console receives the SkillCDN token and keeps it sealed, shown to nobody.\nWaiting for that, until ${moment(started.expiresAt)}.\n`,
2500
+ );
2501
+ for (;;) {
2502
+ await io.sleep(started.intervalSeconds * 1000);
2503
+ const state = await holder.standing();
2504
+ if (state.status === "pending") {
2505
+ continue;
2506
+ }
2507
+ if (state.status === "connected") {
2508
+ answer(io, session, state.held, () => `Connected.\n${format(state.held)}`);
2509
+ return EXIT.ok;
2510
+ }
2511
+ if (state.status === "ended") {
2512
+ throw failed(PACK_CONNECT_ENDED[state.reason]);
2513
+ }
2514
+ throw failed(
2515
+ "The console no longer waits for the connection; run the command again for a new code.",
2516
+ );
2517
+ }
2518
+ }
2334
2519
  const token = (await io.readStdin()).trim();
2335
2520
  if (token.length === 0) {
2336
2521
  throw failed("No token was given on standard input.");
2337
2522
  }
2338
- const changed = await project.setPackToken({ token });
2339
- answer(io, session, changed, () => formatProject(changed));
2523
+ const changed = await holder.set(token);
2524
+ answer(io, session, changed, () => format(changed));
2340
2525
  return EXIT.ok;
2526
+ }
2527
+
2528
+ const PACK_TOKEN_FLAGS = {
2529
+ connect: { type: "boolean" },
2530
+ stdin: { type: "boolean" },
2531
+ remove: { type: "boolean" },
2532
+ } as const;
2533
+
2534
+ const projectPackToken: Command = async (args, io) => {
2535
+ const { values, positionals } = parse(args, PACK_TOKEN_FLAGS);
2536
+ const key = theOne(
2537
+ positionals,
2538
+ "Say which project: console project pack-token <key> --connect, --stdin or --remove",
2539
+ );
2540
+ const way = wayOf(values);
2541
+ const session = await open(io, values);
2542
+ const project = session.client.project(key);
2543
+ return packToken(
2544
+ io,
2545
+ session,
2546
+ way,
2547
+ {
2548
+ set: (token) => project.setPackToken({ token }),
2549
+ remove: () => project.removePackToken(),
2550
+ connect: () => project.connectPackToken(),
2551
+ async standing() {
2552
+ const state = await project.packTokenConnection();
2553
+ return state.status === "connected" ? { status: "connected", held: state.project } : state;
2554
+ },
2555
+ },
2556
+ formatProject,
2557
+ );
2341
2558
  };
2342
2559
 
2343
2560
  const projectIcon: Command = async (args, io) => {
@@ -2564,6 +2781,7 @@ const COMMANDS: Readonly<Record<string, Command>> = {
2564
2781
  rules,
2565
2782
  people,
2566
2783
  person,
2784
+ workspace,
2567
2785
  project,
2568
2786
  members,
2569
2787
  member,
@@ -2715,6 +2933,8 @@ function hintFor(error: ApiError, io: CliIo, seen: ConsoleSeen): string {
2715
2933
  return "The person is listed already. console member role <login> <owner|member> changes what they are.\n";
2716
2934
  case "member.not_found":
2717
2935
  return "The person is not listed in the project. console members lists those who are.\n";
2936
+ case "member.open_project":
2937
+ return "The project is open to the workspace, so everyone of it is a member already. console member add <login> --role owner lists an owner; console member role <login> member takes one back to member.\n";
2718
2938
  case "category.key_taken":
2719
2939
  return "A category has that key already. console categories lists them.\n";
2720
2940
  case "category.not_found":
@@ -22,6 +22,11 @@ export interface Login {
22
22
  readonly agent: string | undefined;
23
23
  /** When the command was signed in, when known. */
24
24
  readonly since: string | undefined;
25
+ /**
26
+ * The workspace's time zone as the console last said it, at login or whoami (ADR-0035): what
27
+ * the command prints moments in, without asking again; none when the workspace names none.
28
+ */
29
+ readonly timeZone?: string | undefined;
25
30
  }
26
31
 
27
32
  /** Everything the command keeps: the logins, and the console a directory that names none works at. */
@@ -71,6 +76,7 @@ const loginSchema = z.object({
71
76
  token: z.string().check(z.minLength(1), z.maxLength(1024)),
72
77
  agent: z.optional(z.string().check(z.maxLength(200))),
73
78
  since: z.optional(z.string().check(z.maxLength(40))),
79
+ timeZone: z.optional(z.string().check(z.minLength(1), z.maxLength(64))),
74
80
  });
75
81
  const fileSchema = z.object({
76
82
  version: z.literal(2),
@@ -95,6 +101,7 @@ export function parseCredentials(json: unknown): Credentials | undefined {
95
101
  token: login.token,
96
102
  agent: login.agent,
97
103
  since: login.since,
104
+ ...(login.timeZone === undefined ? {} : { timeZone: login.timeZone }),
98
105
  })),
99
106
  };
100
107
  }
@@ -15,6 +15,15 @@ import * as z from "zod/mini";
15
15
  /** The file's name, in the working directory or one above it. */
16
16
  export const DIRECTORY_FILE = ".skillcdn-console.json";
17
17
 
18
+ /**
19
+ * The sentence a checkout's agreement carries to send the agents working there to the console:
20
+ * the one the skill working-the-board keeps (references/the-sentence.md) and this repository's
21
+ * own agreement carries. `console use` prints it, since nothing tells an agent to look until the
22
+ * checkout says so, as `skillcdn use` prints the sentence for a pack.
23
+ */
24
+ export const THE_SENTENCE =
25
+ "**Bound to a console?** When `.skillcdn-console.json` is in this checkout, or `console whoami` names a project, work the task through that SkillCDN console (`console help` says how).";
26
+
18
27
  /** How large the file may be before it is not read: a few names, never more. */
19
28
  const MAX_FILE_CHARS = 16 * 1024;
20
29
 
package/src/cli/format.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  RestVersion,
20
20
  RestVersionSummary,
21
21
  RestWork,
22
+ RestWorkspace,
22
23
  } from "../schemas.js";
23
24
 
24
25
  // What the command prints: one line per thing in a list, a few lines for one thing in full.
@@ -27,6 +28,48 @@ import type {
27
28
 
28
29
  const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? "" : "s"}`;
29
30
 
31
+ /** The time zone moments are printed in: the workspace's, as the command last learned it (ADR-0035); none leaves them as the API gives them. */
32
+ let momentZone: string | undefined;
33
+
34
+ /** Says which time zone the moments printed from now on are in, or none. */
35
+ export function setMomentZone(zone: string | undefined): void {
36
+ momentZone = zone;
37
+ }
38
+
39
+ /**
40
+ * An instant as the command prints it: in the workspace's time zone, as ISO 8601 with its
41
+ * offset (`2026-10-18T15:04:17+09:00`), when a zone is known; else as the API gave it, in UTC.
42
+ */
43
+ export function moment(iso: string): string {
44
+ if (momentZone === undefined) {
45
+ return iso;
46
+ }
47
+ const date = new Date(iso);
48
+ if (Number.isNaN(date.getTime())) {
49
+ return iso;
50
+ }
51
+ let parts: Intl.DateTimeFormatPart[];
52
+ try {
53
+ parts = new Intl.DateTimeFormat("en-US", {
54
+ timeZone: momentZone,
55
+ hourCycle: "h23",
56
+ year: "numeric",
57
+ month: "2-digit",
58
+ day: "2-digit",
59
+ hour: "2-digit",
60
+ minute: "2-digit",
61
+ second: "2-digit",
62
+ timeZoneName: "longOffset",
63
+ }).formatToParts(date);
64
+ } catch {
65
+ return iso;
66
+ }
67
+ const of = (type: Intl.DateTimeFormatPartTypes): string =>
68
+ parts.find((part) => part.type === type)?.value ?? "";
69
+ const offset = of("timeZoneName").replace("GMT", "");
70
+ return `${of("year")}-${of("month")}-${of("day")}T${of("hour")}:${of("minute")}:${of("second")}${offset === "" ? "Z" : offset}`;
71
+ }
72
+
30
73
  const indent = (text: string): string =>
31
74
  text
32
75
  .trimEnd()
@@ -54,7 +97,7 @@ export function formatProject(project: RestProject): string {
54
97
  `project ${project.key}: ${project.name}`,
55
98
  `visibility: ${project.visibility} role: ${project.role} skills: ${project.skillsAddress ?? "the organization's"}`,
56
99
  `color: ${project.color ?? "none"} symbol: ${project.symbol ?? "none"} image: ${project.icon === null ? "none" : project.icon.contentType}`,
57
- `pack token: ${project.packToken === null ? "none" : `set ${project.packToken.setAt}`}`,
100
+ `pack token: ${project.packToken === null ? "none" : `set ${moment(project.packToken.setAt)}`}`,
58
101
  ];
59
102
  if (project.description.trim().length > 0) {
60
103
  lines.push("", project.description.trimEnd());
@@ -78,9 +121,25 @@ export function formatListingLine(listing: RestListing): string {
78
121
  return `${listing.login} (let in at ${HOST_NAMES[listing.host]}, not signed in yet)`;
79
122
  }
80
123
 
124
+ /** The workspace as its Settings page shows it: what it is called, who sees whom, the week, the time zone, where the skills come from, and what tokens may last. */
125
+ export function formatWorkspace(workspace: RestWorkspace): string {
126
+ const visibility = workspace.peopleVisibility ?? "everyone";
127
+ const zone = workspace.timeZone ?? null;
128
+ const token = workspace.skills.packToken ?? null;
129
+ return [
130
+ `workspace ${workspace.name}`,
131
+ `people visibility: ${visibility} (${visibility === "everyone" ? "everyone sees everyone" : "a member sees only the people they share a project with; an administrator sees everyone"})`,
132
+ `week begins on: ${workspace.weekStart ?? "auto"} (auto: the system default, as each device has it)`,
133
+ `time zone: ${zone ?? "none"} (${zone === null ? "moments are printed as the API gives them, in UTC" : "moments are printed in it, as ISO 8601 with its offset"})`,
134
+ `skills: ${workspace.skills.address ?? "no address of the organization's"} at ${workspace.skills.source}`,
135
+ `SkillCDN token: ${token === null ? "none" : `set ${moment(token.setAt)}`} (${token === null ? "the organization's pack is read with none" : "the organization's pack is read with it by a project with no address and no token of its own"})`,
136
+ `token days at most: ${workspace.tokenDaysAtMost ?? "a person chooses"}`,
137
+ ].join("\n");
138
+ }
139
+
81
140
  /** One member of a project: their login, what they are in it, and since when. */
82
141
  export function formatMemberLine(member: RestMember): string {
83
- return `${member.person.login} ${member.role} (since ${member.addedAt.slice(0, 10)})`;
142
+ return `${member.person.login} ${member.role} (since ${moment(member.addedAt).slice(0, 10)})`;
84
143
  }
85
144
 
86
145
  /** The line under a page of a listing that has more after it: how to ask for the next page. */
@@ -121,9 +180,9 @@ function scheduleLines(task: RestTask): string[] {
121
180
  lines.push(plan.join(" "));
122
181
  }
123
182
  const happened = [
124
- task.startedAt === null ? undefined : `started: ${task.startedAt}`,
125
- task.doneAt === null ? undefined : `done: ${task.doneAt}`,
126
- task.archivedAt === null ? undefined : `archived: ${task.archivedAt}`,
183
+ task.startedAt === null ? undefined : `started: ${moment(task.startedAt)}`,
184
+ task.doneAt === null ? undefined : `done: ${moment(task.doneAt)}`,
185
+ task.archivedAt === null ? undefined : `archived: ${moment(task.archivedAt)}`,
127
186
  ].filter((part) => part !== undefined);
128
187
  if (happened.length > 0) {
129
188
  lines.push(happened.join(" "));
@@ -181,7 +240,7 @@ export function formatTask(
181
240
  export function formatComment(comment: RestComment): string {
182
241
  const who =
183
242
  comment.agent === null ? comment.author.login : `${comment.author.login} as ${comment.agent}`;
184
- const lines = [` ${who}, ${comment.createdAt}, comment ${comment.id}:`];
243
+ const lines = [` ${who}, ${moment(comment.createdAt)}, comment ${comment.id}:`];
185
244
  if (comment.body.trim().length > 0) {
186
245
  lines.push(indent(comment.body));
187
246
  }
@@ -199,7 +258,7 @@ export function formatRunLine(run: RestRun): string {
199
258
  notes.push(`waiting for decision ${run.waitingFor}`);
200
259
  }
201
260
  if (run.endedAt === null) {
202
- notes.push(`last heard ${run.lastHeardAt}`);
261
+ notes.push(`last heard ${moment(run.lastHeardAt)}`);
203
262
  }
204
263
  return `${run.id} ${run.status} ${run.agent} for ${run.person.login} task #${run.taskNumber} (${notes.join(", ")})`;
205
264
  }
@@ -208,7 +267,7 @@ export function formatRunLine(run: RestRun): string {
208
267
  export function formatRun(run: RestRun, fileUrl?: (artifactId: string) => string): string {
209
268
  const lines = [
210
269
  formatRunLine(run),
211
- `started: ${run.startedAt}${run.endedAt === null ? "" : ` ended: ${run.endedAt}`}`,
270
+ `started: ${moment(run.startedAt)}${run.endedAt === null ? "" : ` ended: ${moment(run.endedAt)}`}`,
212
271
  ];
213
272
  if (run.summary !== null) {
214
273
  lines.push("", "summary:", indent(run.summary));
@@ -216,7 +275,7 @@ export function formatRun(run: RestRun, fileUrl?: (artifactId: string) => string
216
275
  if (run.reports.length > 0) {
217
276
  lines.push("", "reports:");
218
277
  for (const report of run.reports) {
219
- lines.push(` [${report.createdAt}]`, indent(report.body));
278
+ lines.push(` [${moment(report.createdAt)}]`, indent(report.body));
220
279
  }
221
280
  }
222
281
  if (run.artifacts.length > 0) {
@@ -246,7 +305,7 @@ function formatRulesHalf(where: "the workspace" | "this project", rules: RestRul
246
305
  const facts =
247
306
  page === null
248
307
  ? undefined
249
- : `v${page.version}, by ${writer(page.updatedBy, page.agent)}; ${page.updatedAt}`;
308
+ : `v${page.version}, by ${writer(page.updatedBy, page.agent)}; ${moment(page.updatedAt)}`;
250
309
  if (rules.source === "none" || rules.body === null) {
251
310
  return `Rules of ${where}: none of its own; the workspace's hold.`;
252
311
  }
@@ -397,7 +456,7 @@ export function formatDocumentLine(document: RestDocumentSummary): string {
397
456
  if (document.archivedAt !== null) {
398
457
  notes.push("archived");
399
458
  }
400
- return `${document.path} ${document.title} (${notes.join(", ")}; ${document.updatedAt})`;
459
+ return `${document.path} ${document.title} (${notes.join(", ")}; ${moment(document.updatedAt)})`;
401
460
  }
402
461
 
403
462
  /** A folder's folders and pages, or the pages a search found. */
@@ -419,7 +478,7 @@ export function formatDocument(
419
478
  ): string {
420
479
  const lines = [
421
480
  `${document.path}: ${document.title}`,
422
- `version ${document.version}, by ${writer(document.updatedBy, document.agent)} at ${document.updatedAt}${document.archivedAt === null ? "" : `; archived ${document.archivedAt}`}`,
481
+ `version ${document.version}, by ${writer(document.updatedBy, document.agent)} at ${moment(document.updatedAt)}${document.archivedAt === null ? "" : `; archived ${moment(document.archivedAt)}`}`,
423
482
  `id: ${document.id}`,
424
483
  ];
425
484
  if (document.body.trim().length > 0) {
@@ -453,7 +512,7 @@ export function formatDocument(
453
512
  }
454
513
 
455
514
  export function formatVersionLine(version: RestVersionSummary): string {
456
- return `v${version.number} ${version.title} (by ${writer(version.author, version.agent)}; ${version.createdAt})`;
515
+ return `v${version.number} ${version.title} (by ${writer(version.author, version.agent)}; ${moment(version.createdAt)})`;
457
516
  }
458
517
 
459
518
  /** One version of a document, with its body as it was. */