@skillcdn/console 0.1.24 → 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 (156) hide show
  1. package/README.md +8 -7
  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 +248 -92
  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 +13 -28
  19. package/dist/cli/format.d.ts.map +1 -1
  20. package/dist/cli/format.js +74 -274
  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 +36 -19
  24. package/dist/cli/help.js.map +1 -1
  25. package/dist/cli/pack.d.ts +46 -0
  26. package/dist/cli/pack.d.ts.map +1 -0
  27. package/dist/cli/pack.js +250 -0
  28. package/dist/cli/pack.js.map +1 -0
  29. package/dist/client.d.ts +26 -1
  30. package/dist/client.d.ts.map +1 -1
  31. package/dist/client.js +14 -1
  32. package/dist/client.js.map +1 -1
  33. package/dist/compat.d.ts +1 -1
  34. package/dist/compat.js +1 -1
  35. package/dist/components/calendar.d.ts +5 -1
  36. package/dist/components/calendar.d.ts.map +1 -1
  37. package/dist/components/calendar.js +3 -3
  38. package/dist/components/calendar.js.map +1 -1
  39. package/dist/components/decision-list.d.ts +13 -1
  40. package/dist/components/decision-list.d.ts.map +1 -1
  41. package/dist/components/decision-list.js +74 -29
  42. package/dist/components/decision-list.js.map +1 -1
  43. package/dist/components/event-feed.d.ts.map +1 -1
  44. package/dist/components/event-feed.js +2 -0
  45. package/dist/components/event-feed.js.map +1 -1
  46. package/dist/components/pack-connect.d.ts +23 -0
  47. package/dist/components/pack-connect.d.ts.map +1 -0
  48. package/dist/components/pack-connect.js +86 -0
  49. package/dist/components/pack-connect.js.map +1 -0
  50. package/dist/components/pack-token.d.ts +27 -0
  51. package/dist/components/pack-token.d.ts.map +1 -0
  52. package/dist/components/pack-token.js +11 -0
  53. package/dist/components/pack-token.js.map +1 -0
  54. package/dist/components/paged.d.ts +24 -0
  55. package/dist/components/paged.d.ts.map +1 -1
  56. package/dist/components/paged.js +40 -4
  57. package/dist/components/paged.js.map +1 -1
  58. package/dist/components/projects.d.ts +13 -0
  59. package/dist/components/projects.d.ts.map +1 -1
  60. package/dist/components/projects.js +37 -5
  61. package/dist/components/projects.js.map +1 -1
  62. package/dist/components/skills.d.ts.map +1 -1
  63. package/dist/components/skills.js +8 -4
  64. package/dist/components/skills.js.map +1 -1
  65. package/dist/components/ui.d.ts +2 -0
  66. package/dist/components/ui.d.ts.map +1 -1
  67. package/dist/components/ui.js +7 -2
  68. package/dist/components/ui.js.map +1 -1
  69. package/dist/components/work.d.ts +3 -1
  70. package/dist/components/work.d.ts.map +1 -1
  71. package/dist/components/work.js +1 -1
  72. package/dist/components/work.js.map +1 -1
  73. package/dist/components/workspace.d.ts +46 -0
  74. package/dist/components/workspace.d.ts.map +1 -0
  75. package/dist/components/workspace.js +124 -0
  76. package/dist/components/workspace.js.map +1 -0
  77. package/dist/console.css +268 -5
  78. package/dist/console.d.ts.map +1 -1
  79. package/dist/console.js +62 -23
  80. package/dist/console.js.map +1 -1
  81. package/dist/data.d.ts +16 -1
  82. package/dist/data.d.ts.map +1 -1
  83. package/dist/data.js +64 -2
  84. package/dist/data.js.map +1 -1
  85. package/dist/dates.d.ts +12 -2
  86. package/dist/dates.d.ts.map +1 -1
  87. package/dist/dates.js +20 -2
  88. package/dist/dates.js.map +1 -1
  89. package/dist/i18n/en.d.ts +66 -0
  90. package/dist/i18n/en.d.ts.map +1 -1
  91. package/dist/i18n/en.js +80 -8
  92. package/dist/i18n/en.js.map +1 -1
  93. package/dist/i18n/ko.d.ts.map +1 -1
  94. package/dist/i18n/ko.js +75 -7
  95. package/dist/i18n/ko.js.map +1 -1
  96. package/dist/index.d.ts +5 -2
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +5 -2
  99. package/dist/index.js.map +1 -1
  100. package/dist/router.d.ts +10 -5
  101. package/dist/router.d.ts.map +1 -1
  102. package/dist/router.js +24 -9
  103. package/dist/router.js.map +1 -1
  104. package/dist/routes.d.ts +4 -0
  105. package/dist/routes.d.ts.map +1 -1
  106. package/dist/routes.js +4 -0
  107. package/dist/routes.js.map +1 -1
  108. package/dist/schemas.d.ts +500 -158
  109. package/dist/schemas.d.ts.map +1 -1
  110. package/dist/schemas.js +169 -165
  111. package/dist/schemas.js.map +1 -1
  112. package/dist/vocabulary.d.ts +19 -1
  113. package/dist/vocabulary.d.ts.map +1 -1
  114. package/dist/vocabulary.js +16 -0
  115. package/dist/vocabulary.js.map +1 -1
  116. package/docs/specs/cli.md +19 -14
  117. package/docs/specs/rest.md +34 -19
  118. package/package.json +2 -1
  119. package/skills/building-a-console/SKILL.md +5 -5
  120. package/skills/building-a-console/references/the-look-from-a-website.md +39 -0
  121. package/skills/deploying-a-console/SKILL.md +9 -8
  122. package/skills/working-the-board/SKILL.md +5 -5
  123. package/skills/working-the-board/references/setting-up.md +1 -1
  124. package/skills/working-the-board/references/terms.md +2 -0
  125. package/skills/working-the-board/references/the-sentence.md +1 -1
  126. package/src/api.ts +18 -7
  127. package/src/cli/cli.ts +345 -109
  128. package/src/cli/credentials.ts +7 -0
  129. package/src/cli/directory.ts +9 -0
  130. package/src/cli/format.ts +82 -341
  131. package/src/cli/help.ts +43 -19
  132. package/src/cli/pack.ts +356 -0
  133. package/src/client.ts +84 -0
  134. package/src/compat.ts +1 -1
  135. package/src/components/calendar.tsx +8 -2
  136. package/src/components/decision-list.tsx +227 -94
  137. package/src/components/event-feed.tsx +2 -0
  138. package/src/components/pack-connect.tsx +179 -0
  139. package/src/components/pack-token.tsx +74 -0
  140. package/src/components/paged.tsx +72 -3
  141. package/src/components/projects.tsx +108 -9
  142. package/src/components/skills.tsx +51 -33
  143. package/src/components/ui.tsx +16 -3
  144. package/src/components/work.tsx +4 -1
  145. package/src/components/workspace.tsx +298 -0
  146. package/src/console.tsx +176 -29
  147. package/src/data.ts +89 -2
  148. package/src/dates.ts +23 -2
  149. package/src/i18n/en.ts +94 -8
  150. package/src/i18n/ko.ts +84 -7
  151. package/src/index.ts +25 -1
  152. package/src/router.ts +37 -14
  153. package/src/routes.ts +4 -0
  154. package/src/schemas.ts +202 -189
  155. package/src/styles/console.css +268 -5
  156. package/src/vocabulary.ts +25 -0
package/src/cli/cli.ts CHANGED
@@ -16,11 +16,12 @@ 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,
22
23
  type RestSkillLoad,
23
- type RestSkillPage,
24
+ type RestSkills,
24
25
  type RestTaskPatch,
25
26
  } from "../schemas.js";
26
27
  import {
@@ -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,
@@ -75,20 +79,34 @@ import {
75
79
  formatRules,
76
80
  formatRun,
77
81
  formatRunLine,
78
- formatSkillBrowse,
79
- formatSkillFile,
80
- formatSkillFind,
81
- formatSkillLoad,
82
- formatSkills,
83
82
  formatTask,
84
83
  formatTaskLine,
85
84
  formatVersion,
86
85
  formatVersionLine,
87
86
  formatWork,
87
+ formatWorkspace,
88
+ moment,
89
+ setMomentZone,
88
90
  } from "./format.js";
89
91
  import { COMMAND_HELP, helpFor, ownSkillPath, usage } from "./help.js";
90
92
  import { describeInstall, type Install, updateCommandFor } from "./install.js";
91
93
  import type { NoticeStore } from "./notices.js";
94
+ import {
95
+ bodyOfFile,
96
+ bodyOfFolder,
97
+ bodyOfSkill,
98
+ formatSkillBrowse,
99
+ formatSkillFile,
100
+ formatSkillFind,
101
+ formatSkillLoad,
102
+ formatSkills,
103
+ joinFile,
104
+ joinFolder,
105
+ joinSkill,
106
+ type PackSummary,
107
+ skillPathIn,
108
+ summaryOf,
109
+ } from "./pack.js";
92
110
  import { createServeHandler, DEFAULT_SERVE_PORT, type Listen } from "./serve.js";
93
111
 
94
112
  // The `console` command (docs/specs/cli.md): the agent's side of the console. A thin client of
@@ -299,6 +317,14 @@ async function open(io: CliIo, values: Values): Promise<Session> {
299
317
  throw failed(wordsOf(resolved));
300
318
  }
301
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
+ );
302
328
  const client = clientOf(io, url, token);
303
329
  const projectKey = async () =>
304
330
  text(values, "project") ?? io.env.CONSOLE_PROJECT ?? directory?.project;
@@ -473,18 +499,25 @@ async function personIdOf(
473
499
  return (await personOf(session, value)).id;
474
500
  }
475
501
 
476
- /** 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
+ */
477
507
  async function personOf(session: Session, login: string): Promise<RestPerson> {
478
- const { items } = await session.client.people();
479
- const found = items.find((person) => person.login.toLowerCase() === login.toLowerCase());
508
+ const found = await session.client.findPerson(login);
480
509
  if (found === undefined) {
481
510
  throw misuse(
482
- `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.`,
483
512
  );
484
513
  }
485
514
  return found;
486
515
  }
487
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
+
488
521
  /** One word of a vocabulary, or the refusal to understand. */
489
522
  function wordOf<T extends string>(value: string, words: readonly T[], what: string): T {
490
523
  const found = words.find((word) => word === value);
@@ -641,7 +674,14 @@ async function keep(
641
674
  }
642
675
  const next = withLogin(
643
676
  credentials,
644
- { 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
+ },
645
685
  { default: asDefault },
646
686
  );
647
687
  const kept = await io.store.save(next);
@@ -815,6 +855,24 @@ function agentOf(env: CliIo["env"]): string | undefined {
815
855
  return undefined;
816
856
  }
817
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
+
818
876
  const whoami: Command = async (args, io) => {
819
877
  const { values } = parse(args, {}, false);
820
878
  const session = await open(io, values);
@@ -823,6 +881,7 @@ const whoami: Command = async (args, io) => {
823
881
  if (person === null) {
824
882
  throw failed("The console does not know this token. Sign in again: console login");
825
883
  }
884
+ await rememberZone(io, session, me.workspace.timeZone ?? undefined);
826
885
  const key = await session.projectKey();
827
886
  const command = parseVersion(io.version);
828
887
  const server = parseVersion(me.console?.version);
@@ -903,12 +962,15 @@ const use: Command = async (args, io) => {
903
962
  const bound = pinUrl
904
963
  ? ` at ${session.url}${pinAccount && session.account !== undefined ? ` as ${session.account}` : ""}`
905
964
  : "";
906
- answer(
907
- io,
908
- session,
909
- project,
910
- () =>
965
+ answer(io, session, project, () =>
966
+ [
911
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"),
912
974
  );
913
975
  return EXIT.ok;
914
976
  };
@@ -1003,10 +1065,19 @@ const MAX_SKILL_PAGES = 64;
1003
1065
  /** How many results a search answers at most: what the deployment allows on one page. */
1004
1066
  const MAX_FIND_LIMIT = 25;
1005
1067
 
1068
+ /** The pack of the project as the console lists it, and what its reads are rendered at; none when the pack did not answer. */
1069
+ async function packOf(
1070
+ project: ProjectClient,
1071
+ ): Promise<{ readonly listing: RestSkills; readonly summary: PackSummary | undefined }> {
1072
+ const listing = await project.skills();
1073
+ return { listing, summary: summaryOf(listing) };
1074
+ }
1075
+
1006
1076
  const skills: Command = async (args, io) => {
1007
1077
  const { values, positionals } = parse(args, {
1008
1078
  search: { type: "string" },
1009
1079
  limit: { type: "string" },
1080
+ cursor: { type: "string" },
1010
1081
  });
1011
1082
  if (positionals.length > 1) {
1012
1083
  throw misuse("Say one folder: console skills [<folder>] [--search <words>]");
@@ -1016,8 +1087,11 @@ const skills: Command = async (args, io) => {
1016
1087
  const folder = given === undefined || given === "/" || given === "." ? undefined : given;
1017
1088
  const words = text(values, "search");
1018
1089
  const limit = text(values, "limit");
1019
- if (words === undefined && limit !== undefined) {
1020
- throw misuse("--limit goes with --search: console skills --search <words> [--limit <n>]");
1090
+ const cursor = text(values, "cursor");
1091
+ if (words === undefined && (limit !== undefined || cursor !== undefined)) {
1092
+ throw misuse(
1093
+ "--limit and --cursor go with --search: console skills --search <words> [--limit <n>] [--cursor <cursor>]",
1094
+ );
1021
1095
  }
1022
1096
  const atMost = limit === undefined ? undefined : Number(limit);
1023
1097
  if (
@@ -1028,37 +1102,41 @@ const skills: Command = async (args, io) => {
1028
1102
  }
1029
1103
  const session = await open(io, values);
1030
1104
  const project = await session.project();
1105
+ const { listing, summary } = await packOf(project);
1106
+ // The pack as it stands; or why it does not answer, which a folder or a search would meet too.
1107
+ if (summary === undefined || (words === undefined && given === undefined)) {
1108
+ answer(io, session, listing, () => formatSkills(listing, io.skillsDir));
1109
+ return EXIT.ok;
1110
+ }
1031
1111
  if (words !== undefined) {
1032
- // One page of what the words find; more is said, and narrowed by the agent, not fetched.
1033
- const found = await project.skillFind(words, { path: folder, limit: atMost });
1034
- answer(io, session, found, () => formatSkillFind(found));
1112
+ // One page of what the words find; more is said, and asked for with --cursor.
1113
+ const found = await project.skillFind(words, { path: folder, limit: atMost, cursor });
1114
+ answer(io, session, found, () => formatSkillFind(found, summary));
1035
1115
  return EXIT.ok;
1036
1116
  }
1037
- if (given !== undefined) {
1038
- // The folder whole: its pages until the end, as the deployment lists them.
1039
- const folders: NonNullable<RestSkillBrowse["folder"]>[] = [];
1040
- let cursor: string | undefined;
1041
- let last: RestSkillBrowse | undefined;
1042
- for (let n = 0; n < MAX_SKILL_PAGES; n += 1) {
1043
- last = await project.skillBrowse({ path: folder, cursor });
1044
- if (last.status !== "ready" || last.folder === null) {
1045
- break;
1046
- }
1047
- folders.push(last.folder);
1048
- if (last.folder.nextCursor === null) {
1049
- break;
1050
- }
1051
- cursor = last.folder.nextCursor;
1117
+ // The folder whole: its pages until the end, as the pack lists them.
1118
+ const pages: RestSkillBrowse[] = [];
1119
+ let next: string | undefined;
1120
+ let last: RestSkillBrowse | undefined;
1121
+ for (let n = 0; n < MAX_SKILL_PAGES; n += 1) {
1122
+ last = await project.skillBrowse({ path: folder, cursor: next });
1123
+ if (last.read?.status !== "ready") {
1124
+ break;
1052
1125
  }
1053
- if (last === undefined) {
1054
- throw failed("The folder could not be read.");
1126
+ pages.push(last);
1127
+ next = last.read.nextCursor ?? undefined;
1128
+ if (next === undefined) {
1129
+ break;
1055
1130
  }
1056
- const browsed = last;
1057
- answer(io, session, { ...browsed, folders }, () => formatSkillBrowse(browsed, folders));
1058
- return EXIT.ok;
1059
1131
  }
1060
- const found = await project.skills();
1061
- answer(io, session, found, () => formatSkills(found, io.skillsDir));
1132
+ if (last === undefined) {
1133
+ throw failed("The folder could not be read.");
1134
+ }
1135
+ const browsed = last;
1136
+ const whole = joinFolder(pages, summary);
1137
+ answer(io, session, { ...browsed, read: whole === undefined ? null : bodyOfFolder(whole) }, () =>
1138
+ formatSkillBrowse(browsed, whole),
1139
+ );
1062
1140
  return EXIT.ok;
1063
1141
  };
1064
1142
 
@@ -1074,84 +1152,73 @@ const skill: Command = async (args, io) => {
1074
1152
  }
1075
1153
  const session = await open(io, values);
1076
1154
  const project = await session.project();
1155
+ const { listing, summary } = await packOf(project);
1156
+ if (summary === undefined) {
1157
+ answer(io, session, listing, () => formatSkills(listing, io.skillsDir));
1158
+ return EXIT.ok;
1159
+ }
1077
1160
  if (file !== undefined) {
1078
- // The file whole, page after page, as the console reads it from the deployment.
1079
- const parts: string[] = [];
1161
+ // The file whole, page after page, as the console reads it from the pack.
1162
+ const pages: RestSkillFile[] = [];
1080
1163
  let offset: number | undefined;
1081
1164
  let last: RestSkillFile | undefined;
1082
- for (let pages = 0; pages < MAX_SKILL_PAGES; pages += 1) {
1165
+ for (let n = 0; n < MAX_SKILL_PAGES; n += 1) {
1083
1166
  last = await project.skillFile(file, offset === undefined ? {} : { offset });
1084
- if (last.status !== "ready" || last.file === null) {
1167
+ if (last.read === null) {
1085
1168
  break;
1086
1169
  }
1087
- parts.push(last.file.content);
1088
- if (last.file.nextOffset === null) {
1170
+ pages.push(last);
1171
+ if (last.read.kind !== "file" || last.read.nextOffset === null) {
1089
1172
  break;
1090
1173
  }
1091
- offset = last.file.nextOffset;
1174
+ offset = last.read.nextOffset;
1092
1175
  }
1093
1176
  if (last === undefined) {
1094
1177
  throw failed("The file could not be read.");
1095
1178
  }
1096
- const read = last;
1097
- const content = parts.join("");
1098
- answer(io, session, { ...read, content }, () => formatSkillFile(read, content));
1179
+ const fileRead = last;
1180
+ const whole = joinFile(pages, summary);
1181
+ answer(io, session, { ...fileRead, read: whole === undefined ? null : bodyOfFile(whole) }, () =>
1182
+ formatSkillFile(fileRead, whole),
1183
+ );
1099
1184
  return EXIT.ok;
1100
1185
  }
1101
- const { path, pack } = skillPathOf(
1186
+ const wanted = skillPathIn(
1102
1187
  theOne(
1103
1188
  positionals,
1104
1189
  "Say which skill: console skill <path of its SKILL.md>, or console skill --file <path> for a file of the pack",
1105
1190
  ),
1191
+ listing.address,
1106
1192
  );
1193
+ if ("error" in wanted) {
1194
+ throw misuse(wanted.error);
1195
+ }
1107
1196
  // The skill whole: its pages until complete, the rules first, as load_skill lays them out.
1108
- const pages: RestSkillPage[] = [];
1197
+ const pages: RestSkillLoad[] = [];
1109
1198
  let cursor: string | undefined;
1110
1199
  let last: RestSkillLoad | undefined;
1111
1200
  for (let n = 0; n < MAX_SKILL_PAGES; n += 1) {
1112
- last = await project.skill(path, cursor);
1113
- if (last.status !== "ready" || last.skill === null) {
1201
+ last = await project.skill(wanted.path, cursor);
1202
+ if (last.read?.status !== "ready") {
1114
1203
  break;
1115
1204
  }
1116
- pages.push(last.skill);
1117
- if (last.skill.complete || last.skill.nextCursor === null) {
1205
+ pages.push(last);
1206
+ cursor = last.read.skill.nextCursor ?? undefined;
1207
+ if (last.read.skill.complete === true || cursor === undefined) {
1118
1208
  break;
1119
1209
  }
1120
- cursor = last.skill.nextCursor;
1121
1210
  }
1122
1211
  if (last === undefined) {
1123
1212
  throw failed("The skill could not be read.");
1124
1213
  }
1125
- // A URI of another pack would read this pack's skill at the same path, or nothing: said instead.
1126
- if (pack !== undefined && last.address !== null && last.address !== pack) {
1127
- throw misuse(
1128
- `That URI is of another pack, ${pack}; this project's pack is at ${last.address}. Say the path of the SKILL.md, or a URI console skills lists.`,
1129
- );
1130
- }
1131
1214
  const loaded = last;
1132
- answer(io, session, { ...loaded, pages }, () => formatSkillLoad(loaded, pages));
1215
+ const whole = joinSkill(pages, summary);
1216
+ answer(io, session, { ...loaded, read: whole === undefined ? null : bodyOfSkill(whole) }, () =>
1217
+ formatSkillLoad(loaded, whole),
1218
+ );
1133
1219
  return EXIT.ok;
1134
1220
  };
1135
1221
 
1136
- /**
1137
- * A skill's path as given, or taken out of the URI `console skills` lists beside it
1138
- * (`skill://gh/<owner>/<repo>/<path>`), with the pack the URI names, for the read to check.
1139
- */
1140
- function skillPathOf(given: string): { readonly path: string; readonly pack?: string } {
1141
- const uri = /^skill:\/\/(gh\/[^/]+\/[^/]+)\/(.+)$/.exec(given);
1142
- const named = uri?.[1];
1143
- const within = uri?.[2];
1144
- if (named !== undefined && within !== undefined) {
1145
- return { path: within, pack: `/${named}` };
1146
- }
1147
- if (given.startsWith("skill://")) {
1148
- throw misuse(
1149
- `Not a skill's URI the console knows: ${given}. Say the path of its SKILL.md from the repository's root, or the URI console skills lists.`,
1150
- );
1151
- }
1152
- return { path: given };
1153
- }
1154
-
1155
1222
  /** A version number given as a flag, or nothing; anything else is not understood. */
1156
1223
  function versionOf(given: string | undefined): number | undefined {
1157
1224
  if (given === undefined) {
@@ -1317,7 +1384,12 @@ const newTask: Command = async (args, io) => {
1317
1384
  ...(body === undefined ? {} : { body }),
1318
1385
  ...fields,
1319
1386
  });
1320
- 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
+ );
1321
1393
  return EXIT.ok;
1322
1394
  };
1323
1395
 
@@ -1540,6 +1612,9 @@ const take: Command = async (args, io) => {
1540
1612
  answer(io, session, { run, task: taken, comments: comments.items, rules }, () =>
1541
1613
  [
1542
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})`,
1543
1618
  "",
1544
1619
  formatTask(taken, undefined, undefined, comments.items),
1545
1620
  "",
@@ -1685,7 +1760,7 @@ const ask: Command = async (args, io) => {
1685
1760
  io.stdout(
1686
1761
  session.json
1687
1762
  ? `${JSON.stringify(raised, null, 2)}\n`
1688
- : `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`,
1689
1764
  );
1690
1765
  return settle(io, session, project, raised, seconds, true);
1691
1766
  };
@@ -1934,7 +2009,7 @@ const newGoal: Command = async (args, io) => {
1934
2009
  ...(startsOn === undefined ? {} : { startsOn }),
1935
2010
  ...(dueOn === undefined ? {} : { dueOn }),
1936
2011
  });
1937
- answer(io, session, made, () => `Goal set: ${made.title}\nid: ${made.id}`);
2012
+ answer(io, session, made, () => `Goal set: ${made.title}\ngoal id: ${made.id}`);
1938
2013
  return EXIT.ok;
1939
2014
  };
1940
2015
 
@@ -2170,6 +2245,81 @@ const people: Command = async (args, io) => {
2170
2245
  return EXIT.ok;
2171
2246
  };
2172
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
+
2173
2323
  const PERSON_USAGE =
2174
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>";
2175
2325
 
@@ -2293,35 +2443,118 @@ const editProject: Command = async (args, io) => {
2293
2443
  return EXIT.ok;
2294
2444
  };
2295
2445
 
2296
- /** The pack token (ADR-0028): read from standard input and never from an argument, so that no shell keeps it. */
2297
- const projectPackToken: Command = async (args, io) => {
2298
- const { values, positionals } = parse(args, {
2299
- stdin: { type: "boolean" },
2300
- remove: { type: "boolean" },
2301
- });
2302
- const key = theOne(
2303
- positionals,
2304
- "Say which project: console project pack-token <key> --stdin, or console project pack-token <key> --remove",
2305
- );
2306
- 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) {
2307
2459
  throw misuse(
2308
- "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.",
2309
2461
  );
2310
2462
  }
2311
- const session = await open(io, values);
2312
- const project = session.client.project(key);
2313
- if (on(values, "remove")) {
2314
- const changed = await project.removePackToken();
2315
- 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));
2316
2494
  return EXIT.ok;
2317
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
+ }
2318
2519
  const token = (await io.readStdin()).trim();
2319
2520
  if (token.length === 0) {
2320
2521
  throw failed("No token was given on standard input.");
2321
2522
  }
2322
- const changed = await project.setPackToken({ token });
2323
- answer(io, session, changed, () => formatProject(changed));
2523
+ const changed = await holder.set(token);
2524
+ answer(io, session, changed, () => format(changed));
2324
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
+ );
2325
2558
  };
2326
2559
 
2327
2560
  const projectIcon: Command = async (args, io) => {
@@ -2548,6 +2781,7 @@ const COMMANDS: Readonly<Record<string, Command>> = {
2548
2781
  rules,
2549
2782
  people,
2550
2783
  person,
2784
+ workspace,
2551
2785
  project,
2552
2786
  members,
2553
2787
  member,
@@ -2699,6 +2933,8 @@ function hintFor(error: ApiError, io: CliIo, seen: ConsoleSeen): string {
2699
2933
  return "The person is listed already. console member role <login> <owner|member> changes what they are.\n";
2700
2934
  case "member.not_found":
2701
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";
2702
2938
  case "category.key_taken":
2703
2939
  return "A category has that key already. console categories lists them.\n";
2704
2940
  case "category.not_found":