@catalyst-cloud/cli 0.15.2 → 0.15.4

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 (39) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +4 -2
  3. package/dist/args.js +5 -1
  4. package/dist/cli.js +44 -20
  5. package/dist/oauth.js +1 -1
  6. package/dist/onboard-accounts.js +67 -171
  7. package/dist/onboard-checklist.js +310 -0
  8. package/dist/onboard-existing.js +1 -1
  9. package/dist/onboard-first-ticket.js +199 -0
  10. package/dist/onboard-headless.js +2 -2
  11. package/dist/onboard-housekeeping.js +93 -0
  12. package/dist/onboard-next.js +108 -9
  13. package/dist/onboard-ready.js +61 -0
  14. package/dist/onboard-runtime.js +60 -27
  15. package/dist/onboard-standalone-copy.js +73 -66
  16. package/dist/onboard-ui.js +278 -105
  17. package/dist/onboard-values.js +47 -0
  18. package/dist/onboard.js +63 -18
  19. package/dist/ready-copy.js +87 -0
  20. package/dist/ready.js +1 -57
  21. package/dist/setup-brand.js +202 -0
  22. package/dist/setup-onboard-copy.js +314 -42
  23. package/dist/setup-render.js +99 -17
  24. package/dist/setup.js +6 -3
  25. package/dist/terminal-background.js +220 -0
  26. package/package.json +1 -1
  27. package/skills/catalyst-github/SKILL.md +1 -1
  28. package/skills/catalyst-linear/SKILL.md +1 -1
  29. package/skills/catalyst-onboard/SKILL.md +3 -3
  30. package/skills/catalyst-onboard/references/replacing-a-credential.md +9 -15
  31. package/skills/catalyst-onboard/references/what-a-phase-needs.md +4 -11
  32. package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
  33. package/skills/run-this-project/SKILL.md +2 -2
  34. package/skills/unstick/SKILL.md +1 -1
  35. package/skills/what-needs-me/SKILL.md +1 -1
  36. package/skills/what-this-repo-needs/SKILL.md +1 -1
  37. package/skills/whats-happening/SKILL.md +2 -2
  38. package/skills/whats-happening/references/coding-accounts.md +1 -1
  39. package/skills/whats-happening/scripts/snapshot.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.4
4
+
5
+ - Setup shows a colour mark and clearer progress, with a plain display when your terminal needs it.
6
+ - Setup separates your next steps from tasks waiting on an owner or admin, and reports when it stops waiting for an account connection.
7
+ - Setup wraps long text by words and keeps numbered steps in order.
8
+
9
+ ## 0.15.3
10
+
11
+ - Setup can finish once the steps needed to run work are ready. It lists optional next steps and keeps your first-ticket choice when you resume.
12
+ - Setup shows one progress display with numbered steps and the reason a step needs attention.
13
+ - When the Connect accounts page is available, setup uses it to finish account connections. Any usable AI account counts without a test request.
14
+ - Setup offers API keys by default. Subscription account options appear only when your workspace enables them.
15
+
3
16
  ## 0.15.2
4
17
 
5
18
  - `catalyst setup` is the command to set up Catalyst. It runs the same steps as `catalyst onboard`, which still works.
package/README.md CHANGED
@@ -111,7 +111,7 @@ The command is `catalyst`. `catalyst-skills` still works as a deprecated alias,
111
111
 
112
112
  ## What this is
113
113
 
114
- A set of skills that let your coding agent run your own Catalyst Cloud account (https://staging.catalystcloud.dev) from your seat: what is happening, what needs you, and what to do about it. They read your account through the Catalyst Cloud SDK, write to it through the account's agent proxy, and never compose a URL or run a tool of their own; every read, write and subscription is a `catalyst` verb with `--help`. Every skill is plain Markdown under `skills/<name>/SKILL.md` in this repository, and your own login — a keyless device-code session, or a personal key — is the only credential. Your agent acts as you: the asks it raises and the comments it posts carry your name, and "what needs me" means you.
114
+ A set of skills that let your coding agent run your own Catalyst Cloud account (https://staging.catalystcloud.dev) from your seat: what is happening, what needs you, and what to do about it. They read your account through the Catalyst Cloud SDK, write to it through the account's agent proxy, and never compose a URL or run a tool of their own; every read, write and live watch is a `catalyst` verb with `--help`. Every skill is plain Markdown under `skills/<name>/SKILL.md` in this repository, and your own login — a keyless device-code session, or a personal key — is the only credential. Your agent acts as you: the asks it raises and the comments it posts carry your name, and "what needs me" means you.
115
115
 
116
116
  ## What connecting does
117
117
 
@@ -133,6 +133,8 @@ catalyst onboard
133
133
  catalyst ready --onboarding --json
134
134
  ```
135
135
 
136
+ `catalyst setup` opens with the Catalyst Cloud mark in your terminal's colours. It asks the terminal whether its background is light or dark; set `CATALYST_THEME=light` or `CATALYST_THEME=dark` when it guesses wrong. `--no-color` or `NO_COLOR` prints plain text without the mark.
137
+
136
138
  The dry run writes no files. `--only <step>` runs one step and reports its scope separately from full onboarding. Exit 10 means a failed step, 11 means waiting, and 12 means a guard refused the action. The private receipt is `install/last-run.json` under your machine's Catalyst state directory; credentials stay in the login config. A fake HOME reports old services and keeps them intact.
137
139
 
138
140
  ### Headless setup (CI, image builds, agent VMs)
@@ -231,7 +233,7 @@ Some of these only read, and some write: a comment, a card move, an ask, a relea
231
233
 
232
234
  ## What a key cannot see yet
233
235
 
234
- Your personal key reads everything the skills need — tickets, pull requests, the eligibility explainer, the dispatch queue, fleet activity, per-ticket execution history (`catalyst explain --history <ticket>`: phase attempts, remediation rounds, park state) and coding-account status (`catalyst accounts`: provider, declared and observed state, window usage, walls, quarantine — never a credential; enrolling or pausing one is `<your cloud>/settings/coding-accounts`). It also releases a parked or held ticket once its cause is fixed: `catalyst release <ticket> --because <what changed>` (the `unstick` skill runs it), recorded against your name and shown in the ticket's history. And it declares what your containers need: `catalyst environment` reads the account-wide declaration, `environment propose --file <path> --approve` proposes and approves it in one compare-and-set, and the values behind the names stay in the cloud — reading needs any active seat, proposing and approving need an admin or owner one. An admin or owner can put a repository's values in from the terminal. `catalyst secret import .env --repo owner/name` stores every name in the file and lists the declared names that still have no value. `catalyst secret set NAME --repo owner/name --command 'op read op://Vault/item/field'` runs the command on your machine and stores its output; with no `--command` it reads the value from stdin, or asks for it without echoing. No value is ever printed, and the cloud's audit records the command, not its output. That is the account's own declaration — a repository's own `.catalyst/catalyst.toml`, which `catalyst-onboard` already reports on, is a separate thing. `catalyst env inventory` lists the environment variable names a repository needs — never a value; `env check` checks the TOML syntax and environment variable table in `.catalyst/catalyst.toml` offline; `env migrate [catalyst.env.json]` prints a TOML environment table from a legacy declaration, with all names optional and values omitted. These commands need no login (the `what-this-repo-needs` skill walks through them). Two things it cannot do, and the skills say so by name rather than guess:
236
+ Your personal key reads everything the skills need — tickets, pull requests, the eligibility explainer, the dispatch queue, fleet activity, per-ticket execution history (`catalyst explain --history <ticket>`: phase attempts, remediation rounds, park state) and coding-account status (`catalyst accounts`: provider, declared and observed state, usage limits, walls, quarantine — never a credential; enrolling or pausing one is `<your cloud>/settings/coding-accounts`). It also releases a parked or held ticket once its cause is fixed: `catalyst release <ticket> --because <what changed>` (the `unstick` skill runs it), recorded against your name and shown in the ticket's history. And it declares what your containers need: `catalyst environment` reads the account-wide declaration, `environment propose --file <path> --approve` proposes and approves it in one compare-and-set, and the values behind the names stay in the cloud — reading needs any active seat, proposing and approving need an admin or owner one. An admin or owner can put a repository's values in from the terminal. `catalyst secret import .env --repo owner/name` stores every name in the file and lists the declared names that still have no value. `catalyst secret set NAME --repo owner/name --command 'op read op://Vault/item/field'` runs the command on your machine and stores its output; with no `--command` it reads the value from stdin, or asks for it without echoing. No value is ever printed, and the cloud's audit records the command, not its output. That is the account's own declaration — a repository's own `.catalyst/catalyst.toml`, which `catalyst-onboard` already reports on, is a separate thing. `catalyst env inventory` lists the environment variable names a repository needs — never a value; `env check` checks the TOML syntax and environment variable table in `.catalyst/catalyst.toml` offline; `env migrate [catalyst.env.json]` prints a TOML environment table from a legacy declaration, with all names optional and values omitted. These commands need no login (the `what-this-repo-needs` skill walks through them). Two things it cannot do, and the skills say so by name rather than guess:
235
237
 
236
238
  - Read pull-request labels or the reviewer's reaction. The mirror does not carry them; GitHub's own page does.
237
239
  - Compute a flow number — cycle time, throughput, or how long pull requests have been open. Nothing serves those yet, so say they are not computed rather than counting something else and calling it that.
package/dist/args.js CHANGED
@@ -64,6 +64,10 @@ export const FLAG_TABLES = {
64
64
  value: false,
65
65
  help: "do not run Catalyst's work on this machine, and do not ask",
66
66
  },
67
+ "no-color": {
68
+ value: false,
69
+ help: "print setup in plain text, without colour or the Catalyst Cloud mark",
70
+ },
67
71
  "resume-from": {
68
72
  value: true,
69
73
  help: "resume at a verified onboarding step",
@@ -459,7 +463,7 @@ export const FLAG_TABLES = {
459
463
  export const VERB_USAGE = {
460
464
  setup: "setup [onboard options] (sets up Catalyst step by step; same as catalyst onboard) | setup --engine <file> --engine-sha256 <hex> [-- <install options>] (run by install.sh; install.sh --help lists the options)",
461
465
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
462
- onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--coding-account <slot>] [--resume-from <step>] [--only <step>] [--local-sync] [--runner|--no-runner] [--yes] [--dry-run] [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--json]",
466
+ onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--coding-account <slot>] [--resume-from <step>] [--only <step>] [--local-sync] [--runner|--no-runner] [--no-color] [--yes] [--dry-run] [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--json]",
463
467
  install: "install [--skills-dir <dir>] [--force]",
464
468
  status: "status",
465
469
  notice: "notice",
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // cli.ts — the `catalyst` dispatcher (npm: @catalyst-cloud/cli). Verbs live in
2
2
  // their own modules; this file keeps every export the 0.1 tests and the bin import.
3
- import { createSetupRenderer } from "./setup-render.js";
3
+ import { createSetupRenderer, setupBanner, } from "./setup-render.js";
4
4
  import { existsSync, openSync, closeSync } from "node:fs";
5
5
  import { ReadStream as TerminalInput } from "node:tty";
6
6
  import { Writable } from "node:stream";
@@ -44,12 +44,12 @@ import { cmdRepo } from "./repo.js";
44
44
  import { cmdLegacy } from "./legacy.js";
45
45
  import { cmdIdentity } from "./identity.js";
46
46
  import { cmdConnections } from "./connections.js";
47
- import { cmdOnboard, onboardErrorJournal, onboardRequiredStepsWaiting, } from "./onboard.js";
47
+ import { cmdOnboard, onboardErrorJournal, } from "./onboard.js";
48
48
  import { createClackOnboardUi, shouldUseOnboardUi, } from "./onboard-ui.js";
49
49
  import { createOnboardRuntime } from "./onboard-runtime.js";
50
50
  import { argvRequestsHeadless, headlessOnboardDeps, headlessUsageRefusal, onboardHeadlessRequested, runOnboardHeadless, } from "./onboard-headless.js";
51
51
  import { savedOnboardBaseUrl } from "./onboard-next.js";
52
- import { onboardingReadyReport, observeCloudOnboarding, } from "./onboard-ready.js";
52
+ import { onboardingReadyReport, onboardReadyStep, observeCloudOnboarding, } from "./onboard-ready.js";
53
53
  import { selectedOnboardTeam } from "./onboard-existing.js";
54
54
  export { CONFIG_MODE, DEFAULT_BASE_URL, FIRST_STAMPED_VERSION, LEGACY_PACKAGE_NAME, PACKAGE_NAME, PROVENANCE_MARKER, CliError, MeError, UsageError, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, fetchMe, formatMode, installedBundleVersion, installSkills, loadConfig, modernCliPath, normalizeBaseUrl, parseArgs, parseChangelogEntry, parseProvenanceVersion, readChangelog, readManifest, resolveSkillsDir, saveConfig, skillsSourceDir, updateNoticeLine, writeConfig, };
55
55
  export const CUSTOMER_SKILLS = [
@@ -210,6 +210,7 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
210
210
  // CTC-4625: setup passes the install options through to its engine untouched, so they never meet
211
211
  // this CLI's own flag tables. CTC-4680: without --engine, `setup` is the name a person types for
212
212
  // the onboard flow, so it runs exactly that.
213
+ const viaSetup = drawsMark(argv);
213
214
  if (argv[0] === "setup") {
214
215
  const rest = argv.slice(1);
215
216
  const own = rest.indexOf("--") === -1 ? rest : rest.slice(0, rest.indexOf("--"));
@@ -278,6 +279,9 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
278
279
  // Headless runs drop the terminal UI, browser sign-in and every browser action (CTC-4633).
279
280
  const runOnboard = async (args, ctx, headless) => {
280
281
  let ui = headless ? undefined : deps.setupUi;
282
+ // --no-color reaches the renderer as NO_COLOR, so the banner and every step row go plain.
283
+ const noColor = args.flags["no-color"] === true;
284
+ const rendererEnv = noColor ? { ...ctx.env, NO_COLOR: "1" } : ctx.env;
281
285
  let terminal;
282
286
  let input = process.stdin;
283
287
  const originalRaw = Boolean(input.isRaw);
@@ -300,12 +304,21 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
300
304
  }
301
305
  if (input.isTTY) {
302
306
  try {
307
+ const banner = await setupBanner({
308
+ setup: viaSetup,
309
+ env: rendererEnv,
310
+ input,
311
+ stdout: process.stdout,
312
+ stream: bannerStream(ctx),
313
+ noColor,
314
+ });
303
315
  ui = createClackOnboardUi(await import("@clack/prompts"), { input, output: process.stdout }, {
304
316
  baseUrl: () => savedOnboardBaseUrl(ctx.home),
305
317
  verbose: args.flags.verbose === true,
306
- renderer: createSetupRenderer(process.stdout, ctx.env),
318
+ renderer: createSetupRenderer(process.stdout, rendererEnv, banner),
307
319
  disposeRenderer: true,
308
320
  signinTimeoutMs: deps.onboardSigninTimeoutMs,
321
+ version: manifest.version,
309
322
  });
310
323
  }
311
324
  catch (error) {
@@ -325,9 +338,17 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
325
338
  });
326
339
  ui = createClackOnboardUi(await import("@clack/prompts"), { input: process.stdin, output }, {
327
340
  baseUrl: () => savedOnboardBaseUrl(ctx.home),
328
- renderer: createSetupRenderer(output, ctx.env),
341
+ renderer: createSetupRenderer(output, rendererEnv, await setupBanner({
342
+ setup: viaSetup,
343
+ env: rendererEnv,
344
+ input: process.stdin,
345
+ stdout: process.stdout,
346
+ stream: bannerStream(ctx),
347
+ noColor,
348
+ })),
329
349
  disposeRenderer: true,
330
350
  signinTimeoutMs: deps.onboardSigninTimeoutMs,
351
+ version: manifest.version,
331
352
  interactive: false,
332
353
  verbose: args.flags.verbose === true,
333
354
  });
@@ -395,21 +416,7 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
395
416
  localSync: journal?.localSync ?? args.flags["local-sync"] === true,
396
417
  observe: observeCloudOnboarding,
397
418
  });
398
- const failed = report.checks.some((check) => check.required && check.state === "fail") && !onboardRequiredStepsWaiting(journal);
399
- return {
400
- state: report.state === "complete"
401
- ? "done"
402
- : failed
403
- ? "failed"
404
- : "waiting",
405
- reason: report.state === "complete"
406
- ? undefined
407
- : "onboarding_checks_pending",
408
- evidence: {
409
- checks: report.checks.length,
410
- passed: report.checks.filter((check) => check.state === "pass").length,
411
- },
412
- };
419
+ return onboardReadyStep(report, journal);
413
420
  },
414
421
  legacy: deps.legacy,
415
422
  openBrowser: deps.openBrowser,
@@ -572,6 +579,23 @@ const VERB_HELP_KNOWN = Object.fromEntries([
572
579
  "legacy",
573
580
  "onboard",
574
581
  ].map((v) => [v, true]));
582
+ /** CTC-4680: the Pixel Nucleus mark belongs to `catalyst setup` alone. `setup` runs the onboard
583
+ * flow, so this is read from the verb typed, before setup becomes onboard. */
584
+ export function drawsMark(argv) {
585
+ return argv[0] === "setup";
586
+ }
587
+ /** CTC-4680: the banner goes to stderr, through ctx when stderr is not a terminal. */
588
+ function bannerStream(ctx) {
589
+ if (process.stderr.isTTY === true)
590
+ return process.stderr;
591
+ return {
592
+ write(chunk) {
593
+ for (const line of chunk.replace(/\n$/, "").split("\n"))
594
+ ctx.stderr(line);
595
+ return true;
596
+ },
597
+ };
598
+ }
575
599
  /** CTC-4625: `catalyst setup`, wired to this terminal, @clack/prompts and the keyless login. */
576
600
  async function runSetup(argv, ctx, deps) {
577
601
  if (argv[0] === "--help" || argv[0] === "-h") {
package/dist/oauth.js CHANGED
@@ -156,7 +156,7 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
156
156
  throw new Error("browser-unavailable");
157
157
  await deps.openBrowser(auth.verification_uri_complete ?? auth.verification_uri);
158
158
  browserOpened = true;
159
- ctx.stdout("Opened your browser to that page — approve there, or use the code above.");
159
+ ctx.stdout("Opened your browser to that page. Approve there, or use the code above.");
160
160
  }
161
161
  catch {
162
162
  // a browser that will not open is not a failure; the code and URL still work
@@ -3,7 +3,6 @@ import { readExistingOnboardJson } from "./onboard-existing.js";
3
3
  import { pollConsent } from "./onboard-consent.js";
4
4
  import { verifyOnboardRoutes } from "./onboard-capabilities.js";
5
5
  const listPath = "/api/v1/coding-accounts";
6
- const validatePath = "/api/v1/coding-accounts/:slot/validate";
7
6
  const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
8
7
  ? value
9
8
  : null;
@@ -94,195 +93,92 @@ async function inventory(ctx, journal, signal) {
94
93
  }
95
94
  return { slots, identity };
96
95
  }
97
- /** Verify one existing Claude slot per run. A stored login, cached poll or Codex shape is not
98
- * fresh provider access. Enrollment and a phase's actual lease eligibility remain separate. */
96
+ /** Why one account cannot take work, or null when it can. The Connect page's AI-account rule
97
+ * (the server's isUsableCodingAccount): any provider, any member's account, and a canceled
98
+ * subscription keeps working until its paid access ends. */
99
+ function unusableCause(row, now) {
100
+ if (row.declaredState !== "active")
101
+ return "coding_account_inactive";
102
+ if (row.revokedAtMs !== null ||
103
+ (row.accessEndsAtMs !== null && row.accessEndsAtMs <= now))
104
+ return "coding_account_ended";
105
+ if (row.needsCredential)
106
+ return "coding_account_needs_login";
107
+ if (row.quarantined)
108
+ return "coding_account_quarantined";
109
+ if (row.walled)
110
+ return "coding_account_walled";
111
+ return null;
112
+ }
113
+ export function usableAiAccount(row, now) {
114
+ return unusableCause(row, now) === null;
115
+ }
116
+ /** CTC-4680: the step is done when the workspace has at least one usable AI account, from any
117
+ * provider. With --coding-account, that named account is the one that must be usable. */
99
118
  export function onboardAccountsAdapter(input = {}) {
100
- let proof = null;
101
- let attempted = false;
102
- const candidate = (slots, now) => slots
119
+ const usable = (slots, now) => slots
103
120
  .filter((row) => (input.slot === undefined || row.accountSlot === input.slot) &&
104
- row.provider === "claude" &&
105
- row.declaredState === "active" &&
106
- row.ownedByMe &&
107
- !row.needsCredential &&
108
- !row.walled &&
109
- !row.quarantined &&
110
- row.renewalStatus !== "canceled" &&
111
- row.revokedAtMs === null &&
112
- (row.accessEndsAtMs === null || row.accessEndsAtMs > now))
121
+ usableAiAccount(row, now))
113
122
  .sort((a, b) => a.accountSlot.localeCompare(b.accountSlot))[0];
114
123
  const check = async (ctx, journal, signal) => {
115
124
  const live = await inventory(ctx, journal, signal);
116
125
  if ("reason" in live)
117
126
  return waiting(live.reason);
118
- const selected = candidate(live.slots, ctx.now().getTime());
119
- if (proof &&
120
- equal(live.identity, proof) &&
121
- live.slots.some((row) => row.accountSlot === proof.slot &&
122
- row.provider === proof.provider &&
123
- row.declaredState === "active" &&
124
- row.ownedByMe &&
125
- !row.needsCredential &&
126
- !row.walled &&
127
- !row.quarantined &&
128
- row.renewalStatus !== "canceled" &&
129
- row.revokedAtMs === null &&
130
- (row.accessEndsAtMs === null ||
131
- row.accessEndsAtMs > ctx.now().getTime())) &&
132
- proof.checkedAtMs <= ctx.now().getTime() + 5000 &&
133
- proof.checkedAtMs >= ctx.now().getTime() - 30000)
127
+ const selected = usable(live.slots, ctx.now().getTime());
128
+ if (selected)
134
129
  return {
135
130
  state: "done",
136
131
  evidence: {
137
- accountSlot: proof.slot,
138
- provider: proof.provider,
139
- checkedAt: new Date(proof.checkedAtMs).toISOString(),
132
+ provider: selected.provider,
133
+ checkedAt: ctx.now().toISOString(),
140
134
  },
141
135
  };
142
- proof = null;
143
- if (!live.slots.length)
144
- return input.waitForAccount &&
145
- ["owner", "admin"].includes(live.identity.role)
146
- ? { state: "pending" }
147
- : waiting("account_enrollment_required");
148
- if (input.slot !== undefined &&
149
- !live.slots.some((row) => row.accountSlot === input.slot))
150
- return waiting("coding_account_not_found");
151
- if (!selected)
152
- return waiting(live.slots.some((row) => row.provider === "codex")
153
- ? "codex_provider_access_unverified"
154
- : "account_provider_access_unverified");
155
- if (attempted)
156
- return waiting("account_provider_access_unverified");
157
- const cfg = loadConfig(ctx.home);
158
- if (!cfg?.user || !["owner", "admin"].includes(cfg.user.role))
159
- return waiting("account_validation_admin_required");
160
- return { state: "pending" };
136
+ if (input.slot !== undefined) {
137
+ const named = live.slots.find((row) => row.accountSlot === input.slot);
138
+ // A named account speaks for itself: other accounts in the workspace may be fine.
139
+ return waiting(named
140
+ ? (unusableCause(named, ctx.now().getTime()) ?? "coding_account_not_found")
141
+ : "coding_account_not_found");
142
+ }
143
+ if (input.waitForAccount &&
144
+ ["owner", "admin"].includes(live.identity.role))
145
+ return { state: "pending" };
146
+ return waiting(live.slots.length ? "ai_account_not_usable" : "account_enrollment_required");
161
147
  };
162
148
  return {
163
149
  check,
164
150
  act: async (ctx, journal, external) => {
165
- if (attempted)
166
- return waiting("account_provider_access_unverified");
167
151
  const initial = await inventory(ctx, journal, external);
168
152
  if ("reason" in initial)
169
153
  return waiting(initial.reason);
170
- let live = initial;
171
- if (!live.slots.length &&
172
- input.waitForAccount &&
173
- ["owner", "admin"].includes(live.identity.role)) {
174
- const result = await input.waitForAccount(() => pollConsent({
175
- timeoutMs: input.accountWaitMs,
176
- signal: external,
177
- sleep: input.sleep,
178
- readStatus: async (signal) => {
179
- const current = await inventory(ctx, journal, signal);
180
- if ("reason" in current)
181
- return { outcome: "waiting", reason: current.reason };
182
- live = current;
183
- return { outcome: current.slots.length ? "connected" : "absent" };
184
- },
185
- }));
186
- if (result.state !== "done")
187
- return waiting(result.reason === "consent_timeout"
188
- ? "account_enrollment_required"
189
- : (result.reason ?? "account_enrollment_required"));
190
- }
191
- const selected = candidate(live.slots, ctx.now().getTime());
192
- if (!selected)
193
- return waiting("account_provider_access_unverified");
194
- const support = await verifyOnboardRoutes(ctx, journal, [{ method: "POST", path: validatePath }], external);
195
- if ("reason" in support)
196
- return waiting(support.reason);
197
- const cfg = loadConfig(ctx.home);
198
- if (!equal(binding(ctx, journal), live.identity) ||
199
- !cfg?.user ||
200
- !["owner", "admin"].includes(cfg.user.role))
201
- return waiting("account_identity_unverified");
202
- const expiry = cfg.auth
203
- ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime()
204
- : 0;
205
- const bearer = cfg.key ||
206
- (cfg.auth && Number.isFinite(expiry) && expiry > 30000
207
- ? cfg.auth.accessToken
208
- : undefined);
209
- if (!bearer)
210
- return waiting("account_login_refresh_required");
211
- input.message?.("Checking one stored Claude account with a one-token provider request. This may use Claude quota. Codex credentials are not refreshed. Phase eligibility is checked separately.");
212
- // A display callback must not be able to switch the identity used for this request.
213
- if (!equal(binding(ctx, journal), live.identity))
214
- return waiting("account_identity_unverified");
215
- const deadline = new AbortController();
216
- const signal = external
217
- ? AbortSignal.any([external, deadline.signal])
218
- : deadline.signal;
219
- const timer = setTimeout(() => deadline.abort(), 15000);
220
- let remove = () => { };
221
- try {
222
- return await new Promise((resolve) => {
223
- const stopped = () => resolve(waiting(external?.aborted
224
- ? "interrupted"
225
- : "account_validation_unavailable"));
226
- if (signal.aborted) {
227
- stopped();
228
- return;
229
- }
230
- signal.addEventListener("abort", stopped, { once: true });
231
- remove = () => signal.removeEventListener("abort", stopped);
232
- Promise.resolve()
233
- .then(async () => {
234
- if (signal.aborted)
235
- return waiting("interrupted");
236
- // UI callbacks can queue a config change before this deferred send executes.
237
- if (!equal(binding(ctx, journal), live.identity))
238
- return waiting("account_identity_unverified");
239
- attempted = true;
240
- const response = await ctx.fetch(`${support.origin}/api/v1/coding-accounts/${encodeURIComponent(selected.accountSlot)}/validate`, {
241
- method: "POST",
242
- redirect: "error",
243
- signal,
244
- headers: {
245
- authorization: `Bearer ${bearer}`,
246
- accept: "application/json",
247
- },
248
- });
249
- if (response.status !== 200)
250
- return waiting("account_validation_unavailable");
251
- const body = object(await response.json());
252
- if (signal.aborted ||
253
- !equal(binding(ctx, journal), live.identity))
254
- return waiting("account_identity_unverified");
255
- const now = ctx.now().getTime();
256
- if (!body ||
257
- body.provider !== selected.provider ||
258
- typeof body.checkedAtMs !== "number" ||
259
- !Number.isSafeInteger(body.checkedAtMs) ||
260
- body.checkedAtMs > now + 5000 ||
261
- body.checkedAtMs < now - 30000 ||
262
- !["working", "walled", "rejected", "inconclusive"].includes(String(body.result)))
263
- return waiting("account_validation_unverified");
264
- if (body.result !== "working")
265
- return waiting(body.result === "walled"
266
- ? "account_provider_walled"
267
- : body.result === "rejected"
268
- ? "account_provider_rejected"
269
- : "account_provider_access_unverified");
270
- proof = {
271
- ...live.identity,
272
- slot: selected.accountSlot,
273
- provider: selected.provider,
274
- checkedAtMs: body.checkedAtMs,
275
- };
276
- input.message?.("Claude provider access was verified. This does not reserve a runner or prove that a phase can start.");
277
- return check(ctx, journal, signal);
278
- })
279
- .then((result) => (signal.aborted ? stopped() : resolve(result)), () => stopped());
280
- });
281
- }
282
- finally {
283
- clearTimeout(timer);
284
- remove();
285
- }
154
+ if (!input.waitForAccount ||
155
+ !["owner", "admin"].includes(initial.identity.role))
156
+ return check(ctx, journal, external);
157
+ let enrolled = initial.slots.length > 0;
158
+ const result = await input.waitForAccount(() => pollConsent({
159
+ timeoutMs: input.accountWaitMs,
160
+ signal: external,
161
+ sleep: input.sleep,
162
+ readStatus: async (signal) => {
163
+ const current = await inventory(ctx, journal, signal);
164
+ if ("reason" in current)
165
+ return { outcome: "waiting", reason: current.reason };
166
+ enrolled = current.slots.length > 0;
167
+ return {
168
+ outcome: usable(current.slots, ctx.now().getTime())
169
+ ? "connected"
170
+ : "absent",
171
+ };
172
+ },
173
+ }));
174
+ if (result.state === "done")
175
+ return check(ctx, journal, external);
176
+ // A wait that ran out holds the step and offers another try (round 5).
177
+ return waiting(result.reason !== "consent_timeout"
178
+ ? (result.reason ?? "account_enrollment_required")
179
+ : enrolled
180
+ ? "ai_account_not_usable"
181
+ : "account_enrollment_required");
286
182
  },
287
183
  };
288
184
  }