@catalyst-cloud/cli 0.8.0 → 0.9.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ Onboarding now guides your own Linear and GitHub consent. `catalyst connections personal <linear|github> start` opens the provider's consent page in your browser and `status` confirms the grant landed; the tenant's own Linear connection and GitHub App install stay separate, and personal GitHub waits until the App is installed and a repository is registered.
6
+
7
+ An unmatched Linear identity can be fixed from the terminal: `catalyst identity linear status`, `options` and `set <linearUserId>` choose your Linear user from the roster and read the choice back. An existing match is never replaced.
8
+
9
+ The local replica and event cache report whether they are current. `catalyst replica status --probe` and `catalyst events status --probe` compare each local feed with its cloud head and require a live writer; onboarding starts the writer only when you opt in, and a missing local cache never blocks setup.
10
+
11
+ Register, list and remove MCP servers with `catalyst mcp`, using vault-secret names for authentication. Custom HTTPS servers wait for account-admin approval; query strings, userinfo and fragments are refused. The cloud side shipped on 2026-09-28; registration does not yet make a server callable through the portal.
12
+
13
+ This release needs `@catalyst-cloud/sdk` 0.12. `@catalyst-cloud/catalyst-skills` is deprecated in favour of `@catalyst-cloud/cli`; it still installs and forwards to this version.
14
+
3
15
  ## 0.8.0
4
16
 
5
17
  `catalyst-skills ready` loads the SDK on Node 26, the current Homebrew default, and has since 0.7.0: the type-stripping loader tries Node's `strip` mode before `transform`, because Node 26 accepts only `strip`. The 0.7.0 entry did not say so. If you saw `sdk: could not load` on Node 26 on 0.5.0 or earlier, updating is the fix.
package/README.md CHANGED
@@ -84,7 +84,19 @@ CATALYST_CLOUD_TOKEN=<your-personal-key> catalyst-skills login
84
84
 
85
85
  `--key <your-personal-key>` is the third form, for a script.
86
86
 
87
- That is the whole setup. Everything below explains what you just installed.
87
+ After a tenant owner connects the tenant's Linear workspace, connect your personal Linear account. Connect your personal GitHub account only after the tenant's GitHub App is installed and its repository is registered. These personal grants are separate from the tenant's Linear connection and GitHub App installation. Your agent can start each connection and check whether it landed, but you approve each one in your browser:
88
+
89
+ ```sh
90
+ catalyst-skills connections personal linear start
91
+ catalyst-skills connections personal linear status
92
+ # First install the tenant's GitHub App and register its repository in Settings.
93
+ catalyst-skills connections personal github start
94
+ catalyst-skills connections personal github status
95
+ ```
96
+
97
+ Each `start` prints a short-lived URL and attempts to open your browser. On a remote machine, open the printed URL on your own device. `status --json` gives the same result to an agent; `start --wait 60` waits up to one minute for approval. A connected result is the proof that the grant landed. If a provider reports unavailable, retry status later rather than starting a second approval.
98
+
99
+ The `catalyst-onboard` skill checks both grants before taking you through the rest of tenant setup and the first ticket.
88
100
 
89
101
  ## What this is
90
102
 
@@ -128,7 +140,7 @@ Nothing, by default. After `login`, every read, write, ask and explanation goes
128
140
 
129
141
  The check every skill runs first is `catalyst-skills replica status`, which needs no network: is the pidfile's process alive, is the writer-lock heartbeat younger than the staleness threshold, and is the cursor non-empty. It exits `0` for fresh, `1` for present but stale, `2` for not connected, `3` for absent, and prints one line either way (`--json` for scripts). A fresh replica is used; anything else falls back to the API and the skill says so in its answer. A skill never refuses to work because the replica is down and never silently reads a stale one. `replica status --probe` compares the local cursor against the cloud's head for the honest "how far behind" number; that is the only form that touches the network. `catalyst-skills replica stop` stops a detached writer.
130
142
 
131
- `catalyst-skills events tail` follows new cached events, `events wait-for --type ... --ticket ... --timeout ...` performs a bounded wait, and `events query` reads retained history. These commands never write the cache or contact the cloud. The replica process is the single writer and reports an event-sync failure without terminating a healthy entity replica. The event cache keeps closed daily segments for at most seven days or 256 MiB per tenant and reports an explicit gap when a requested sequence has retired.
143
+ `catalyst-skills events status --probe` checks the event cache's own cursor and compares it with the cloud head; `replica status --probe` checks the separate entity-replica cursor. A fresh replica does not prove event freshness. `events tail` follows new cached events, `events wait-for --ticket ... --timeout ...` performs a bounded wait, and `events query` reads retained history. Those three commands only read the cache; `events status --probe` makes the cloud comparison. The replica process is the single writer and reports an event-sync failure without terminating a healthy entity replica. The event cache keeps closed daily segments for at most seven days or 256 MiB per tenant and reports an explicit gap when a requested sequence has retired.
132
144
 
133
145
  The replica is a Node process, not a service: the supported path is the plain command, and `skills/connect-me/references/keeping-the-replica-running.md` gives launchd and systemd examples for people who want the writer to survive a reboot.
134
146
 
@@ -136,7 +148,7 @@ The replica is a Node process, not a service: the supported path is the plain co
136
148
 
137
149
  | skill | what the person says | what it does | file |
138
150
  | --- | --- | --- | --- |
139
- | `catalyst-onboard` | "Set me up. Onboard me. I just signed up — what do I do first?" | Walks you from nothing to your first ticket running, one step at a time: connect this machine, connect Linear, map one project, register one repository, then watch a card move. Reads each part of setup with the instrument that owns it and says who can fix anything unfinished and where. Hands over the steps only a browser can do instead of pretending to have done them. | [`SKILL.md`](skills/catalyst-onboard/SKILL.md) |
151
+ | `catalyst-onboard` | "Set me up. Onboard me. I just signed up — what do I do first?" | Walks you from nothing to your first ticket running, one step at a time: connect this machine, connect the tenant and your personal provider accounts, map one project, register one repository, then watch a card move. Reads each part of setup with the instrument that owns it and says who can fix anything unfinished and where. Hands over browser consent and settings steps without claiming they happened until a status check confirms them. | [`SKILL.md`](skills/catalyst-onboard/SKILL.md) |
140
152
  | `whats-happening` | "What's happening? Where are we? Why is that stuck? What's next?" | The desk for a tenant: reads the contract, what is running and queued, the eligibility explainer and the open asks, and answers in one reply with ticket ids. Routes work to a project owner and decisions to `what-needs-me`. | [`SKILL.md`](skills/whats-happening/SKILL.md) |
141
153
  | `what-needs-me` | "What needs me? What am I blocking?" | The human's decision inbox, ranked by what each answer releases, and the one way an agent raises a decision on their behalf: files an ask through the cloud's ask route with the tenant's own template and records the answer so the held work releases. | [`SKILL.md`](skills/what-needs-me/SKILL.md) |
142
154
  | `run-this-project` | "Run this project for me. Own it until it closes." | Single-threaded owner of one project: subscribes to the tenant stream for its scope, reacts to each change in the same turn, makes tickets ready and moves them to dispatch, parks what should stop, chases stalls, escalates inward, and keeps one status summary current. Never polls. | [`SKILL.md`](skills/run-this-project/SKILL.md) |
@@ -203,3 +215,15 @@ npm uninstall -g @catalyst-cloud/catalyst-skills
203
215
  ## License
204
216
 
205
217
  MIT — see [LICENSE](LICENSE). How to contribute and how releases happen are described in [CONTRIBUTING.md](CONTRIBUTING.md). The install commands above are one canonical block kept in [`.agents/install-block.md`](.agents/install-block.md); change them there first.
218
+
219
+ ### Unmatched Linear identity
220
+
221
+ Personal Linear consent normally binds the provider viewer automatically. For an unmatched identity, inspect your choices and explicitly select yourself:
222
+
223
+ ```sh
224
+ catalyst-skills identity linear status
225
+ catalyst-skills identity linear options --json
226
+ catalyst-skills identity linear set <linearUserId>
227
+ ```
228
+
229
+ The command uses your personal credential and reads the result back after selection. It cannot change another member, replace an automatic match, or take an already-claimed identity. A missing options field means no choice was offered, which can include a temporarily unreadable roster. This command depends on the pending SDK 0.12.0 release with identity support.
package/dist/args.js CHANGED
@@ -14,6 +14,12 @@ export const FLAG_TABLES = {
14
14
  login: {
15
15
  "start-replica": { value: false, help: "run `replica start --detach` after connecting" },
16
16
  },
17
+ mcp: {
18
+ url: { value: true, help: "add: the upstream HTTPS endpoint" },
19
+ auth: { value: true, help: "add: none for an unauthenticated upstream" },
20
+ bearer: { value: true, help: "add: vault secret NAME for a bearer token, never its value" },
21
+ header: { value: true, repeat: true, help: "add: HEADER_NAME=VAULT_SECRET_NAME (repeatable)" },
22
+ },
17
23
  install: {},
18
24
  status: {},
19
25
  notice: {},
@@ -39,6 +45,7 @@ export const FLAG_TABLES = {
39
45
  "stale-ms": { value: true, help: "status: heartbeat age that counts as stale (default 15000)" },
40
46
  },
41
47
  events: {
48
+ probe: { value: false, help: "status: compare the local event cursor with the cloud event head" },
42
49
  type: { value: true, help: "exact event type" },
43
50
  ticket: { value: true, help: "ticket identifier found in the event payload" },
44
51
  after: { value: true, help: "event sequence to read after (tail/wait default to local head)" },
@@ -116,6 +123,10 @@ export const FLAG_TABLES = {
116
123
  command: { value: true, help: "set: run this command on this machine and store its output (e.g. 'op read op://Vault/item/field'); the command text is audited, so never put a value in it" },
117
124
  rotate: { value: true, repeat: true, help: "import: replace this name if it is already set (repeatable)" },
118
125
  },
126
+ identity: {},
127
+ connections: {
128
+ wait: { value: true, help: "start: wait up to this many seconds for browser approval (0-600)" },
129
+ },
119
130
  release: {
120
131
  because: { value: true, help: "what changed since the ticket was held (required unless --dry-run)" },
121
132
  "retry-unchanged": { value: false, help: "release even though nothing the mirror can see changed (say what did in --because)" },
@@ -135,7 +146,7 @@ export const VERB_USAGE = {
135
146
  query: "query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>> [--team K] [--project P] [--state S] [--limit N] [--all] [--source replica|api] [--json]",
136
147
  replica: "replica <start [--detach]|stop|status [--probe] [--json]|sql \"<select>\"|schema [table]> [--db <path>]",
137
148
  runtime: "runtime <status [--json]|install|path|uninstall>",
138
- events: "events <tail|wait-for|query> [--type NAME] [--ticket CTC-N] [--after SEQUENCE] [--limit N] [--timeout SECONDS] [--directory PATH]",
149
+ events: "events <tail|wait-for|query|status [--probe] [--json]> [--type NAME] [--ticket CTC-N] [--after SEQUENCE] [--limit N] [--timeout SECONDS] [--directory PATH]",
139
150
  explain: "explain <ticket> [--history] [--json]",
140
151
  history: "history <ticket> [--json]",
141
152
  running: "running [--ticket T --phase P] [--json]",
@@ -145,8 +156,11 @@ export const VERB_USAGE = {
145
156
  ask: "ask <raise --team --title [--context] [--option]... [--default] --blocks <ticket>...|--nothing-to-block [--ask-key] | accept <askTicket> --answer <commentId> --role <role> | list [--anyone] [--json]>",
146
157
  ready: "ready [--json] [--offline]",
147
158
  accounts: "accounts [--json]",
159
+ mcp: "mcp <add <name> --url URL <--auth none|--bearer SECRET_NAME|--header NAME=SECRET_NAME...>|list|remove <name>> [--json]",
148
160
  environment: "environment [read] [--json] | environment propose --file <path>|--stdin [--expect-revision N] [--approve] [--json] | environment approve [--revision N --hash H] [--json]",
149
161
  secret: "secret set <NAME> --repo <owner/name> [--command '<cmd>'] [--json] (value from --command, stdin, or a hidden prompt) | secret import <file> --repo <owner/name> [--rotate NAME]... [--json]",
162
+ identity: "identity linear <status|options|set> [<linearUserId>] [--json]",
163
+ connections: "connections personal <linear|github> <start|status> [--wait <seconds>] [--json]",
150
164
  release: "release <ticket> --because <what changed> [--retry-unchanged] [--dry-run] [--json] | release --class <failure-class> --team <K> --because <what changed> [--retry-unchanged] [--dry-run] [--limit N] [--json]",
151
165
  };
152
166
  export function parseArgs(argv) {
package/dist/cli.js CHANGED
@@ -20,9 +20,12 @@ import { installedBundleVersion, installSkills, parseChangelogEntry, readChangel
20
20
  import { cmdWatch } from "./watch.js";
21
21
  import { cmdWrite } from "./write.js";
22
22
  import { cmdAsk } from "./ask.js";
23
+ import { cmdMcp } from "./mcp.js";
23
24
  import { cmdRelease } from "./release.js";
24
25
  import { cmdEnvironment } from "./environment.js";
25
26
  import { cmdSecret } from "./secret.js";
27
+ import { cmdIdentity } from "./identity.js";
28
+ import { cmdConnections } from "./connections.js";
26
29
  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, };
27
30
  export const CUSTOMER_SKILLS = [
28
31
  "catalyst-github",
@@ -51,11 +54,12 @@ export function usageText() {
51
54
  " catalyst-skills join ... (deprecated alias of login; removed in the next minor version)",
52
55
  " catalyst-skills install [--skills-dir <dir>] [--force] (repair path; your agent's own command installs the skills)",
53
56
  " catalyst-skills status | notice | me | ready | accounts",
57
+ " catalyst-skills mcp add|list|remove (vault references only)",
54
58
  " catalyst-skills contract [--refresh] [--path <a.b.c>]",
55
59
  " catalyst-skills query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>>",
56
60
  " catalyst-skills replica <start [--detach]|stop|status [--probe]|sql \"<select>\"|schema [table]>",
57
61
  " catalyst-skills runtime <status [--json]|install|path|uninstall> (a pinned Node this CLI manages itself)",
58
- " catalyst-skills events <tail|wait-for|query> [--type NAME] [--ticket CTC-N] [--after SEQUENCE]",
62
+ " catalyst-skills events <tail|wait-for|query|status [--probe]> [--type NAME] [--ticket CTC-N] [--after SEQUENCE]",
59
63
  " catalyst-skills explain <ticket> | history <ticket> | running [--ticket T --phase P] | queue [--team K]",
60
64
  " catalyst-skills watch [--team K] [--ticket T]... [--project P] [--exec CMD]",
61
65
  " catalyst-skills write <comment|state|label|create|reaction|attachment|session> ...",
@@ -63,6 +67,8 @@ export function usageText() {
63
67
  " catalyst-skills release <ticket> --because <what changed> [--retry-unchanged] [--dry-run] | release --class <c> --team <K> ...",
64
68
  " catalyst-skills environment [read] | environment propose --file <path>|--stdin [--approve] | environment approve",
65
69
  " catalyst-skills secret set <NAME> --repo <owner/name> [--command '<cmd>'] | secret import <file> --repo <owner/name>",
70
+ " catalyst-skills identity linear <status|options|set> [<linearUserId>] [--json]",
71
+ " catalyst-skills connections personal <linear|github> <start|status> [--wait <seconds>] [--json]",
66
72
  "",
67
73
  "Every verb takes --help. --json makes the output machine-readable.",
68
74
  "",
@@ -211,6 +217,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
211
217
  return await cmdAsk(args, ctx);
212
218
  case "ready":
213
219
  return await cmdReady(args, ctx, { skillNames: CUSTOMER_SKILLS, loadSdk: deps.loadSdk, offline: args.flags.offline === true });
220
+ case "mcp":
221
+ return await cmdMcp(args, ctx);
214
222
  case "accounts":
215
223
  return await cmdAccounts(args, ctx);
216
224
  case "release":
@@ -219,6 +227,10 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
219
227
  return await cmdEnvironment(args, ctx, deps.environment ?? {});
220
228
  case "secret":
221
229
  return await cmdSecret(args, ctx, deps.secret ?? {});
230
+ case "identity":
231
+ return await cmdIdentity(args, ctx, deps.identity ?? {});
232
+ case "connections":
233
+ return await cmdConnections(args, ctx, deps.connections ?? {});
222
234
  default:
223
235
  ctx.stderr(`unknown command: ${args.command}`);
224
236
  ctx.stderr(usageText());
@@ -242,7 +254,7 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
242
254
  throw err;
243
255
  }
244
256
  }
245
- const VERB_HELP_KNOWN = Object.fromEntries(["login", "join", "install", "status", "notice", "me", "contract", "query", "replica", "runtime", "events", "explain", "running", "queue", "watch", "write", "ask", "ready", "accounts", "release", "secret"].map((v) => [v, true]));
257
+ const VERB_HELP_KNOWN = Object.fromEntries(["login", "join", "install", "status", "notice", "me", "contract", "query", "replica", "runtime", "events", "explain", "running", "queue", "watch", "write", "ask", "ready", "accounts", "release", "secret", "environment", "connections", "identity", "mcp"].map((v) => [v, true]));
246
258
  async function cmdLogin(args, ctx, deps) {
247
259
  const manifest = readManifest();
248
260
  const key = (args.key ?? ctx.env.CATALYST_CLOUD_TOKEN ?? "").trim();
@@ -0,0 +1,85 @@
1
+ import { flagInt, positionals } from "./args.js";
2
+ import { openBrowser as defaultOpenBrowser } from "./browser.js";
3
+ import { requireConfig } from "./config.js";
4
+ import { CliError, UsageError } from "./errors.js";
5
+ import { bearerFor } from "./oauth.js";
6
+ import { loadHttpSdk } from "./sdk.js";
7
+ const waitIntervalSeconds = 10;
8
+ function parseCommand(args) {
9
+ const parts = positionals(args);
10
+ if (parts.length !== 3 || parts[0] !== "personal" || (parts[1] !== "linear" && parts[1] !== "github") ||
11
+ (parts[2] !== "start" && parts[2] !== "status")) {
12
+ throw new UsageError("connections takes: personal <linear|github> <start|status>");
13
+ }
14
+ const waitSeconds = flagInt(args, "wait", 0);
15
+ if (waitSeconds < 0 || waitSeconds > 600)
16
+ throw new UsageError("--wait must be between 0 and 600 seconds");
17
+ if (parts[2] !== "start" && args.flags.wait !== undefined)
18
+ throw new UsageError("--wait is only valid with connections personal <provider> start");
19
+ return { provider: parts[1], action: parts[2], waitSeconds };
20
+ }
21
+ function statusLine(provider, result) {
22
+ const name = provider === "linear" ? "Linear" : "GitHub";
23
+ switch (result.outcome) {
24
+ case "connected":
25
+ return `Personal ${name}: connected as ${result.provider === "linear" ? result.linearUserId : result.githubLogin}`;
26
+ case "absent":
27
+ return `Personal ${name}: not connected — run catalyst-skills connections personal ${provider} start`;
28
+ case "lapsed":
29
+ return `Personal ${name}: connection expired — run catalyst-skills connections personal ${provider} start`;
30
+ case "unavailable":
31
+ return `Personal ${name}: temporarily unavailable — the grant state is unknown; retry status later`;
32
+ default:
33
+ return `Personal ${name}: ${result.outcome}${"reason" in result ? ` (${result.reason})` : ""}`;
34
+ }
35
+ }
36
+ function statusExit(result) {
37
+ return result.outcome === "connected" || result.outcome === "absent" || result.outcome === "lapsed" ? 0 : 1;
38
+ }
39
+ export async function cmdConnections(args, ctx, deps = {}) {
40
+ const { provider, action, waitSeconds } = parseCommand(args);
41
+ const cfg = requireConfig(ctx);
42
+ if (!cfg.user)
43
+ throw new CliError("this machine uses a tenant account key, not a member credential — run catalyst-skills login as yourself", "member-required");
44
+ const createClient = deps.createClient ?? (async (options) => (await loadHttpSdk()).createTenantClient(options));
45
+ const client = await createClient({ key: await bearerFor(ctx, cfg), baseUrl: cfg.baseUrl, fetch: ctx.fetch });
46
+ if (action === "status") {
47
+ const result = await client.personalConnections.status(provider);
48
+ ctx.stdout(args.json ? JSON.stringify(result) : statusLine(provider, result));
49
+ return statusExit(result);
50
+ }
51
+ const started = await client.personalConnections.start(provider);
52
+ if (started.outcome !== "ok") {
53
+ ctx.stdout(args.json ? JSON.stringify(started) : `Personal ${provider} connection could not start: ${started.outcome}${"reason" in started ? ` (${started.reason})` : ""}`);
54
+ return 1;
55
+ }
56
+ if (!args.json) {
57
+ ctx.stdout(`Open this URL in your browser to approve your personal ${provider} connection:`);
58
+ ctx.stdout(started.authorizationUrl);
59
+ (deps.openBrowser ?? defaultOpenBrowser)(started.authorizationUrl);
60
+ }
61
+ if (waitSeconds === 0) {
62
+ if (args.json)
63
+ ctx.stdout(JSON.stringify(started));
64
+ else
65
+ ctx.stdout(`After approval, run: catalyst-skills connections personal ${provider} status`);
66
+ return 0;
67
+ }
68
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
69
+ let waitedSeconds = 0;
70
+ let status = await client.personalConnections.status(provider);
71
+ while ((status.outcome === "absent" || status.outcome === "lapsed") && waitedSeconds < waitSeconds) {
72
+ const seconds = Math.min(waitIntervalSeconds, waitSeconds - waitedSeconds);
73
+ await sleep(seconds * 1000);
74
+ waitedSeconds += seconds;
75
+ status = await client.personalConnections.status(provider);
76
+ }
77
+ if (args.json)
78
+ ctx.stdout(JSON.stringify({ start: started, status, waitedSeconds }));
79
+ else {
80
+ ctx.stdout(statusLine(provider, status));
81
+ if (status.outcome === "absent" || status.outcome === "lapsed")
82
+ ctx.stdout(`Approval has not appeared yet; run catalyst-skills connections personal ${provider} status later.`);
83
+ }
84
+ return status.outcome === "connected" ? 0 : status.outcome === "absent" || status.outcome === "lapsed" ? 2 : 1;
85
+ }
@@ -0,0 +1,66 @@
1
+ // Read-only event cache freshness. The event cursor is separate from the replica snapshot cursor.
2
+ import { readFileSync } from "node:fs";
3
+ import { requireConfig } from "./config.js";
4
+ import { apiClient } from "./transport.js";
5
+ function jsonFile(path) {
6
+ try {
7
+ const value = JSON.parse(readFileSync(path, "utf8"));
8
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
14
+ function livePid(value) {
15
+ if (!Number.isInteger(value) || Number(value) <= 0)
16
+ return false;
17
+ try {
18
+ process.kill(Number(value), 0);
19
+ return true;
20
+ }
21
+ catch (error) {
22
+ return error.code === "EPERM";
23
+ }
24
+ }
25
+ /** Compare the persisted event cursor with the cloud head without consuming events. */
26
+ export async function eventCacheStatus(ctx, directory, probe) {
27
+ const checkpoint = jsonFile(`${directory}/cursor.json`);
28
+ const lock = jsonFile(`${directory}/.sync.writer.lock`);
29
+ const cursor = checkpoint?.version === 1 && Number.isSafeInteger(checkpoint.cursor) && Number(checkpoint.cursor) >= 0 ? Number(checkpoint.cursor) : null;
30
+ const heartbeatAgeMs = typeof lock?.heartbeat === "number" ? Math.max(0, ctx.now().getTime() - lock.heartbeat) : null;
31
+ const writerAlive = livePid(lock?.pid);
32
+ const reasons = [];
33
+ if (cursor === null)
34
+ reasons.push("event cache cursor is absent");
35
+ if (heartbeatAgeMs === null || heartbeatAgeMs >= 15_000)
36
+ reasons.push("event writer heartbeat is absent or stale");
37
+ if (!writerAlive)
38
+ reasons.push("event writer is not running");
39
+ let head = null;
40
+ if (probe && cursor !== null) {
41
+ try {
42
+ const cfg = requireConfig(ctx);
43
+ const response = await apiClient(cfg, ctx).getNdjson("/api/v1/events/backbone", { query: { since: cursor }, accept: [409] });
44
+ const rawHead = response.headers.get("x-catalyst-event-backbone-head-seq");
45
+ const value = rawHead === null || rawHead.trim() === "" ? NaN : Number(rawHead);
46
+ if (!Number.isSafeInteger(value) || value < 0)
47
+ reasons.push("cloud event head could not be verified");
48
+ else {
49
+ head = value;
50
+ if (response.status !== 200)
51
+ reasons.push(`cloud refused the event cursor (HTTP ${response.status})`);
52
+ }
53
+ }
54
+ catch (error) {
55
+ reasons.push(`cloud event head is unknown: ${error instanceof Error ? error.message : String(error)}`);
56
+ }
57
+ }
58
+ if (probe && head !== null && cursor !== head)
59
+ reasons.push(`event cache cursor ${cursor} differs from cloud head ${head}`);
60
+ if (!probe)
61
+ reasons.push("cloud event head was not probed");
62
+ const unknown = reasons.some((reason) => reason.includes("unknown") || reason.includes("could not be verified") || reason.includes("was not probed"));
63
+ const provenStale = reasons.some((reason) => reason.includes("heartbeat") || reason.includes("not running") || reason.includes("differs from cloud head") || reason.includes("refused the event cursor"));
64
+ const verdict = cursor === null ? "absent" : provenStale ? "stale" : unknown ? "unknown" : reasons.length === 0 ? "current" : "stale";
65
+ return { verdict, directory, cursor, head, heartbeatAgeMs, writerAlive, reasons };
66
+ }
package/dist/events.js CHANGED
@@ -2,6 +2,7 @@ import { flagInt, flagString, positionals } from "./args.js";
2
2
  import { apiBase, requireConfig } from "./config.js";
3
3
  import { CliError, UsageError } from "./errors.js";
4
4
  import { authStrategyFor } from "./oauth.js";
5
+ import { eventCacheStatus } from "./event-status.js";
5
6
  const eventsModule = "@catalyst-cloud/sdk/events";
6
7
  export const loadEventsSdk = () => import(eventsModule);
7
8
  export async function createEventSync(ctx, deps = {}) {
@@ -20,13 +21,18 @@ export async function createEventSync(ctx, deps = {}) {
20
21
  export async function cmdEvents(args, ctx, deps = {}) {
21
22
  const [sub] = positionals(args);
22
23
  if (!sub)
23
- throw new UsageError("events needs a subcommand: tail | wait-for | query");
24
- if (!["tail", "wait-for", "query"].includes(sub))
24
+ throw new UsageError("events needs a subcommand: tail | wait-for | query | status");
25
+ if (!["tail", "wait-for", "query", "status"].includes(sub))
25
26
  throw new UsageError(`unknown events subcommand: ${sub}`);
26
27
  const cfg = requireConfig(ctx);
27
28
  const sdk = await (deps.loadSdk ?? loadEventsSdk)();
28
29
  const directory = flagString(args, "directory") ??
29
30
  sdk.defaultEventCacheDirectory(cfg.account);
31
+ if (sub === "status") {
32
+ const status = await eventCacheStatus(ctx, directory, args.flags.probe === true);
33
+ ctx.stdout(args.json ? JSON.stringify(status) : `events: ${status.verdict} at ${directory}${status.reasons.length ? ` (${status.reasons.join("; ")})` : ` (cursor ${status.cursor}, cloud head ${status.head})`}`);
34
+ return status.verdict === "current" ? 0 : status.verdict === "stale" ? 1 : status.verdict === "absent" ? 3 : 2;
35
+ }
30
36
  const after = startingCursor(args, sub === "query");
31
37
  const matches = matcher(args);
32
38
  if (sub === "query") {
@@ -0,0 +1,53 @@
1
+ import { positionals } from "./args.js";
2
+ import { requireConfig } from "./config.js";
3
+ import { CliError, UsageError } from "./errors.js";
4
+ import { bearerFor } from "./oauth.js";
5
+ import { loadHttpSdk } from "./sdk.js";
6
+ function render(result, choices) {
7
+ if (result.outcome !== "ok")
8
+ return `Linear identity: ${result.outcome}${"reason" in result ? ` (${result.reason})` : ""}`;
9
+ const { identity } = result;
10
+ const summary = `Linear identity: ${identity.resolution}${"linearUserId" in identity ? ` (${identity.linearUserId})` : ""}`;
11
+ if (!choices)
12
+ return summary;
13
+ if (result.options === undefined)
14
+ return `${summary}\nNo choices were offered. An automatic match cannot be replaced; an unmatched roster may need a retry.`;
15
+ if (result.options.length === 0)
16
+ return `${summary}\nThe workspace roster has no eligible people.`;
17
+ return [summary, ...result.options.map((o) => `${o.id}\t${o.displayName ?? o.name ?? "(unnamed)"}`), "Select your own identity explicitly: catalyst-skills identity linear set <linearUserId>"].join("\n");
18
+ }
19
+ export async function cmdIdentity(args, ctx, deps = {}) {
20
+ const parts = positionals(args);
21
+ const [provider, action, selected] = parts;
22
+ if (provider !== "linear" || !["status", "options", "set"].includes(action ?? "") ||
23
+ (action === "set" ? parts.length !== 3 || !selected?.trim() : parts.length !== 2)) {
24
+ throw new UsageError("identity takes: linear <status|options> or linear set <linearUserId>");
25
+ }
26
+ const cfg = requireConfig(ctx);
27
+ if (!cfg.user)
28
+ throw new CliError("this machine uses a tenant account key; run catalyst-skills login as yourself", "member-required");
29
+ const createClient = deps.createClient ?? (async (options) => (await loadHttpSdk()).createTenantClient(options));
30
+ const client = await createClient({ key: await bearerFor(ctx, cfg), baseUrl: cfg.baseUrl, fetch: ctx.fetch });
31
+ if (!client.linearIdentity)
32
+ throw new CliError("the installed SDK lacks identity recovery; upgrade catalyst-skills after the SDK release", "sdk-upgrade-required");
33
+ if (action === "set") {
34
+ // The arity check above requires a selection; never choose the first roster entry for a person.
35
+ if (selected === undefined)
36
+ throw new UsageError("linearUserId is required");
37
+ const written = await client.linearIdentity.set(selected);
38
+ if (written.outcome !== "ok") {
39
+ ctx.stdout(args.json ? JSON.stringify(written) : render(written, false));
40
+ return 1;
41
+ }
42
+ const verified = await client.linearIdentity.get();
43
+ if (verified.outcome !== "ok" || !("linearUserId" in verified.identity) || verified.identity.linearUserId !== selected) {
44
+ ctx.stdout(args.json ? JSON.stringify({ outcome: "not-confirmed", identity: verified }) : `Linear identity selection was not confirmed. Read the current status before retrying.\n${render(verified, false)}`);
45
+ return 1;
46
+ }
47
+ ctx.stdout(args.json ? JSON.stringify(verified) : render(verified, false));
48
+ return 0;
49
+ }
50
+ const result = await client.linearIdentity.get();
51
+ ctx.stdout(args.json ? JSON.stringify(result) : render(result, action === "options"));
52
+ return result.outcome === "ok" ? 0 : 1;
53
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,101 @@
1
+ import { flagList, flagString, positionals } from "./args.js";
2
+ import { requireConfig } from "./config.js";
3
+ import { CliError, UsageError } from "./errors.js";
4
+ import { bearerFor } from "./oauth.js";
5
+ import { loadTenantSdk } from "./sdk.js";
6
+ const SECRET_NAME = /^[A-Z][A-Z0-9_]*$/;
7
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
8
+ const FORBIDDEN_HEADERS = new Set(["host", "connection", "transfer-encoding", "content-length"]);
9
+ function secretName(value) {
10
+ if (!SECRET_NAME.test(value))
11
+ throw new UsageError("auth needs a vault secret NAME (A-Z, 0-9 and _, starting with a letter), never its value");
12
+ return value;
13
+ }
14
+ function parseAuth(args) {
15
+ const bearer = flagString(args, "bearer");
16
+ const headers = flagList(args, "header");
17
+ const auth = flagString(args, "auth");
18
+ if (Number(bearer !== undefined) + Number(headers.length > 0) + Number(auth !== undefined) !== 1) {
19
+ throw new UsageError("choose exactly one of --auth none, --bearer SECRET_NAME, or --header NAME=SECRET_NAME");
20
+ }
21
+ if (auth !== undefined) {
22
+ if (auth !== "none")
23
+ throw new UsageError("--auth accepts only none");
24
+ return { kind: "none" };
25
+ }
26
+ if (bearer !== undefined)
27
+ return { kind: "bearer", secretName: secretName(bearer) };
28
+ const seen = new Set();
29
+ return { kind: "headers", headers: headers.map((entry) => {
30
+ const equal = entry.indexOf("=");
31
+ if (equal < 1)
32
+ throw new UsageError("--header needs HEADER_NAME=VAULT_SECRET_NAME");
33
+ const name = entry.slice(0, equal);
34
+ const lower = name.toLowerCase();
35
+ if (!HEADER_NAME.test(name) || FORBIDDEN_HEADERS.has(lower) || lower.startsWith("proxy-")) {
36
+ throw new UsageError("--header contains an invalid or forbidden routing/framing header");
37
+ }
38
+ if (seen.has(lower))
39
+ throw new UsageError("--header names must be unique, ignoring case");
40
+ seen.add(lower);
41
+ return { name, secretName: secretName(entry.slice(equal + 1)) };
42
+ }) };
43
+ }
44
+ function registration(args, name) {
45
+ const raw = flagString(args, "url");
46
+ let url;
47
+ try {
48
+ url = new URL(raw ?? "");
49
+ }
50
+ catch {
51
+ throw new UsageError("mcp add needs --url with an absolute HTTPS URL");
52
+ }
53
+ if (url.protocol !== "https:" || url.username || url.password || raw?.includes("?") || raw?.includes("#") || (url.port && url.port !== "443")) {
54
+ throw new UsageError("MCP URLs require HTTPS on port 443, without userinfo, query strings or fragments");
55
+ }
56
+ // The cloud validates public DNS, catalog identity and admin approval. Never contact this URL here.
57
+ return { name, url: url.href, auth: parseAuth(args) };
58
+ }
59
+ function describeServer(server) {
60
+ const state = server.status === "pending" ? "waiting for admin approval"
61
+ : server.status === "pending_egress_guard" ? "waiting for public egress guard" : "ready";
62
+ const auth = server.auth.kind === "none" ? "none" : server.auth.kind === "bearer"
63
+ ? `bearer: ${server.auth.secretName}`
64
+ : server.auth.headers.map((header) => `${header.name}: ${header.secretName}`).join(", ");
65
+ return `${server.name} (${server.id}): ${state}\n ${server.url}\n auth: ${auth}`;
66
+ }
67
+ export async function cmdMcp(args, ctx) {
68
+ const [verb, ...rest] = positionals(args);
69
+ if (verb !== "add" && verb !== "list" && verb !== "remove")
70
+ throw new UsageError("mcp needs add, list or remove");
71
+ if (rest.length !== (verb === "list" ? 0 : 1))
72
+ throw new UsageError(`mcp ${verb}${verb === "list" ? "" : " <name>"}`);
73
+ const name = rest[0] ?? "";
74
+ if (verb !== "list" && !/^[a-z][a-z0-9_-]{0,63}$/.test(name))
75
+ throw new UsageError("server name must start with a lowercase letter and contain only lowercase letters, digits, _ or - (up to 64 characters)");
76
+ if (verb !== "add" && ["url", "auth", "bearer", "header"].some((key) => key in args.flags)) {
77
+ throw new UsageError("URL and auth options are only accepted by mcp add");
78
+ }
79
+ const input = verb === "add" ? registration(args, name) : null;
80
+ const cfg = requireConfig(ctx);
81
+ const key = await bearerFor(ctx, cfg);
82
+ const sdk = await loadTenantSdk();
83
+ const client = sdk.createTenantClient({ key, baseUrl: cfg.baseUrl, fetch: ctx.fetch, now: () => ctx.now().getTime() });
84
+ const result = input !== null ? await client.agent.portalServerRegister(input)
85
+ : verb === "list" ? await client.agent.portalServers()
86
+ : await client.agent.portalServerRemove({ name });
87
+ if (result.outcome !== "registered" && result.outcome !== "ok" && result.outcome !== "removed") {
88
+ const reason = result.outcome === "route-unknown" ? `cloud does not advertise ${result.route}`
89
+ : "reason" in result ? result.reason : result.outcome;
90
+ throw new CliError(String(reason), result.outcome);
91
+ }
92
+ if (args.json)
93
+ ctx.stdout(JSON.stringify(result));
94
+ else if (result.outcome === "registered")
95
+ ctx.stdout(describeServer(result.server));
96
+ else if (result.outcome === "ok")
97
+ ctx.stdout(result.servers.length === 0 ? "No portal servers registered." : result.servers.map(describeServer).join("\n"));
98
+ else
99
+ ctx.stdout(result.removed ? `Removed ${name}.` : `${name} was not registered.`);
100
+ return 0;
101
+ }
package/dist/sdk.js CHANGED
@@ -3,7 +3,19 @@ import { installTsDepsLoader } from "./ts-deps-loader.js";
3
3
  import { readManifest } from "./config.js";
4
4
  import { FIX_COMMAND, supportedRangeText } from "./runtime.js";
5
5
  let cached = null;
6
+ let cachedHttp = null;
6
7
  const realImport = () => import("@catalyst-cloud/sdk/node");
8
+ const realHttpImport = () => import("@catalyst-cloud/sdk");
9
+ /** The isomorphic typed HTTP client; keeps the SDK import in this module. */
10
+ export function loadHttpSdk(importer = realHttpImport) {
11
+ if (!cachedHttp) {
12
+ cachedHttp = importer().catch((err) => {
13
+ cachedHttp = null;
14
+ throw new CliError(`the Catalyst Cloud SDK HTTP client could not be loaded: ${err instanceof Error ? err.message : String(err)}`, "sdk-unavailable");
15
+ });
16
+ }
17
+ return cachedHttp;
18
+ }
7
19
  /** Import the SDK's node entry, installing the type-stripping loader first. Cached per process. */
8
20
  export function loadSdk(importer = realImport) {
9
21
  if (!cached) {
@@ -26,4 +38,15 @@ export function loadSdk(importer = realImport) {
26
38
  /** Test seam: forget the cached import. */
27
39
  export function resetSdkCache() {
28
40
  cached = null;
41
+ cachedHttp = null;
42
+ }
43
+ /** HTTP tenant methods live on the SDK's root entry, separate from replica/node exports. */
44
+ export async function loadTenantSdk() {
45
+ installTsDepsLoader();
46
+ try {
47
+ return await import("@catalyst-cloud/sdk");
48
+ }
49
+ catch {
50
+ throw new CliError("the Catalyst Cloud SDK could not be loaded; reinstall the skills bundle", "sdk-unavailable");
51
+ }
29
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Customer skill bundle for Catalyst Cloud, named from the executive's seat — get set up, see what is happening, see what needs you, run a project, unstick what is stuck — plus the catalyst CLI that reads through the Catalyst Cloud SDK, writes through the agent proxy, and connects your machine to your tenant with one command. The skills directory is the roster; this line never counts it.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,7 +48,7 @@
48
48
  "paths:check": "node scripts/vendor-paths.mjs --check"
49
49
  },
50
50
  "dependencies": {
51
- "@catalyst-cloud/sdk": "^0.10.0"
51
+ "@catalyst-cloud/sdk": "^0.12.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^26.3.0",
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
5
5
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.8.0 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.0 — written in this repository for customer tenants -->
8
8
 
9
9
  # Catalyst's GitHub
10
10
 
@@ -5,7 +5,7 @@ description: >-
5
5
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
6
  disable-model-invocation: true
7
7
  ---
8
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.8.0 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.0 — written in this repository for customer tenants -->
9
9
 
10
10
  # Catalyst Linear
11
11