@catalyst-cloud/cli 0.15.1 → 0.15.3

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 (40) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +13 -4
  3. package/dist/args.js +1 -1
  4. package/dist/capabilities.js +3 -1
  5. package/dist/cli.js +25 -30
  6. package/dist/consent-browser.js +3 -1
  7. package/dist/oauth.js +1 -1
  8. package/dist/onboard-accounts.js +67 -171
  9. package/dist/onboard-checklist.js +309 -0
  10. package/dist/onboard-existing.js +1 -1
  11. package/dist/onboard-first-ticket.js +199 -0
  12. package/dist/onboard-github.js +13 -1
  13. package/dist/onboard-headless.js +6 -4
  14. package/dist/onboard-housekeeping.js +93 -0
  15. package/dist/onboard-next.js +124 -13
  16. package/dist/onboard-ready.js +61 -0
  17. package/dist/onboard-runtime.js +117 -26
  18. package/dist/onboard-standalone-copy.js +73 -66
  19. package/dist/onboard-ui.js +189 -81
  20. package/dist/onboard-values.js +47 -0
  21. package/dist/onboard.js +59 -18
  22. package/dist/ready-copy.js +325 -0
  23. package/dist/ready.js +35 -98
  24. package/dist/setup-onboard-copy.js +262 -41
  25. package/dist/setup-render.js +73 -14
  26. package/dist/setup.js +6 -3
  27. package/package.json +2 -2
  28. package/skills/catalyst-github/SKILL.md +1 -1
  29. package/skills/catalyst-linear/SKILL.md +1 -1
  30. package/skills/catalyst-onboard/SKILL.md +3 -3
  31. package/skills/catalyst-onboard/references/replacing-a-credential.md +9 -15
  32. package/skills/catalyst-onboard/references/what-a-phase-needs.md +4 -11
  33. package/skills/catalyst-onboard/scripts/where-am-i.mjs +3 -2
  34. package/skills/run-this-project/SKILL.md +2 -2
  35. package/skills/unstick/SKILL.md +1 -1
  36. package/skills/what-needs-me/SKILL.md +1 -1
  37. package/skills/what-this-repo-needs/SKILL.md +1 -1
  38. package/skills/whats-happening/SKILL.md +2 -2
  39. package/skills/whats-happening/references/coding-accounts.md +1 -1
  40. package/skills/whats-happening/scripts/snapshot.mjs +1 -1
@@ -1,5 +1,6 @@
1
1
  import { ONBOARD_DEPENDENCIES, ONBOARD_STEPS, ONBOARD_TITLES, } from "./onboard.js";
2
- import { onboardReasonText } from "./onboard-next.js";
2
+ import { dispatchStageName, onboardReasonText } from "./onboard-next.js";
3
+ /** The step numbers `--json` reports. People read SETUP_PART_NUMBERS instead (CTC-4680). */
3
4
  export const SETUP_NUMBERS = {
4
5
  machine: 1,
5
6
  cli: 1,
@@ -23,13 +24,94 @@ export const SETUP_NUMBERS = {
23
24
  values: 16,
24
25
  "first-ticket": 17,
25
26
  };
27
+ /** CTC-4680: setup runs in three parts. The times are estimates until setup logs give medians. */
28
+ export const SETUP_PARTS = {
29
+ 1: {
30
+ title: "This computer",
31
+ estimate: "about 2 min",
32
+ summary: "skills and sign-in",
33
+ done: "This computer is set up.",
34
+ },
35
+ 2: {
36
+ title: "Linear and GitHub",
37
+ estimate: "about 10 min",
38
+ summary: "approvals in your browser",
39
+ done: "Linear and GitHub are connected.",
40
+ },
41
+ 3: {
42
+ title: "Ready for work",
43
+ estimate: "about 5 min",
44
+ summary: "a first ticket",
45
+ done: "Catalyst is ready for work.",
46
+ },
47
+ };
48
+ export const SETUP_PART_OF = {
49
+ machine: 1,
50
+ cli: 1,
51
+ skills: 1,
52
+ legacy: 1,
53
+ signin: 1,
54
+ daemon: 1,
55
+ housekeeping: 1,
56
+ "linear.workspace": 2,
57
+ "linear.personal": 2,
58
+ "linear.team": 2,
59
+ "linear.adopt": 2,
60
+ "linear.automations": 2,
61
+ "github.install": 2,
62
+ "github.personal": 2,
63
+ "github.repos": 3,
64
+ projects: 3,
65
+ accounts: 3,
66
+ capacity: 3,
67
+ runner: 3,
68
+ settings: 3,
69
+ values: 3,
70
+ "first-ticket": 3,
71
+ ready: 3,
72
+ };
73
+ /** What a person reads: each part restarts at 1, in the order setup runs the steps. Steps that
74
+ * share a row (the computer checks, repositories and projects) share a number. */
75
+ export const SETUP_PART_NUMBERS = {
76
+ machine: 1,
77
+ cli: 1,
78
+ skills: 1,
79
+ legacy: 1,
80
+ signin: 2,
81
+ housekeeping: 3,
82
+ "linear.workspace": 1,
83
+ "linear.personal": 2,
84
+ "linear.team": 3,
85
+ "linear.adopt": 4,
86
+ "linear.automations": 5,
87
+ "github.install": 6,
88
+ "github.personal": 7,
89
+ "github.repos": 1,
90
+ projects: 1,
91
+ accounts: 2,
92
+ capacity: 3,
93
+ runner: 4,
94
+ settings: 5,
95
+ values: 6,
96
+ "first-ticket": 7,
97
+ };
98
+ /** CTC-4680: people type `catalyst setup`; `catalyst onboard` stays its alias. What setup draws
99
+ * names setup. `--json` and headless output keep their own words. */
100
+ export function setupCommandWords(text) {
101
+ return text.replace(/\bcatalyst onboard\b/g, "catalyst setup");
102
+ }
103
+ /** "Part 1 of 3 · This computer", with a colon where the output is plain. */
104
+ export function setupPartHeading(part, unicode) {
105
+ return `Part ${part} of 3${unicode ? " ·" : ":"} ${SETUP_PARTS[part].title}`;
106
+ }
26
107
  const TITLES = {
27
108
  "linear.adopt": "Set up the team's workflow",
28
109
  "linear.automations": "Check Linear's PR automations",
29
110
  projects: "Choose repositories",
30
- accounts: "Add a coding account",
111
+ accounts: "Add an AI account",
112
+ housekeeping: "Daily update",
31
113
  capacity: "Check runners",
32
- values: "Review repository settings",
114
+ values: "Check repository values",
33
115
  };
34
116
  const OPTIONAL = new Set([
35
117
  "local_sync_not_selected",
@@ -47,6 +129,16 @@ export const CONNECTION_REPAIR_REASONS = new Set([
47
129
  "github_app_permissions_unverified", "github_app_repository_access_unverified",
48
130
  "linear_workspace_permissions_unverified", "personal_permissions_unverified",
49
131
  ]);
132
+ /** CTC-4680: the signed-in browser route that goes straight to GitHub's install page. The settings
133
+ * page it replaces opened Integrations and started nothing. */
134
+ export const GITHUB_INSTALL_START = "/connect/github/start";
135
+ /** CTC-4680 round 5: a browser wait that ran its whole window without the person finishing. */
136
+ export const TIMED_OUT_REASONS = new Set([
137
+ "consent_timeout",
138
+ "workspace_browser_unavailable",
139
+ "personal_browser_unavailable",
140
+ "github_installation_browser_unavailable",
141
+ ]);
50
142
  const clean = (s) => s.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 300);
51
143
  function value(step, key) {
52
144
  const v = step.evidence?.[key];
@@ -73,18 +165,23 @@ function succeeded(step) {
73
165
  case "projects":
74
166
  return "repositories are now Catalyst projects";
75
167
  case "accounts":
76
- return `${value(step, "provider") === "claude" ? "Claude" : value(step, "provider") === "codex" ? "Codex" : "a coding account"} can take work`;
168
+ return `${value(step, "provider") === "claude" ? "Claude" : value(step, "provider") === "codex" ? "Codex" : "an AI account"} can take work`;
169
+ case "housekeeping":
170
+ return "scheduled, runs daily";
77
171
  case "capacity":
78
172
  return typeof step.evidence?.remainingUnits === "number"
79
173
  ? `${step.evidence.remainingUnits} runner slots available`
80
174
  : "runners can take work";
81
175
  case "settings":
82
- case "values":
83
176
  return "settings reviewed";
84
- case "first-ticket":
85
- return value(step, "ticketKey")
86
- ? `${value(step, "ticketKey")} started`
177
+ case "values":
178
+ return "values set";
179
+ case "first-ticket": {
180
+ const key = value(step, "ticket") ?? value(step, "ticketKey");
181
+ return key
182
+ ? `${key} moved to ${dispatchStageName(step.evidence?.stage)}. Catalyst picks it up next.`
87
183
  : "first ticket started";
184
+ }
88
185
  case "daemon":
89
186
  return "local sync is running";
90
187
  case "runner":
@@ -104,6 +201,12 @@ function unfinished(step) {
104
201
  return "could not verify the connection's permissions";
105
202
  if (step.reason === "housekeeping_service_unverified")
106
203
  return "no scheduler here. catalyst says when an update is out";
204
+ if (step.reason === "housekeeping_off_chosen")
205
+ return "skipped, as you chose";
206
+ if (step.reason === "housekeeping_no_scheduler")
207
+ return "can't run on this computer";
208
+ if (step.id === "housekeeping")
209
+ return "not scheduled; the install command turns it on";
107
210
  if (step.reason === "local_sync_not_selected")
108
211
  return "off, reads come from the cloud";
109
212
  if (step.reason === "runner_docker_missing")
@@ -112,15 +215,19 @@ function unfinished(step) {
112
215
  return "an admin handles this step";
113
216
  if (step.reason === "automation_management_unavailable")
114
217
  return "could not read them; checked again before work starts";
115
- if (step.reason === "interrupted")
218
+ if (step.reason === "interrupted" || step.reason === "first_ticket_skipped")
116
219
  return "skipped for now";
220
+ if (TIMED_OUT_REASONS.has(step.reason ?? ""))
221
+ return "the link timed out";
222
+ if (step.reason === "github_installation_approval_pending")
223
+ return "waiting for an owner of your GitHub organization";
117
224
  if (step.state === "failed")
118
225
  return "could not finish this step";
119
226
  switch (step.id) {
120
227
  case "github.install":
121
228
  return "not installed yet";
122
229
  case "accounts":
123
- return "no coding account ready yet";
230
+ return "no AI account ready yet";
124
231
  case "linear.workspace":
125
232
  case "linear.personal":
126
233
  case "github.personal":
@@ -130,8 +237,13 @@ function unfinished(step) {
130
237
  case "capacity":
131
238
  return "no runner can take work yet";
132
239
  case "settings":
133
- case "values":
134
240
  return "optional; not reviewed yet";
241
+ case "values":
242
+ return step.reason === "required_values_missing"
243
+ ? "a value is missing"
244
+ : "not checked yet";
245
+ case "first-ticket":
246
+ return "no ticket chosen yet";
135
247
  default:
136
248
  return step.state === "skipped" ? "not chosen" : "not done yet";
137
249
  }
@@ -143,6 +255,7 @@ export function setupStepView(step, journal, required = false) {
143
255
  : step.state === "failed"
144
256
  ? "fail"
145
257
  : step.reason === "prerequisite_not_ready" ||
258
+ step.reason === "github_install_pending" ||
146
259
  (step.state === "pending" && !step.reason)
147
260
  ? "later"
148
261
  : !required &&
@@ -151,29 +264,118 @@ export function setupStepView(step, journal, required = false) {
151
264
  : "act";
152
265
  let outcome = step.state === "done" ? succeeded(step) : unfinished(step);
153
266
  if (mark === "later") {
154
- const numbers = [
155
- ...new Set((ONBOARD_DEPENDENCIES[step.id] ?? [])
156
- .filter((id) => {
267
+ const waitsOn = step.reason === "github_install_pending"
268
+ ? ["github.install"]
269
+ : (ONBOARD_DEPENDENCIES[step.id] ?? []).filter((id) => {
157
270
  const s = journal?.steps.find((s) => s.id === id);
158
271
  return s?.state !== "done" && s?.state !== "skipped";
159
- })
160
- .map((id) => SETUP_NUMBERS[id])
161
- .filter((n) => n !== undefined)),
162
- ];
272
+ });
273
+ // Numbers restart in each part: this part's steps come bare, then each other part once.
274
+ const byPart = new Map();
275
+ for (const id of waitsOn) {
276
+ const n = SETUP_PART_NUMBERS[id];
277
+ if (n === undefined)
278
+ continue;
279
+ const p = SETUP_PART_OF[id];
280
+ const list = byPart.get(p) ?? [];
281
+ if (!list.includes(n))
282
+ byPart.set(p, [...list, n]);
283
+ }
284
+ const own = SETUP_PART_OF[step.id];
285
+ const steps = (ns) => {
286
+ const sorted = [...ns].sort((x, y) => x - y);
287
+ const last = sorted.pop();
288
+ return sorted.length
289
+ ? `steps ${sorted.join(", ")} and ${last}`
290
+ : `step ${last}`;
291
+ };
292
+ const groups = [...byPart]
293
+ .sort(([x], [y]) => (x === own ? -1 : y === own ? 1 : x - y))
294
+ .map(([p, ns]) => (p === own ? steps(ns) : `${steps(ns)} of part ${p}`));
163
295
  outcome =
164
296
  step.state === "pending" && !step.reason
165
297
  ? "not checked yet"
166
- : numbers.length
167
- ? `after ${numbers.length === 1 ? "step" : "steps"} ${numbers.join(" and ")}`
298
+ : groups.length
299
+ ? `after ${groups.length > 1 ? `${groups.slice(0, -1).join(", ")}, and ${groups.at(-1)}` : groups[0]}`
168
300
  : "after an earlier step";
169
301
  }
170
302
  return {
171
- number: SETUP_NUMBERS[step.id] ?? 0,
303
+ number: SETUP_PART_NUMBERS[step.id] ?? 0,
172
304
  title: TITLES[step.id] ?? ONBOARD_TITLES[step.id],
173
305
  mark,
174
306
  outcome,
175
307
  };
176
308
  }
309
+ /** How far each part has got, by its numbered rows. A row is done when every step in it is done
310
+ * or skipped, and needs someone when any of its steps does. */
311
+ export function setupPartProgress(journal, scope) {
312
+ const progress = {
313
+ 1: { total: 0, done: 0, needs: 0, failed: 0 },
314
+ 2: { total: 0, done: 0, needs: 0, failed: 0 },
315
+ 3: { total: 0, done: 0, needs: 0, failed: 0 },
316
+ };
317
+ const rows = new Map();
318
+ for (const id of ONBOARD_STEPS) {
319
+ const number = SETUP_PART_NUMBERS[id];
320
+ if (number === undefined || (scope && !scope.includes(id)))
321
+ continue;
322
+ const step = journal.steps.find((s) => s.id === id) ?? {
323
+ id,
324
+ state: "pending",
325
+ };
326
+ const key = `${SETUP_PART_OF[id]}:${number}`;
327
+ rows.set(key, [...(rows.get(key) ?? []), setupStepView(step, journal).mark]);
328
+ }
329
+ for (const [key, marks] of rows) {
330
+ const part = progress[Number(key.split(":")[0])];
331
+ part.total++;
332
+ if (marks.every((m) => m === "done" || m === "skip"))
333
+ part.done++;
334
+ else if (marks.some((m) => m === "act" || m === "fail")) {
335
+ part.needs++;
336
+ if (marks.includes("fail"))
337
+ part.failed++;
338
+ }
339
+ }
340
+ return progress;
341
+ }
342
+ /**
343
+ * The tracker's rows: on the first screen (`plan`, the part about to start), at a boundary
344
+ * (`next`, the part about to start), or at the end. A part with no rows in this run is left out.
345
+ */
346
+ export function setupTrackerRows(progress, moment) {
347
+ const steps = (n) => `${n} ${n === 1 ? "step" : "steps"}`;
348
+ return [1, 2, 3]
349
+ .filter((p) => progress[p].total > 0)
350
+ .map((p) => {
351
+ const { title, estimate, summary } = SETUP_PARTS[p];
352
+ const got = progress[p];
353
+ const row = (mark, note) => ({
354
+ mark,
355
+ number: p,
356
+ title,
357
+ note,
358
+ });
359
+ if (typeof moment === "object" && "plan" in moment)
360
+ return row(p === moment.plan ? "now" : "later", `${estimate} · ${summary}`);
361
+ const finished = got.done === got.total;
362
+ const needsMark = got.failed ? "fail" : "act";
363
+ if (typeof moment === "object") {
364
+ if (p === moment.next)
365
+ return row("now", `next · ${estimate}`);
366
+ if (p > moment.next)
367
+ return row("later", estimate);
368
+ return finished
369
+ ? row("done", "done")
370
+ : row(needsMark, `${steps(got.total - got.done)} not finished`);
371
+ }
372
+ if (finished)
373
+ return row("done", "");
374
+ if (got.needs)
375
+ return row(needsMark, `${steps(got.needs)} ${got.needs === 1 ? "needs" : "need"} someone`);
376
+ return row("later", `${got.done} of ${steps(got.total)} done`);
377
+ });
378
+ }
177
379
  function page(base, path) {
178
380
  try {
179
381
  const url = new URL(path, base);
@@ -188,7 +390,9 @@ function page(base, path) {
188
390
  }
189
391
  }
190
392
  function action(step, base, journal) {
191
- if (CONNECTION_REPAIR_REASONS.has(step.reason ?? ""))
393
+ if (CONNECTION_REPAIR_REASONS.has(step.reason ?? "") ||
394
+ step.reason === "github_installation_approval_pending" ||
395
+ step.reason === "member_scope")
192
396
  return onboardReasonText(step, { baseUrl: base, journal });
193
397
  const link = (path, verb) => {
194
398
  const url = page(base, path);
@@ -202,11 +406,18 @@ function action(step, base, journal) {
202
406
  case "linear.personal":
203
407
  return link("/settings/connected-accounts?connect=linear", "connect your Linear account");
204
408
  case "github.install":
205
- return link("/settings/connections?install=github", "install Catalyst on your GitHub organization");
409
+ // The start route answers 403 to anyone but an owner or admin, so a member gets the
410
+ // Integrations page and who to ask instead.
411
+ if (step.reason === "github_installation_admin_required" ||
412
+ step.reason === "member_scope")
413
+ return onboardReasonText(step, { baseUrl: base, journal });
414
+ return link(GITHUB_INSTALL_START, "install Catalyst on your GitHub organization");
206
415
  case "github.personal":
207
416
  return link("/settings/connected-accounts?connect=github", "connect your GitHub account");
208
417
  case "accounts":
209
- return link("/settings/coding-accounts", "add a coding account");
418
+ return step.reason === "ai_account_not_usable"
419
+ ? onboardReasonText(step, { baseUrl: base, journal })
420
+ : link("/settings/coding-accounts", "add an AI account");
210
421
  case "linear.adopt": {
211
422
  const team = journal?.steps.find((row) => row.id === "linear.team" && row.state === "done");
212
423
  const key = team && value(team, "teamKey");
@@ -222,24 +433,30 @@ function action(step, base, journal) {
222
433
  ? onboardReasonText(step, { baseUrl: base, journal })
223
434
  : "Run catalyst onboard --runner to check whether this computer can take work.";
224
435
  case "settings":
225
- case "values":
226
436
  return "Open your repository checkout and review its settings.";
437
+ case "values":
438
+ return onboardReasonText({ ...step, reason: step.reason ?? "required_values_unverified" }, { baseUrl: base, journal });
227
439
  case "linear.team":
228
440
  return "Choose a Linear team when setup checks again.";
229
441
  case "github.repos":
230
442
  case "projects":
231
443
  return "Choose repositories when setup checks again.";
232
444
  case "first-ticket":
233
- return "Choose a first ticket when setup checks again.";
445
+ return onboardReasonText({ ...step, reason: step.reason ?? "first_ticket_choice_required" }, { baseUrl: base, journal });
234
446
  default:
235
447
  return "Try this step again when setup checks again.";
236
448
  }
237
449
  }
238
450
  export function setupFinalScreen(journal, base, paused = false, required = new Set()) {
451
+ // A member's run skips admin steps; readiness names the ones an admin has not finished yet.
452
+ const readyStep = journal.steps.find((step) => step.id === "ready");
453
+ const adminLeft = new Set(readyStep?.reason === "admin_setup_pending" && typeof readyStep.evidence?.admin === "string"
454
+ ? readyStep.evidence.admin.split(",")
455
+ : []);
239
456
  const roots = journal.steps
240
457
  .filter((step) => {
241
458
  const m = setupStepView(step, journal, required.has(step.id)).mark;
242
- return ((m === "act" || m === "fail") &&
459
+ return ((m === "act" || m === "fail" || adminLeft.has(step.id)) &&
243
460
  (ONBOARD_DEPENDENCIES[step.id] ?? []).every((id) => {
244
461
  const parent = journal.steps.find((s) => s.id === id);
245
462
  return parent?.state === "done" || parent?.state === "skipped";
@@ -247,7 +464,7 @@ export function setupFinalScreen(journal, base, paused = false, required = new S
247
464
  })
248
465
  .filter((step) => step.id !== "ready")
249
466
  .sort((a, b) => ONBOARD_STEPS.indexOf(a.id) - ONBOARD_STEPS.indexOf(b.id));
250
- const folded = (id) => id === "projects" ? "github.repos" : id === "values" ? "settings" : id;
467
+ const folded = (id) => id === "projects" ? "github.repos" : id;
251
468
  const unique = roots.filter((step, i) => roots.findIndex((s) => folded(s.id) === folded(step.id)) === i);
252
469
  const actions = unique
253
470
  .slice(0, 5)
@@ -274,16 +491,17 @@ export function setupFinalScreen(journal, base, paused = false, required = new S
274
491
  : journal.exit === 0
275
492
  ? "Ready for work"
276
493
  : "Not ready for work yet";
277
- const ticket = journal.steps.find((s) => s.id === "first-ticket" && s.state === "done");
278
- const key = ticket && value(ticket, "ticketKey");
494
+ const firstTicket = journal.steps.find((s) => s.id === "first-ticket");
495
+ const ticket = firstTicket?.state === "done" ? firstTicket : undefined;
496
+ const key = ticket && (value(ticket, "ticket") ?? value(ticket, "ticketKey"));
279
497
  const savedTicketUrl = ticket && (value(ticket, "url") ?? value(ticket, "ticketUrl"));
280
498
  const ticketUrl = savedTicketUrl && page(base, savedTicketUrl);
281
499
  const next = paused
282
500
  ? "Next: run catalyst onboard to continue."
283
- : key && ticketUrl
284
- ? `Next: open ${ticketUrl} and follow ${key}.`
501
+ : key
502
+ ? `Follow ${ticketUrl ?? key} in Linear; Catalyst comments there as each phase finishes.`
285
503
  : heading === "Setup complete"
286
- ? "Next: move a ticket to Todo in Linear."
504
+ ? `Next: move a ticket to ${dispatchStageName(firstTicket?.evidence?.stage)} in Linear.`
287
505
  : journal.exit === 0 && actions[0]
288
506
  ? `Next: ${actions[0].text[0].toLowerCase() + actions[0].text.slice(1)}`
289
507
  : actions.length === 1
@@ -294,7 +512,9 @@ export function setupFinalScreen(journal, base, paused = false, required = new S
294
512
  ? "Next: run catalyst onboard after you finish 1 to " +
295
513
  actions.length +
296
514
  "."
297
- : "Next: run catalyst onboard to check again.";
515
+ : readyStep?.reason === "member_team_required"
516
+ ? "Next: run catalyst onboard --team <KEY> to check again."
517
+ : "Next: run catalyst onboard to check again.";
298
518
  return {
299
519
  heading,
300
520
  readinessCause,
@@ -317,7 +537,7 @@ export function setupBrowserInstruction(id, base, opened = false) {
317
537
  "Connect your Linear account",
318
538
  ],
319
539
  "github.install": [
320
- "/settings/connections?install=github",
540
+ GITHUB_INSTALL_START,
321
541
  "install Catalyst on your GitHub organization",
322
542
  "Install Catalyst on your GitHub organization",
323
543
  ],
@@ -328,8 +548,8 @@ export function setupBrowserInstruction(id, base, opened = false) {
328
548
  ],
329
549
  accounts: [
330
550
  "/settings/coding-accounts",
331
- "add the token with your Claude email",
332
- "Add a coding account",
551
+ "add an API key",
552
+ "Add an API key",
333
553
  ],
334
554
  };
335
555
  const words = copy[id];
@@ -340,9 +560,6 @@ export function setupBrowserInstruction(id, base, opened = false) {
340
560
  ? `Opened your browser. ${words[2]} there.`
341
561
  : `Open this link and ${words[1]}:`,
342
562
  url: page(base, words[0]),
343
- preparation: id === "accounts"
344
- ? "In another terminal, run claude setup-token."
345
- : undefined,
346
563
  };
347
564
  }
348
565
  /** JSON facts use the same reachable actions as the terminal, while retaining the existing journal. */
@@ -386,8 +603,12 @@ export function onboardJsonView(journal, base, paused = false, options = {}) {
386
603
  const step = view.steps.find((step) => step.id === action.id);
387
604
  const role = view.steps.find((step) => step.id === "signin" && step.state === "done")?.evidence?.role;
388
605
  const admin = step.reason === "member_scope" ||
606
+ // CTC-4680: refused by the person's Catalyst role, not by GitHub, so a workspace admin acts.
607
+ step.reason === "github_installation_admin_required" ||
389
608
  step.reason?.endsWith("_identity_unverified") ||
390
- (action.id === "accounts" && role !== "owner" && role !== "admin");
609
+ ((action.id === "accounts" || action.id === "values") &&
610
+ role !== "owner" &&
611
+ role !== "admin");
391
612
  const url = action.text
392
613
  .match(/https?:\/\/[^\s<>]+/)?.[0]
393
614
  ?.replace(/[.,;:!?]+$/, "");
@@ -60,6 +60,7 @@ const ASCII_MARKS = {
60
60
  later: "[later]",
61
61
  run: "[..]",
62
62
  ask: "[?]",
63
+ now: "[next]",
63
64
  };
64
65
  const UNICODE_MARKS = {
65
66
  done: ["✓", "green"],
@@ -69,7 +70,34 @@ const UNICODE_MARKS = {
69
70
  later: ["·", "gray"],
70
71
  run: ["◐", "cyan"],
71
72
  ask: ["◆", "cyan"],
73
+ now: ["◆", "cyan"],
72
74
  };
75
+ /** CTC-4680: the Pixel Nucleus core, copper on a dark terminal and rust on a light one
76
+ * (catalyst-cloud docs/brand-mark.md), with the nearest 256- and 16-colour background. */
77
+ const CORE = {
78
+ dark: { rgb: "210;142;99", x256: 173, x16: 43 },
79
+ light: { rgb: "169;81;47", x256: 130, x16: 41 },
80
+ };
81
+ /**
82
+ * The mark's 4×4 grid in two terminal lines, each cell 2 columns by half a line. The ring is a
83
+ * glyph in the terminal's own foreground colour; the core is a background colour, so no
84
+ * foreground is ever set and the C reads on light and dark terminals alike. The second line
85
+ * reverses video so the core's half block takes the copper and its other half the ring.
86
+ */
87
+ export function pixelNucleusLines(env) {
88
+ // COLORFGBG is "fg;bg" (rxvt, Konsole, iTerm2); 7 and 15 are the light backgrounds.
89
+ const light = ["7", "15"].includes((env.COLORFGBG ?? "").split(";").at(-1) ?? "");
90
+ const core = light ? CORE.light : CORE.dark;
91
+ const bg = /^(truecolor|24bit)$/i.test(env.COLORTERM ?? "")
92
+ ? `48;2;${core.rgb}`
93
+ : /256/.test(env.TERM ?? "")
94
+ ? `48;5;${core.x256}`
95
+ : String(core.x16);
96
+ return [
97
+ `▄▄\u001b[${bg}m▀▀▀▀\u001b[49m▀▀`,
98
+ `▀▀\u001b[7;${bg}m▀▀▀▀\u001b[27;49m▄▄`,
99
+ ];
100
+ }
73
101
  export function createSetupRenderer(stream, env) {
74
102
  let traits = terminalTraits(stream, env);
75
103
  const style = (format, text) => traits.color ? styleText(format, text, { validateStream: false }) : text;
@@ -85,10 +113,14 @@ export function createSetupRenderer(stream, env) {
85
113
  const visible = (text) => text
86
114
  .replace(/\u001b\]8;;[^\u001b]*\u001b\\/g, "")
87
115
  .replace(/\u001b\[[0-9;]*m/g, "");
88
- const rail = traits.unicode ? "│" : "";
89
- const titleCol = traits.unicode ? 6 : 11;
90
- const outcomeCol = traits.unicode ? 38 : 43;
91
- const prefix = (col) => rail + " ".repeat(col - rail.length);
116
+ // CTC-4680 (round 2): headings and prose at column 2, step rows at 4, and their details 4 more,
117
+ // at 8, so the eye doesn't travel across a small screen to read them. No rail: the same columns
118
+ // on a terminal and in a pipe.
119
+ const textCol = 2;
120
+ const rowCol = 4;
121
+ const titleCol = rowCol + 4;
122
+ const outcomeCol = rowCol + (traits.unicode ? 38 : 43);
123
+ const prefix = (col) => " ".repeat(col);
92
124
  const mark = (kind) => {
93
125
  if (!traits.unicode)
94
126
  return ASCII_MARKS[kind];
@@ -109,9 +141,7 @@ export function createSetupRenderer(stream, env) {
109
141
  .map((line) => {
110
142
  const plain = visible(line);
111
143
  const indent = /^https?:\/\/\S+$/.test(plain) && stringWidth(plain) > width - col
112
- ? traits.unicode
113
- ? 3
114
- : 0
144
+ ? textCol
115
145
  : col;
116
146
  return prefix(indent) + line;
117
147
  });
@@ -120,13 +150,13 @@ export function createSetupRenderer(stream, env) {
120
150
  refresh();
121
151
  const lead = plan
122
152
  ? traits.unicode
123
- ? "│"
153
+ ? " "
124
154
  : " "
125
155
  : traits.unicode
126
156
  ? mark(kind)
127
157
  : mark(kind).padEnd(7);
128
158
  const titleWidth = stringWidth(visible(title));
129
- const label = `${lead}${traits.unicode ? " " : " "}${String(number || "").padStart(2)} ${title}`;
159
+ const label = `${prefix(rowCol)}${lead}${traits.unicode ? " " : " "}${String(number || "").padStart(2)} ${title}`;
130
160
  if (!outcome)
131
161
  return [label];
132
162
  if (width < 80 || titleWidth > 30)
@@ -212,6 +242,7 @@ export function createSetupRenderer(stream, env) {
212
242
  clear();
213
243
  abandon();
214
244
  };
245
+ let branded = false;
215
246
  const r = {
216
247
  get traits() {
217
248
  refresh();
@@ -219,11 +250,31 @@ export function createSetupRenderer(stream, env) {
219
250
  },
220
251
  intro(title) {
221
252
  refresh();
222
- ordinary([traits.unicode ? `┌ ${style("bold", title)}` : title]);
253
+ ordinary([prefix(textCol) + style("bold", title)]);
254
+ },
255
+ brand(program, version) {
256
+ if (branded)
257
+ return;
258
+ branded = true;
259
+ refresh();
260
+ if (!traits.color || traits.columns < 50) {
261
+ ordinary([
262
+ `${prefix(textCol)}Catalyst Cloud ${program}${version ? ` ${version}` : ""}`,
263
+ ]);
264
+ return;
265
+ }
266
+ const [one, two] = pixelNucleusLines(env);
267
+ ordinary([
268
+ `${prefix(textCol)}${one} ${style("bold", "Catalyst Cloud")}`,
269
+ `${prefix(textCol)}${two} ${style("dim", `${program}${version ? ` · ${version}` : ""}`)}`,
270
+ ]);
271
+ },
272
+ tracker(parts) {
273
+ ordinary(parts.flatMap((p) => stepRows(p.mark, p.number, p.title, p.note)));
223
274
  },
224
275
  outro(text) {
225
276
  abandon();
226
- write(traits.unicode ? [`│`, `└ ${text}`] : ["", text]);
277
+ write(["", prefix(textCol) + text]);
227
278
  },
228
279
  plan(number, title, text) {
229
280
  ordinary(stepRows("run", number, title, text, true));
@@ -316,14 +367,22 @@ export function createSetupRenderer(stream, env) {
316
367
  },
317
368
  heading(text) {
318
369
  abandon();
319
- write(traits.unicode ? ["│", `├─ ${style("bold", text)}`] : ["", text]);
370
+ write(["", prefix(textCol) + style("bold", text)]);
320
371
  },
321
372
  line(text) {
322
- ordinary(rows(text, traits.unicode ? 3 : 0));
373
+ ordinary(rows(text, textCol));
374
+ },
375
+ item(label, text) {
376
+ const col = textCol + stringWidth(label);
377
+ const lines = rows(text, col);
378
+ const hang = prefix(col);
379
+ ordinary(lines[0].startsWith(hang)
380
+ ? [prefix(textCol) + label + lines[0].slice(col), ...lines.slice(1)]
381
+ : [prefix(textCol) + label.trimEnd(), ...lines]);
323
382
  },
324
383
  blank() {
325
384
  refresh();
326
- ordinary([rail]);
385
+ ordinary([""]);
327
386
  },
328
387
  link(url) {
329
388
  return traits.links