@catalyst-cloud/cli 0.15.6 → 0.16.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 (54) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +9 -6
  3. package/dist/args.js +26 -1
  4. package/dist/ask.js +56 -0
  5. package/dist/capabilities.js +3 -1
  6. package/dist/cli.js +10 -5
  7. package/dist/environment.js +1 -1
  8. package/dist/execution.js +2 -2
  9. package/dist/hosts.js +89 -0
  10. package/dist/legacy.js +12 -2
  11. package/dist/machine-inventory.js +37 -0
  12. package/dist/onboard-capacity.js +5 -6
  13. package/dist/onboard-github-copy.js +2 -0
  14. package/dist/onboard-github.js +2 -0
  15. package/dist/onboard-headless.js +2 -2
  16. package/dist/onboard-next.js +21 -12
  17. package/dist/onboard-project-cleanup.js +100 -0
  18. package/dist/onboard-returning.js +172 -0
  19. package/dist/onboard-runner.js +149 -34
  20. package/dist/onboard-runtime.js +26 -1
  21. package/dist/onboard-ui.js +67 -1
  22. package/dist/onboard-workflow.js +32 -7
  23. package/dist/onboard.js +9 -2
  24. package/dist/ready-copy.js +30 -24
  25. package/dist/ready.js +2 -2
  26. package/dist/secret.js +2 -2
  27. package/dist/setup-onboard-copy.js +37 -32
  28. package/dist/setup.js +4 -0
  29. package/dist/transport.js +18 -2
  30. package/package.json +8 -5
  31. package/skills/catalyst-github/SKILL.md +1 -1
  32. package/skills/catalyst-linear/SKILL.md +1 -1
  33. package/skills/catalyst-onboard/SKILL.md +3 -3
  34. package/skills/catalyst-onboard/references/connecting-this-machine.md +4 -4
  35. package/skills/catalyst-onboard/references/declaring-a-repository.md +1 -1
  36. package/skills/catalyst-onboard/references/reading-ready.md +14 -14
  37. package/skills/catalyst-onboard/references/replacing-a-credential.md +1 -1
  38. package/skills/catalyst-onboard/references/the-one-path.md +5 -5
  39. package/skills/catalyst-onboard/references/what-a-phase-needs.md +4 -4
  40. package/skills/catalyst-onboard/references/what-the-browser-owns.md +20 -10
  41. package/skills/catalyst-onboard/scripts/where-am-i.mjs +50 -27
  42. package/skills/run-this-project/SKILL.md +1 -1
  43. package/skills/run-this-project/references/stalls-and-escalation.md +1 -1
  44. package/skills/unstick/SKILL.md +1 -1
  45. package/skills/what-needs-me/SKILL.md +2 -2
  46. package/skills/what-needs-me/references/raising-a-decision.md +12 -1
  47. package/skills/what-needs-me/references/reading-the-inbox.md +1 -1
  48. package/skills/what-needs-me/scripts/raise.mjs +10 -1
  49. package/skills/what-this-repo-needs/SKILL.md +1 -1
  50. package/skills/whats-happening/SKILL.md +3 -2
  51. package/skills/whats-happening/references/coding-accounts.md +1 -1
  52. package/skills/whats-happening/references/machines.md +9 -0
  53. package/skills/whats-happening/references/settings-and-where-they-live.md +8 -8
  54. package/skills/whats-happening/references/stages-and-mapping.md +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.1
4
+
5
+ Setup lists your existing projects and lets you finish this computer without choosing a team again.
6
+
7
+ - Keep your saved machine name when setup finds an existing installation, even if its settings file is missing.
8
+ - Check readiness across projects that share a Linear team, with a clear next action when a check cannot finish.
9
+ - Remove recognized older background jobs automatically. Preserve shared data and explain any cleanup that needs attention.
10
+ - Recheck the existing installation before offering a replacement, and distinguish an ended enrollment from one that could not be verified.
11
+
12
+ ## 0.16.0
13
+
14
+ The CLI, skills and plugin now share the 0.16 release line with the SDK and installer. The CLI installs the matching Node replica module for local cache commands. Update the CLI and update the Catalyst plugin through your agent's plugin manager.
15
+
3
16
  ## 0.15.6
4
17
 
5
18
  Setup checks current readiness and waits up to one minute while Catalyst reads repository settings.
package/README.md CHANGED
@@ -85,7 +85,7 @@ catalyst ready
85
85
 
86
86
  `catalyst login` with no key opens a device-code login: it prints a short code and a URL, you approve it in your browser, and this machine is connected as you. On a machine with no browser (a remote box, a container) the code and URL still work — approve them from your phone. The short-lived session refreshes silently afterwards, so you log in about once a year. `npx -p @catalyst-cloud/cli catalyst login` works without the global install, and `bunx` works in place of `npx`. A non-default cloud is set with `CATALYST_CLOUD_BASE_URL` or `--base-url <url>`.
87
87
 
88
- Prefer a key? Pass one instead — mint a **personal key** at Settings → API keys in the Catalyst Cloud app (every member can; no admin needed). The environment form keeps it out of your shell history:
88
+ For a script or an unattended machine, where nobody can approve a code, pass a **personal key** instead. Minting one is a browser step: Settings → API keys in the Catalyst Cloud app (every member can; no admin needed). The environment form keeps it out of your shell history:
89
89
 
90
90
  ```sh
91
91
  CATALYST_CLOUD_TOKEN=<your-personal-key> catalyst login
@@ -98,7 +98,8 @@ After a workspace owner connects your cloud account's Linear workspace, connect
98
98
  ```sh
99
99
  catalyst connections personal linear start
100
100
  catalyst connections personal linear status
101
- # First install the workspace's GitHub App and register its repository in Settings.
101
+ # First install the workspace's GitHub App and register its repository:
102
+ # catalyst onboard --team <KEY> --repo <owner/name>
102
103
  catalyst connections personal github start
103
104
  catalyst connections personal github status
104
105
  ```
@@ -121,7 +122,7 @@ A keyless session's access token is short-lived and rotates on its own: every re
121
122
 
122
123
  `login` does not install skills; the install command above did that. Re-running `login` after a key rotation, or to switch rails, rewrites the config. `catalyst status` prints which cloud account this machine is connected to and as whom, `catalyst ready` prints one READY or NOT READY verdict with the fix for each failure and who can apply it, and `catalyst --version` prints the package version and its pinned contract range.
123
124
 
124
- The setup skill Catalyst seeds into your repository ends by pointing at this same command. That served skill lives in the Catalyst Cloud application, not here; this is the only connect step a customer runs. Your cloud account's **account key** (Settings → Account keys, admin-minted) is a different credential — for a host or daemon that runs unattended — and is not what a person connects their own agent with: it would strip your name from everything your agent writes, and `login` says so if you use one.
125
+ The setup skill Catalyst seeds into your repository ends by pointing at this same command. That served skill lives in the Catalyst Cloud application, not here; this is the only connect step a customer runs. Your cloud account's **account key** (minted by `catalyst onboard --runner`, run by an owner or admin) is a different credential — for a host or daemon that runs unattended — and is not what a person connects their own agent with: it would strip your name from everything your agent writes, and `login` says so if you use one.
125
126
 
126
127
  ## Run and resume setup
127
128
 
@@ -161,6 +162,8 @@ Before any network call, the run checks that every input is present. Each missin
161
162
  | The person's Linear | `linear_personal_grant_missing` | Settings → Connected accounts |
162
163
  | The person's GitHub | `github_personal_grant_missing` | Settings → Connected accounts |
163
164
 
165
+ With a person at the terminal, `catalyst onboard` starts each of these approvals itself and prints the exact link to approve.
166
+
164
167
  With `--runner yes`, setup starts the self-hosted runner and verifies enrollment, advertised capacity and team admission. This needs Docker with Compose, pinned host images and an organization key file (`CATALYST_RUNNER_ORG_KEY_FILE`). When images, a key or admission are missing, exit 11 names that gate. A failed setup step exits 10. Use `--runner no` to skip the machine's runner. Unlike the interactive optional runner step, an explicitly requested headless runner must finish before exit 0.
165
168
 
166
169
  With `--json`, stdout carries exactly one document: the onboarding receipt plus a `headless` block (`schema: "catalyst-onboard-headless/1"`). The block holds the resolved `inputs` (the key appears only as its source: `env`, `file` or `saved`), and lists of items under `missing`, `refused`, `failed`, `deferred` and `warnings`. Each item has `id`, `kind` (`input`, `grant` or `step`), `reason` and a one-line `text`, plus `flag`, `env` and `url` where they apply. Everything else goes to stderr.
@@ -183,7 +186,7 @@ Local sync is optional. Include it in the plan with `--local-sync` when you need
183
186
  ## Requirements
184
187
 
185
188
  - Node 22.15 or newer (Node 26 works), or bun 1.4 or newer. The bundle uses Node's built-in SQLite module (and, on bun, bun's own `node:sqlite`) for the optional local replica, so there is no native dependency to build; if `better-sqlite3` resolves on the machine it is used instead. `catalyst ready` names the exact reason when the runtime is too old, and `catalyst runtime install` installs a pinned Node under this CLI's own cache — without touching your machine's default Node — if you would rather not upgrade it.
186
- - Your personal key, minted by you at Settings → API keys. The key is the only account selector: you never type an account id. If your Linear identity is not matched yet, `login` says so; you match it yourself with `catalyst identity linear set` (an identity someone else already claims needs an admin at Settings → Members), and until then "what needs me" shows everyone's asks.
189
+ - Your own login: a keyless `catalyst login`, or for an unattended machine a personal key you mint at Settings → API keys. The credential is the only account selector: you never type an account id. If your Linear identity is not matched yet, `login` says so; you match it yourself with `catalyst identity linear set` (an identity someone else already claims needs an admin at Settings → Members), and until then "what needs me" shows everyone's asks.
187
190
  - An agent that discovers skills. Claude Code loads the plugin; Codex, Cursor, OpenCode and the rest read the `skills/<name>/SKILL.md` files the `npx skills` installer writes.
188
191
  - Bun is optional, only if you prefer `bunx` over `npx` — bun 1.4 or newer, which is when `node:sqlite` arrives; older bun cannot run this CLI at all.
189
192
 
@@ -233,7 +236,7 @@ Some of these only read, and some write: a comment, a card move, an ask, a relea
233
236
 
234
237
  ## What a key cannot see yet
235
238
 
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:
239
+ 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`, with no command yet). 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:
237
240
 
238
241
  - Read pull-request labels or the reviewer's reaction. The mirror does not carry them; GitHub's own page does.
239
242
  - 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.
@@ -277,7 +280,7 @@ npm uninstall -g @catalyst-cloud/cli
277
280
 
278
281
  ## If login fails
279
282
 
280
- - `catalyst: GET /me failed (401): credential not accepted — mint a personal key at Settings → API keys and log in again` — the key is stale, mistyped or revoked. Mint a new one, then run `login` again.
283
+ - `catalyst: GET /me failed (401): credential not accepted. Run catalyst login to sign in again; an unattended machine needs a new personal key from Settings → API keys` — the key is stale, mistyped or revoked. Run a keyless `catalyst login`, or on an unattended machine mint a new key, then run `login` again.
281
284
  - `catalyst: GET /me failed (403): account-not-operational` — your cloud account is suspended. This is a conversation with your account's admin, not a local fix.
282
285
  - `catalyst: could not reach <url>: <detail>` — the machine cannot reach the cloud. The URL is named in the message; check `CATALYST_CLOUD_BASE_URL` or `--base-url`.
283
286
  - A line naming two contract versions after `Connected to` — your account serves a contract outside this bundle's `1.x || 2.x` range. The config is written; update the bundle before using the other skills.
package/dist/args.js CHANGED
@@ -20,6 +20,7 @@ const GLOBAL_FLAGS = {
20
20
  };
21
21
  /** Per-verb flag tables. A verb absent here accepts only the global flags. */
22
22
  export const FLAG_TABLES = {
23
+ hosts: { yes: { value: false, help: "confirm removal without a terminal" } },
23
24
  login: {
24
25
  "start-replica": {
25
26
  value: false,
@@ -41,6 +42,10 @@ export const FLAG_TABLES = {
41
42
  },
42
43
  install: {},
43
44
  onboard: {
45
+ "machine-name": {
46
+ value: true,
47
+ help: "name this self-hosted machine (default: its hostname)",
48
+ },
44
49
  verbose: { value: false, help: "show granted permissions for connected steps in interactive setup" },
45
50
  team: {
46
51
  value: true,
@@ -280,6 +285,25 @@ export const FLAG_TABLES = {
280
285
  value: false,
281
286
  help: "raise: declare that nothing is blocked",
282
287
  },
288
+ "gates-pr": {
289
+ value: true,
290
+ repeat: true,
291
+ help: "raise: a PR whose hold the answer releases (repeatable; needs --released-by)",
292
+ },
293
+ "gates-label": {
294
+ value: true,
295
+ repeat: true,
296
+ help: "raise: the hold label the answer removes: hold or hold:preview (repeatable; default hold)",
297
+ },
298
+ "released-by": {
299
+ value: true,
300
+ repeat: true,
301
+ help: "raise: an option letter whose answer releases the hold (repeatable)",
302
+ },
303
+ "gates-repo": {
304
+ value: true,
305
+ help: "raise: the --gates-pr repository as owner/name (default: the account's only repository)",
306
+ },
283
307
  anyone: {
284
308
  value: false,
285
309
  help: "list: every open ask in the tenant, not only the ones assigned to you",
@@ -461,6 +485,7 @@ export const FLAG_TABLES = {
461
485
  },
462
486
  };
463
487
  export const VERB_USAGE = {
488
+ hosts: "hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name> [--json]",
464
489
  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)",
465
490
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
466
491
  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]",
@@ -479,7 +504,7 @@ export const VERB_USAGE = {
479
504
  queue: "queue [--team K] [--json]",
480
505
  watch: "watch [--team K] [--ticket T]... [--project P] [--exec CMD] [--cursor-file <path>] [--from cursor|head]",
481
506
  write: "write <comment <ticket> --body|--stdin [--parent] [--bookkeeping] [--as-user] | state <ticket> --slot|--state-id|--state-type | label <ticket> --add... --remove... | create --team --title [--description|--stdin] [--label] [--priority] | reaction <ticket>|--comment <id> --emoji <e> | attachment <ticket> --title --url | session <ticket> [--title] [--plan-file] [--activity]>",
482
- ask: "ask <raise --team --title [--context] [--option]... [--default] --blocks <ticket>...|--nothing-to-block [--ask-key] | accept <askTicket> --answer <commentId> --role <role> | list [--anyone] [--json]>",
507
+ ask: "ask <raise --team --title [--context] [--option]... [--default] --blocks <ticket>...|--nothing-to-block [--ask-key] [--gates-pr <n>... --released-by <letter>... [--gates-label hold|hold:preview]... [--gates-repo <owner/name>]] | accept <askTicket> --answer <commentId> --role <role> | list [--anyone] [--json]>",
483
508
  ready: "ready [--onboarding] [--local-sync] [--json] [--offline]",
484
509
  accounts: "accounts [--json]",
485
510
  mcp: "mcp <add <name> --url URL <--auth none|--bearer SECRET_NAME|--header NAME=SECRET_NAME...>|list|remove <name>> [--json]",
package/dist/ask.js CHANGED
@@ -39,6 +39,7 @@ async function raise(args, ctx, doc, api) {
39
39
  if (options.length > doc.askTemplate.maxLetteredOptions) {
40
40
  throw new UsageError(`ask raise takes at most ${doc.askTemplate.maxLetteredOptions} options`);
41
41
  }
42
+ const gates = askGates(args, options, doc);
42
43
  const blocks = [];
43
44
  for (const t of flagList(args, "blocks"))
44
45
  blocks.push((await resolveIssue(api, t)).id);
@@ -55,10 +56,65 @@ async function raise(args, ctx, doc, api) {
55
56
  body.nothingToBlock = true;
56
57
  if (flagString(args, "ask-key"))
57
58
  body.askKey = flagString(args, "ask-key");
59
+ if (gates)
60
+ body.gates = gates;
58
61
  const result = await postAgent(api, doc, "ask", body);
59
62
  ctx.stdout(args.json ? JSON.stringify(result) : `ask raised: ${String(result.identifier ?? result.id ?? "ok")}${blocks.length ? ` (blocks ${blocks.length})` : ""}`);
60
63
  return 0;
61
64
  }
65
+ /** The hold labels an answered ask may lift; the cloud's ask route refuses any other with a 400. */
66
+ const GATE_LABELS = ["hold", "hold:preview"];
67
+ const OWNER_NAME = /^[^/\s]+\/[^/\s]+$/;
68
+ /** CTC-5044 — the PR hold an answer releases. Absent unless --gates-pr is given, so an ask without it
69
+ * posts exactly the body it always did. Everything the route would refuse (a label outside
70
+ * {@link GATE_LABELS}, a letter that is not one of this ask's options) is refused here first, before
71
+ * any ticket is resolved or anything is posted. Options are lettered A, B, C in the order given. */
72
+ function askGates(args, options, doc) {
73
+ const prs = flagList(args, "gates-pr");
74
+ if (prs.length === 0) {
75
+ for (const f of ["released-by", "gates-label", "gates-repo"]) {
76
+ if (flagList(args, f).length || flagString(args, f))
77
+ throw new UsageError(`ask raise takes --${f} only with --gates-pr <n>`);
78
+ }
79
+ return undefined;
80
+ }
81
+ const pr = [...new Set(prs.map((n) => {
82
+ if (!/^[1-9]\d*$/.test(n))
83
+ throw new UsageError(`--gates-pr must be a PR number (got "${n}")`);
84
+ return Number(n);
85
+ }))];
86
+ const labels = [...new Set(flagList(args, "gates-label"))];
87
+ for (const l of labels) {
88
+ if (!GATE_LABELS.includes(l))
89
+ throw new UsageError(`--gates-label ${l} is not a hold an answer can release; use one of: ${GATE_LABELS.join(", ")}`);
90
+ }
91
+ const released = [...new Set(flagList(args, "released-by").map((l) => l.trim().toUpperCase()))];
92
+ if (released.length === 0)
93
+ throw new UsageError("--gates-pr needs --released-by <letter>: the option whose answer releases the hold");
94
+ const letters = options.map((_, i) => String.fromCharCode(65 + i));
95
+ for (const l of released) {
96
+ if (!letters.includes(l))
97
+ throw new UsageError(`--released-by ${l} is not one of this ask's options (${letters.length ? letters.join(", ") : "it has none; add --option"})`);
98
+ }
99
+ const registered = doc.merge.repositories.map((r) => `${r.owner}/${r.name}`);
100
+ const named = flagString(args, "gates-repo");
101
+ let repo;
102
+ if (named !== undefined) {
103
+ if (!OWNER_NAME.test(named))
104
+ throw new UsageError(`--gates-repo must be owner/name (got "${named}")`);
105
+ const match = registered.find((r) => r.toLowerCase() === named.toLowerCase());
106
+ if (registered.length && !match)
107
+ throw new UsageError(`--gates-repo ${named} is not a repository this account registers (${registered.join(", ")})`);
108
+ repo = match ?? named;
109
+ }
110
+ else if (registered.length === 1) {
111
+ repo = registered[0];
112
+ }
113
+ else {
114
+ throw new UsageError(`--gates-pr needs --gates-repo <owner/name>: this account registers ${registered.length ? registered.join(", ") : "no repository"}`);
115
+ }
116
+ return { repo, pr: pr.length === 1 ? pr[0] : pr, labels: labels.length ? labels : ["hold"], releasedBy: released };
117
+ }
62
118
  async function accept(args, ctx, doc, api, rest) {
63
119
  const askTicket = rest[0];
64
120
  const answer = flagString(args, "answer");
@@ -5,9 +5,11 @@ const agent = (method, name) => ({ method, path: `/api/v1/agent/${name}` });
5
5
  * the person, the workspace, the project, the repository, then work. A new verb is a new row here
6
6
  * in the same change, or `catalyst capabilities` lies to every guide that reads it. */
7
7
  export const CAPABILITIES = [
8
+ { verb: "hosts", does: "list this account's machines, current use and live capacity", needs: "member", routes: [], since: "0.16.0" },
9
+ { verb: "hosts remove|rename", does: "remove or rename a self-hosted machine in this account", needs: "admin", routes: [], since: "0.16.0" },
8
10
  // CTC-4680: plain `catalyst setup` runs the onboard flow, so install.sh may hand off to it. install.sh
9
11
  // reads this flag rather than `setup --help`, which already exits 0 on 0.15.x.
10
- { verb: "setup", does: "set up Catalyst step by step, or show install.sh's plan, progress and sign-in in this terminal", needs: "member", routes: [], since: "0.15.0", engineProtocol: 1, bootstrapHandoff: true },
12
+ { verb: "setup", does: "set up Catalyst step by step, or show install.sh's plan, progress and sign-in in this terminal", needs: "member", routes: [], since: "0.15.0", engineProtocol: 1, bootstrapHandoff: true, bootstrapHandoffVersion: 2 },
11
13
  { verb: "onboard", does: "run and resume Catalyst setup with a saved step record", needs: "member", routes: [], since: "0.14.0", bootstrapHandoff: true, headless: true },
12
14
  { verb: "status", does: "say whether this machine is connected, as whom, and to which cloud", needs: "member", routes: [], since: "0.1.0" },
13
15
  { verb: "me", does: "read the person's seat, role and Linear identity", needs: "member", routes: [], since: "0.1.0" },
package/dist/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { cmdHosts } from "./hosts.js";
1
2
  // cli.ts — the `catalyst` dispatcher (npm: @catalyst-cloud/cli). Verbs live in
2
3
  // their own modules; this file keeps every export the 0.1 tests and the bin import.
3
4
  import { createSetupRenderer, setupBanner, } from "./setup-render.js";
@@ -79,6 +80,7 @@ export function usageText() {
79
80
  " catalyst onboard [--resume-from <step>] [--only <step>] [--runner|--no-runner] [--yes] [--dry-run] [--json]",
80
81
  ` catalyst ${SETUP_USAGE}`,
81
82
  " catalyst status | notice | me | ready | accounts",
83
+ " catalyst hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name>",
82
84
  " catalyst mcp add|list|remove (vault references only)",
83
85
  " catalyst contract [--refresh] [--path <a.b.c>]",
84
86
  " catalyst query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>>",
@@ -372,11 +374,11 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
372
374
  ...(deps.setupApproved
373
375
  ? { reviewedSetup: true, confirm: async () => true }
374
376
  : {}),
375
- beforeStep: async () => {
376
- if (args.flags["dry-run"] !== true)
377
- await refreshSessionIfShort(ctx, ONBOARD_STEP_MIN_MS);
378
- },
379
377
  ...createOnboardRuntime(args, ctx, {
378
+ beforeStep: async () => {
379
+ if (args.flags["dry-run"] !== true)
380
+ await refreshSessionIfShort(ctx, ONBOARD_STEP_MIN_MS);
381
+ },
380
382
  ui,
381
383
  signinTimeoutMs: deps.onboardSigninTimeoutMs,
382
384
  stageSignin: (signal) => {
@@ -511,6 +513,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
511
513
  return await cmdLegacy(args, ctx, deps.legacy ?? {});
512
514
  case "identity":
513
515
  return await cmdIdentity(args, ctx, deps.identity ?? {});
516
+ case "hosts":
517
+ return await cmdHosts(args, ctx, deps.hosts);
514
518
  case "connections":
515
519
  return await cmdConnections(args, ctx, deps.connections ?? {});
516
520
  default:
@@ -620,6 +624,7 @@ async function runSetup(argv, ctx, deps) {
620
624
  ...(flags.json ? ["--json"] : []),
621
625
  ...(flags.verbose ? ["--verbose"] : []),
622
626
  ...(flags.localSync ? ["--local-sync"] : []),
627
+ ...(flags.runner === undefined ? [] : [flags.runner ? "--runner" : "--no-runner"]),
623
628
  ...(flags.yes || flags.json || !terminal ? ["--yes"] : []),
624
629
  ];
625
630
  let ui;
@@ -726,7 +731,7 @@ async function cmdLogin(args, ctx, deps, personalOnly = false) {
726
731
  : ' — your Linear identity is not matched yet, so "what needs me" will show everyone\'s asks until an admin matches it in Settings'}`);
727
732
  }
728
733
  else {
729
- ctx.stderr(`[catalyst] this is your Catalyst workspace's shared key (a host credential), not your own. The skills work, but nothing your agent writes will carry your name, and "what needs me" cannot mean you. Make a personal key at Settings → API keys and sign in with that.`);
734
+ ctx.stderr(`[catalyst] this is your Catalyst workspace's shared key (a host credential), not your own. The skills work, but nothing your agent writes will carry your name, and "what needs me" cannot mean you. Run catalyst login to sign in as yourself; for an unattended machine, make a personal key at Settings → API keys and sign in with that.`);
730
735
  }
731
736
  ctx.stdout(`Config written to ${written.path} (mode ${formatMode(written.mode)}, holds your ${auth ? "login session" : "key"} and the CLI path)${written.mode === CONFIG_MODE
732
737
  ? ""
@@ -79,7 +79,7 @@ export function renderRead(r) {
79
79
  // never a refusal: the secret may be created next, and a repo may supply the name at checkout.
80
80
  const unresolved = r.unresolvedReferences ?? [];
81
81
  if (unresolved.length > 0) {
82
- lines.push(`referenced but not set on this tenant yet: ${unresolved.join(", ")} — add them in the app, or a phase that needs one will fail on it`);
82
+ lines.push(`referenced but not set in this workspace yet: ${unresolved.join(", ")}. Set them with catalyst var set or catalyst secret set, or a phase that needs one will fail on it`);
83
83
  }
84
84
  return lines;
85
85
  }
package/dist/execution.js CHANGED
@@ -51,7 +51,7 @@ export const EXCLUSION_REASONS = {
51
51
  export const UNKNOWN_REASONS = {
52
52
  ordering_never_published: "the dispatch order has never been published for this team — usually because the team has no saved stage mapping, which does not clear by itself",
53
53
  ordering_stale: "the dispatch order is stale",
54
- workflow_mapping_unknown: "the team has no saved stage mapping for dispatch, pr, done and canceled (or a mapped stage was deleted); it does not clear by itself — a tenant owner or admin maps the team in Settings → Linear teams",
54
+ workflow_mapping_unknown: "the team has no saved stage mapping for dispatch, pr, done and canceled (or a mapped stage was deleted); it does not clear by itself; a workspace owner or admin runs catalyst team map <KEY>, or catalyst team adopt <KEY> to create Catalyst's stages",
55
55
  ticket_unknown: "the ticket is not in the mirror",
56
56
  dependency_snapshot_unknown: "the dependency snapshot has never completed",
57
57
  blocker_unknown: "a blocking relation could not be resolved",
@@ -373,7 +373,7 @@ export async function cmdAccounts(args, ctx) {
373
373
  return 0;
374
374
  }
375
375
  if (accounts.length === 0) {
376
- ctx.stdout(`No coding accounts are enrolled on this tenant — enrol one at ${normalizeBaseUrl(cfg.baseUrl)}/settings/coding-accounts`);
376
+ ctx.stdout(`No coding accounts are enrolled in this workspace. A workspace owner or admin adds one at ${normalizeBaseUrl(cfg.baseUrl)}/settings/coding-accounts; there is no command for that yet.`);
377
377
  return 0;
378
378
  }
379
379
  for (const a of accounts)
package/dist/hosts.js ADDED
@@ -0,0 +1,89 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { positionals } from "./args.js";
3
+ import { requireConfig } from "./config.js";
4
+ import { CliError, UsageError } from "./errors.js";
5
+ import { apiClient } from "./transport.js";
6
+ import { isMachineInventory, isMachineMutation, } from "./machine-inventory.js";
7
+ import { stdinIsTty } from "./prompt.js";
8
+ async function confirmRemove(name) {
9
+ if (!stdinIsTty())
10
+ return false;
11
+ const prompt = createInterface({
12
+ input: process.stdin,
13
+ output: process.stderr,
14
+ });
15
+ try {
16
+ return /^(y|yes)$/i.test((await prompt.question(`Remove ${name} from this account? It will stop receiving work. [y/N] `)).trim());
17
+ }
18
+ finally {
19
+ prompt.close();
20
+ }
21
+ }
22
+ export function machineLines(inventory) {
23
+ const lines = inventory.machines.map((m) => `${m.name} | ${m.ownership === "self_hosted" ? "Self-hosted" : "Catalyst's"} | ${m.teams.join(", ")} | ${m.slots} slots, ${m.inUse} in use, ${m.free} free | ${m.status} | last check-in ${m.lastCheckInAtMs === null ? "never" : new Date(m.lastCheckInAtMs).toISOString()}`);
24
+ if (lines.length === 0)
25
+ lines.push("No machines are enrolled for this account.");
26
+ const c = inventory.capacity;
27
+ lines.push(`Live capacity: ${c.slots} slots (${c.selfHostedSlots} self-hosted, ${c.catalystSlots} from Catalyst), ${c.inUse} in use, ${c.free} free. Stale machines are excluded.`);
28
+ return lines;
29
+ }
30
+ export async function cmdHosts(args, ctx, deps = {}) {
31
+ const parts = positionals(args);
32
+ const action = parts[0] ?? "list";
33
+ if (!((action === "list" && parts.length <= 1) ||
34
+ (action === "remove" && parts.length === 2) ||
35
+ (action === "rename" && parts.length === 3)))
36
+ throw new UsageError("hosts takes: [list] | remove <name-or-id> [--yes] | rename <name-or-id> <new-name>");
37
+ if (args.flags.yes === true && action !== "remove")
38
+ throw new UsageError("--yes is only valid with hosts remove");
39
+ if (action === "rename" &&
40
+ !/^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/.test(parts[2]))
41
+ throw new UsageError("Use 1 to 63 letters, digits, dots, underscores or hyphens, starting with a letter or digit.");
42
+ const cfg = requireConfig(ctx);
43
+ const client = apiClient(cfg, ctx);
44
+ const listed = await client.getJson("/api/v1/hosts/machines", {
45
+ query: { account: cfg.account },
46
+ });
47
+ if (!isMachineInventory(listed.body) || listed.body.account !== cfg.account)
48
+ throw new CliError("The cloud returned an unreadable machine list.", "shape");
49
+ if (action === "list") {
50
+ if (args.json)
51
+ ctx.stdout(JSON.stringify(listed.body));
52
+ else
53
+ for (const line of machineLines(listed.body))
54
+ ctx.stdout(line);
55
+ return 0;
56
+ }
57
+ const matches = listed.body.machines.filter((m) => m.id === parts[1] || m.name === parts[1]);
58
+ if (matches.length !== 1)
59
+ throw new CliError(matches.length === 0
60
+ ? "No machine matches that name or ID."
61
+ : "More than one machine has that name. Use its ID.", "machine_not_unique");
62
+ const machine = matches[0];
63
+ if (!(action === "remove" ? machine.removable : machine.renameable))
64
+ throw new CliError("Catalyst manages this cloud machine. Only self-hosted machines can be changed here.", "managed_machine");
65
+ if (action === "remove" &&
66
+ args.flags.yes !== true &&
67
+ !(await (deps.confirm ?? confirmRemove)(machine.name))) {
68
+ ctx.stdout(args.json
69
+ ? JSON.stringify({ removed: false, reason: "confirmation_required" })
70
+ : "Machine kept. Use --yes to confirm removal without a terminal.");
71
+ return 2;
72
+ }
73
+ const path = `/api/v1/hosts/${encodeURIComponent(machine.id)}${action === "rename" ? "/name" : ""}?account=${encodeURIComponent(cfg.account)}`;
74
+ const result = action === "remove"
75
+ ? await client.deleteJson(path, { accept: [403] })
76
+ : await client.postJson(path, { name: parts[2] }, { accept: [403] });
77
+ if (result.status === 403)
78
+ throw new CliError("Admin access is required to change machines in this account.", "admin_required", 2, 403);
79
+ if (!isMachineMutation(result.body) ||
80
+ result.body.hostId !== machine.id ||
81
+ (action === "rename" && result.body.name !== parts[2]))
82
+ throw new CliError("The cloud did not confirm the machine change.", "shape");
83
+ ctx.stdout(args.json
84
+ ? JSON.stringify(result.body)
85
+ : action === "remove"
86
+ ? `Removed ${machine.name}.`
87
+ : `Renamed ${machine.name} to ${result.body.name}.`);
88
+ return 0;
89
+ }
package/dist/legacy.js CHANGED
@@ -189,8 +189,18 @@ function remove(item, run, platform, uid) {
189
189
  const why = platform === "darwin"
190
190
  ? tool("launchctl", ["bootout", `gui/${uid}/${item.name}`], "launchctl bootout")
191
191
  : tool("systemctl", ["--user", "disable", "--now", item.name], "systemctl --user disable --now");
192
- if (why !== null)
193
- return why;
192
+ if (why !== null) {
193
+ if (platform !== "darwin")
194
+ return why;
195
+ // A nonzero bootout may mean the exact owned service was already unloaded.
196
+ // Prove the scheduler domain works before interpreting its absence response.
197
+ const domain = run("launchctl", ["print", `gui/${uid}`]);
198
+ const service = run("launchctl", ["print", `gui/${uid}/${item.name}`]);
199
+ if (domain.status !== 0 ||
200
+ service.status !== 113 ||
201
+ !/could not find service|could not find specified service/i.test(service.stderr || service.stdout))
202
+ return `${why}. Check launchctl print gui/${uid}/${item.name}, fix the reported scheduler error, then run catalyst legacy --remove.`;
203
+ }
194
204
  unlinkSync(item.path);
195
205
  return null;
196
206
  }
@@ -0,0 +1,37 @@
1
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2
+ const count = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
3
+ function machine(value) {
4
+ return (object(value) &&
5
+ typeof value.id === "string" &&
6
+ typeof value.name === "string" &&
7
+ (value.ownership === "self_hosted" || value.ownership === "catalyst") &&
8
+ Array.isArray(value.teams) &&
9
+ value.teams.every((team) => typeof team === "string") &&
10
+ count(value.slots) &&
11
+ count(value.inUse) &&
12
+ count(value.free) &&
13
+ (value.lastCheckInAtMs === null || count(value.lastCheckInAtMs)) &&
14
+ (value.status === "live" || value.status === "stale") &&
15
+ typeof value.removable === "boolean" &&
16
+ typeof value.renameable === "boolean");
17
+ }
18
+ export function isMachineInventory(value) {
19
+ if (!object(value) ||
20
+ typeof value.account !== "string" ||
21
+ !count(value.readAtMs) ||
22
+ !Array.isArray(value.machines) ||
23
+ !value.machines.every(machine) ||
24
+ !object(value.capacity))
25
+ return false;
26
+ return (count(value.capacity.slots) &&
27
+ count(value.capacity.inUse) &&
28
+ count(value.capacity.free) &&
29
+ count(value.capacity.selfHostedSlots) &&
30
+ count(value.capacity.catalystSlots));
31
+ }
32
+ export function isMachineMutation(value) {
33
+ return (object(value) &&
34
+ value.ok === true &&
35
+ typeof value.hostId === "string" &&
36
+ (value.name === undefined || typeof value.name === "string"));
37
+ }
@@ -163,7 +163,7 @@ export function onboardCapacityAdapter(input = {}) {
163
163
  const expected = binding(ctx, journal), selected = selection(journal), teamId = selectedOnboardTeam(journal), repos = selectedOnboardRepositories(journal);
164
164
  if (!expected)
165
165
  return waiting("capacity_identity_unverified");
166
- if (!teamId || repos.length !== 1 || repos[0].teamId !== teamId)
166
+ if (!teamId || repos.length === 0 || repos.some(r => r.teamId !== teamId))
167
167
  return waiting("capacity_context_unverified");
168
168
  const controller = new AbortController(), signal = external
169
169
  ? AbortSignal.any([external, controller.signal])
@@ -191,10 +191,9 @@ export function onboardCapacityAdapter(input = {}) {
191
191
  const inventory = await readOnboardRepositoryInventory(ctx, journal, signal);
192
192
  if ("reason" in inventory ||
193
193
  !current() ||
194
- !inventory.repositories.some((r) => r.repoId === repos[0].repoId &&
195
- r.teamId === teamId &&
196
- r.owner.toLowerCase() === repos[0].owner.toLowerCase() &&
197
- r.name.toLowerCase() === repos[0].name.toLowerCase()))
194
+ !repos.every(selectedRepo => inventory.repositories.some(r => r.repoId === selectedRepo.repoId && r.teamId === teamId &&
195
+ r.owner.toLowerCase() === selectedRepo.owner.toLowerCase() &&
196
+ r.name.toLowerCase() === selectedRepo.name.toLowerCase())))
198
197
  return pause("capacity_context_unverified");
199
198
  const read = await readExistingOnboardJson(ctx, route, signal);
200
199
  if ("reason" in read || !current())
@@ -202,7 +201,7 @@ export function onboardCapacityAdapter(input = {}) {
202
201
  if (!(await liveIdentity(ctx, journal, signal)) || !current())
203
202
  return pause("capacity_identity_unverified");
204
203
  const observed = snapshot(read.body, ctx.now().getTime());
205
- const bucket = observed?.buckets.find((b) => b.repo === repos[0].repoId), mapped = bucket?.teams.find((t) => t.key === teamKey);
204
+ const bucket = observed?.buckets.find(b => b.teams.some(t => t.key === teamKey) && repos.some(r => r.repoId === b.repo)), mapped = bucket?.teams.find((t) => t.key === teamKey);
206
205
  if (!observed || !bucket || !mapped)
207
206
  return pause("capacity_context_unverified");
208
207
  if (!mapped.admission || mapped.remaining === null)
@@ -0,0 +1,2 @@
1
+ /** GitHub can require sudo confirmation before an install or permission change. */
2
+ export const GITHUB_CONFIRM_ACCESS = "GitHub may show Confirm access. Use the two-factor code for the GitHub account you are signed in to.";
@@ -1,4 +1,5 @@
1
1
  import { finishOnTheWeb } from "./consent-browser.js";
2
+ import { GITHUB_CONFIRM_ACCESS } from "./onboard-github-copy.js";
2
3
  import { loadConfig, normalizeBaseUrl } from "./config.js";
3
4
  import { pollConsent } from "./onboard-consent.js";
4
5
  import { GITHUB_PERMISSION, githubInstallationPage, parsePermissions, } from "./onboard-permissions.js";
@@ -468,6 +469,7 @@ export function githubInstallationAdapter(options) {
468
469
  reason: "github_installation_admin_required",
469
470
  };
470
471
  // The signed URL is transient credential material: only the browser receives it.
472
+ ctx.stderr(GITHUB_CONFIRM_ACCESS);
471
473
  let browserUnavailable = false;
472
474
  try {
473
475
  await options.openBrowser(url, signal);
@@ -29,8 +29,8 @@ const pages = (baseUrl) => {
29
29
  keys: `${web}/settings/api-keys`,
30
30
  connections: `${web}/a/account/connections`,
31
31
  personal: `${web}/settings/connected-accounts`,
32
- teams: `${web}/settings/linear-teams`,
33
- repos: `${web}/settings/repositories`,
32
+ teams: `${web}/settings/projects`,
33
+ repos: `${web}/settings/projects`,
34
34
  codingAccounts: `${web}/settings/coding-accounts`,
35
35
  };
36
36
  };