@catalyst-cloud/catalyst-skills 0.1.1 → 0.2.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 (112) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +109 -53
  3. package/bin/catalyst-skills.js +42 -2
  4. package/dist/args.js +242 -0
  5. package/dist/ask.js +137 -0
  6. package/dist/cli.js +167 -279
  7. package/dist/config.js +106 -0
  8. package/dist/contract-types.js +4 -0
  9. package/dist/contract.js +178 -0
  10. package/dist/detach.js +10 -0
  11. package/dist/errors.js +27 -0
  12. package/dist/execution.js +308 -0
  13. package/dist/http.js +175 -0
  14. package/dist/prompt.js +35 -0
  15. package/dist/query.js +225 -0
  16. package/dist/ready.js +123 -0
  17. package/dist/replica.js +318 -0
  18. package/dist/sdk.js +26 -0
  19. package/dist/skill-shape.js +179 -0
  20. package/dist/skills.js +74 -0
  21. package/dist/ts-deps-loader.js +68 -0
  22. package/dist/watch/consumer.js +141 -0
  23. package/dist/watch/cursor-file.js +62 -0
  24. package/dist/watch.js +173 -0
  25. package/dist/write.js +224 -0
  26. package/package.json +12 -7
  27. package/skills/catalyst-github/SKILL.md +35 -0
  28. package/skills/catalyst-github/agents/openai.yaml +6 -0
  29. package/skills/catalyst-github/agents/portability.yaml +4 -0
  30. package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
  31. package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
  32. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
  33. package/skills/catalyst-github/scripts/lib/cli.mjs +102 -0
  34. package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
  35. package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
  36. package/skills/catalyst-linear/SKILL.md +43 -0
  37. package/skills/catalyst-linear/agents/openai.yaml +6 -0
  38. package/skills/catalyst-linear/agents/portability.yaml +5 -0
  39. package/skills/catalyst-linear/references/reading-a-ticket.md +50 -0
  40. package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
  41. package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
  42. package/skills/catalyst-linear/scripts/comment.mjs +59 -0
  43. package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
  44. package/skills/catalyst-linear/scripts/label.mjs +48 -0
  45. package/skills/catalyst-linear/scripts/lib/cli.mjs +163 -0
  46. package/skills/catalyst-linear/scripts/move.mjs +41 -0
  47. package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
  48. package/skills/catalyst-linear/scripts/search.mjs +49 -0
  49. package/skills/catalyst-setup/SKILL.md +32 -0
  50. package/skills/catalyst-setup/agents/openai.yaml +6 -0
  51. package/skills/catalyst-setup/agents/portability.yaml +4 -0
  52. package/skills/catalyst-setup/references/what-each-check-means.md +61 -0
  53. package/skills/catalyst-setup/scripts/check.mjs +75 -0
  54. package/skills/catalyst-setup/scripts/lib/cli.mjs +102 -0
  55. package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
  56. package/skills/connect-me/SKILL.md +55 -0
  57. package/skills/connect-me/agents/openai.yaml +6 -0
  58. package/skills/connect-me/agents/portability.yaml +5 -0
  59. package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
  60. package/skills/connect-me/scripts/lib/cli.mjs +184 -0
  61. package/skills/connect-me/scripts/verify-connection.mjs +68 -0
  62. package/skills/how-catalyst-works/SKILL.md +42 -0
  63. package/skills/how-catalyst-works/agents/openai.yaml +6 -0
  64. package/skills/how-catalyst-works/agents/portability.yaml +4 -0
  65. package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
  66. package/skills/how-catalyst-works/references/stages-and-mapping.md +54 -0
  67. package/skills/how-catalyst-works/references/the-ladder.md +41 -0
  68. package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
  69. package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
  70. package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
  71. package/skills/how-catalyst-works/scripts/lib/cli.mjs +163 -0
  72. package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
  73. package/skills/how-catalyst-works/scripts/whats-running.mjs +66 -0
  74. package/skills/run-this-project/SKILL.md +45 -0
  75. package/skills/run-this-project/agents/openai.yaml +6 -0
  76. package/skills/run-this-project/agents/portability.yaml +5 -0
  77. package/skills/run-this-project/assets/stall-policy.json +15 -0
  78. package/skills/run-this-project/references/making-work-ready.md +60 -0
  79. package/skills/run-this-project/references/reacting-to-events.md +76 -0
  80. package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
  81. package/skills/run-this-project/scripts/lib/cli.mjs +184 -0
  82. package/skills/run-this-project/scripts/make-ready.mjs +64 -0
  83. package/skills/run-this-project/scripts/scope-status.mjs +0 -0
  84. package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
  85. package/skills/what-needs-me/SKILL.md +41 -0
  86. package/skills/what-needs-me/agents/openai.yaml +6 -0
  87. package/skills/what-needs-me/agents/portability.yaml +5 -0
  88. package/skills/what-needs-me/references/raising-a-decision.md +41 -0
  89. package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
  90. package/skills/what-needs-me/references/settling-an-answer.md +37 -0
  91. package/skills/what-needs-me/scripts/inbox.mjs +47 -0
  92. package/skills/what-needs-me/scripts/lib/cli.mjs +134 -0
  93. package/skills/what-needs-me/scripts/raise.mjs +53 -0
  94. package/skills/what-needs-me/scripts/settle.mjs +73 -0
  95. package/skills/whats-happening/SKILL.md +43 -0
  96. package/skills/whats-happening/agents/openai.yaml +6 -0
  97. package/skills/whats-happening/agents/portability.yaml +4 -0
  98. package/skills/whats-happening/assets/status-reply.json +77 -0
  99. package/skills/whats-happening/references/reading-the-board.md +43 -0
  100. package/skills/whats-happening/references/reprioritising.md +37 -0
  101. package/skills/whats-happening/references/routing-work.md +36 -0
  102. package/skills/whats-happening/references/status-reply.md +34 -0
  103. package/skills/whats-happening/references/why-is-it-stuck.md +57 -0
  104. package/skills/whats-happening/scripts/explain.mjs +28 -0
  105. package/skills/whats-happening/scripts/lib/cli.mjs +134 -0
  106. package/skills/whats-happening/scripts/snapshot.mjs +145 -0
  107. package/skills/ask/SKILL.md +0 -37
  108. package/skills/concierge/SKILL.md +0 -41
  109. package/skills/join/SKILL.md +0 -30
  110. package/skills/linearis/SKILL.md +0 -40
  111. package/skills/setup/SKILL.md +0 -27
  112. package/skills/steward/SKILL.md +0 -30
package/dist/http.js ADDED
@@ -0,0 +1,175 @@
1
+ import { normalizeBaseUrl } from "./config.js";
2
+ import { CliError, MeError } from "./errors.js";
3
+ export const REQUEST_TIMEOUT_MS = 15_000;
4
+ export class ApiClient {
5
+ cfg;
6
+ fetchImpl;
7
+ constructor(cfg, fetchImpl) {
8
+ this.cfg = cfg;
9
+ this.fetchImpl = fetchImpl;
10
+ }
11
+ /** `path` is absolute under the origin (e.g. `/api/v1/issues`). */
12
+ url(path, query) {
13
+ const u = new URL(`${normalizeBaseUrl(this.cfg.baseUrl)}${path}`);
14
+ for (const [k, v] of Object.entries(query ?? {}))
15
+ if (v !== undefined)
16
+ u.searchParams.set(k, String(v));
17
+ return u.toString();
18
+ }
19
+ async getJson(path, opts = {}) {
20
+ const headers = {
21
+ authorization: `Bearer ${this.cfg.key}`,
22
+ accept: "application/json",
23
+ };
24
+ if (opts.etag)
25
+ headers["if-none-match"] = opts.etag;
26
+ const url = this.url(path, opts.query);
27
+ const res = await this.send(url, { method: "GET", headers });
28
+ if (opts.accept?.includes(res.status)) {
29
+ return { status: res.status, body: undefined, headers: res.headers };
30
+ }
31
+ await this.refuseIfNotOk(res, `GET ${path}`);
32
+ return { status: res.status, body: await this.parseBody(res, `GET ${path}`), headers: res.headers };
33
+ }
34
+ /**
35
+ * A GET whose success body is NDJSON — one JSON object per line, the shape `streamNdjson` sends.
36
+ *
37
+ * ⛔ `/changes` and `/snapshot` ANSWER NDJSON ON 200 AND JSON ON A REFUSAL, so a caller that only
38
+ * has `getJson` reads every real success as "returned a non-JSON body" while its refusals parse
39
+ * perfectly. That asymmetry is why `query changes` looked like it worked: nothing reached a 200.
40
+ * `accept` behaves as it does on `getJson` — those statuses come back with `rows: []` for the
41
+ * caller to branch on, and their (JSON) body is deliberately not parsed here.
42
+ */
43
+ async getNdjson(path, opts = {}) {
44
+ const url = this.url(path, opts.query);
45
+ const res = await this.send(url, {
46
+ method: "GET",
47
+ headers: { authorization: `Bearer ${this.cfg.key}`, accept: "application/x-ndjson, application/json" },
48
+ });
49
+ if (opts.accept?.includes(res.status))
50
+ return { status: res.status, body: [], headers: res.headers };
51
+ await this.refuseIfNotOk(res, `GET ${path}`);
52
+ const text = await res.text();
53
+ const rows = [];
54
+ for (const line of text.split("\n")) {
55
+ const trimmed = line.trim();
56
+ if (trimmed === "")
57
+ continue;
58
+ try {
59
+ rows.push(JSON.parse(trimmed));
60
+ }
61
+ catch {
62
+ throw new MeError(`GET ${path} returned a line that is not JSON: ${trimmed.slice(0, 120)}`, "shape");
63
+ }
64
+ }
65
+ return { status: res.status, body: rows, headers: res.headers };
66
+ }
67
+ async postJson(path, body) {
68
+ const url = this.url(path);
69
+ const res = await this.send(url, {
70
+ method: "POST",
71
+ headers: {
72
+ authorization: `Bearer ${this.cfg.key}`,
73
+ accept: "application/json",
74
+ "content-type": "application/json",
75
+ },
76
+ body: JSON.stringify(body),
77
+ });
78
+ await this.refuseIfNotOk(res, `POST ${path}`);
79
+ return { status: res.status, body: await this.parseBody(res, `POST ${path}`), headers: res.headers };
80
+ }
81
+ async send(url, init) {
82
+ try {
83
+ return await this.fetchImpl(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
84
+ }
85
+ catch (err) {
86
+ throw new MeError(`could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`, "network");
87
+ }
88
+ }
89
+ async parseBody(res, what) {
90
+ try {
91
+ return (await res.json());
92
+ }
93
+ catch {
94
+ throw new MeError(`${what} returned a non-JSON body`, "shape");
95
+ }
96
+ }
97
+ async refuseIfNotOk(res, what) {
98
+ if (res.ok)
99
+ return;
100
+ let reason = "";
101
+ let raw = "";
102
+ try {
103
+ raw = await res.text();
104
+ const body = JSON.parse(raw);
105
+ reason = body.reason ?? body.error ?? body.message ?? "";
106
+ }
107
+ catch {
108
+ reason = raw.slice(0, 200);
109
+ }
110
+ if (res.status === 401) {
111
+ throw new CliError(`${what} failed (401): credential not accepted — ask your tenant admin for a valid account key`, "unauthorized", 2, 401);
112
+ }
113
+ if (res.status === 403) {
114
+ throw new CliError(`${what} failed (403): this key may not reach that route${reason ? ` (${reason})` : ""} — a machine-gated route needs an account key (ctc_acct_…), not a workstation key`, "forbidden", 2, 403);
115
+ }
116
+ if (res.status === 429) {
117
+ const retryAfter = res.headers.get("retry-after");
118
+ const budget = /budget/i.test(raw) || /budget/i.test(reason);
119
+ throw new CliError(`${what} refused (429)${budget ? ": the per-host daily write budget is spent" : ": rate limited"}${reason ? ` — ${reason}` : ""}${retryAfter ? ` — retry after ${retryAfter}s` : ""}`, budget ? "budget" : "rate-limited", 2, 429);
120
+ }
121
+ throw new CliError(`${what} failed (${res.status})${reason ? `: ${reason}` : ""}`, "http", 2, res.status);
122
+ }
123
+ }
124
+ export function apiClient(cfg, ctx) {
125
+ return new ApiClient(cfg, ctx.fetch);
126
+ }
127
+ /** GET /api/v1/me — how a key learns its own tenant. Unchanged contract from 0.1. */
128
+ export async function fetchMe(baseUrl, key, fetchImpl, timeoutMs = REQUEST_TIMEOUT_MS) {
129
+ const url = `${normalizeBaseUrl(baseUrl)}/api/v1/me`;
130
+ let res;
131
+ try {
132
+ res = await fetchImpl(url, {
133
+ headers: { authorization: `Bearer ${key}`, accept: "application/json" },
134
+ signal: AbortSignal.timeout(timeoutMs),
135
+ });
136
+ }
137
+ catch (err) {
138
+ throw new MeError(`could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`, "network");
139
+ }
140
+ if (!res.ok) {
141
+ let reason;
142
+ try {
143
+ const body = (await res.json());
144
+ reason = body.reason ?? body.error ?? "";
145
+ }
146
+ catch {
147
+ reason = "";
148
+ }
149
+ const detail = res.status === 401
150
+ ? "credential not accepted — ask your tenant admin for a valid account key"
151
+ : reason || `HTTP ${res.status}`;
152
+ throw new MeError(`GET /me failed (${res.status}): ${detail}`, "http", res.status);
153
+ }
154
+ let body;
155
+ try {
156
+ body = (await res.json());
157
+ }
158
+ catch {
159
+ throw new MeError("GET /me returned a non-JSON body", "shape");
160
+ }
161
+ if (typeof body.account !== "string" ||
162
+ typeof body.slug !== "string" ||
163
+ typeof body.name !== "string" ||
164
+ (body.permissions !== null && !Array.isArray(body.permissions)) ||
165
+ (body.principal !== "service" && body.principal !== "session")) {
166
+ throw new MeError("GET /me returned an unexpected shape", "shape");
167
+ }
168
+ return {
169
+ account: body.account,
170
+ slug: body.slug,
171
+ name: body.name,
172
+ permissions: body.permissions,
173
+ principal: body.principal,
174
+ };
175
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,35 @@
1
+ // prompt.ts — the one interactive read in the CLI: asking for the account key on a terminal without
2
+ // echoing it. The streams are parameters so the tests can drive it without a real TTY.
3
+ import { createInterface } from "node:readline";
4
+ /** True when there is a terminal to prompt on. Both ends matter: the question needs somewhere to go. */
5
+ export function stdinIsTty() {
6
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
7
+ }
8
+ /**
9
+ * Read one line without echoing it. Everything readline would write after the question itself goes
10
+ * to a muted stream, so the key never reaches the screen or a scrollback buffer; the caller still
11
+ * gets it back, trimmed.
12
+ */
13
+ export async function promptSecret(question, io) {
14
+ const input = io?.input ?? process.stdin;
15
+ const target = io?.output ?? process.stdout;
16
+ let muted = false;
17
+ const output = Object.create(target);
18
+ output.write = (chunk) => {
19
+ if (!muted)
20
+ target.write(chunk);
21
+ return true;
22
+ };
23
+ const rl = createInterface({ input, output, terminal: true });
24
+ try {
25
+ const answer = await new Promise((resolve) => {
26
+ rl.question(question, (value) => resolve(value));
27
+ muted = true;
28
+ });
29
+ target.write("\n");
30
+ return answer.trim();
31
+ }
32
+ finally {
33
+ rl.close();
34
+ }
35
+ }
package/dist/query.js ADDED
@@ -0,0 +1,225 @@
1
+ // query.ts — `query issues|issue|pulls|pull|projects|cycles|search|changes`: the replica when it is
2
+ // fresh, else the API, with the source named on stderr every time. Output shape is the read-model
3
+ // view either way. `pull`, `cycles`, `search` and `changes` are API-only (the SDK wraps no view for
4
+ // them and the read-model package is not a declared dependency of this bundle).
5
+ import { flagInt, flagString, positionals } from "./args.js";
6
+ import { requireConfig, replicaDbPath } from "./config.js";
7
+ import { CliError, UsageError } from "./errors.js";
8
+ import { apiClient } from "./http.js";
9
+ import { engineFor, replicaStatus } from "./replica.js";
10
+ import { loadSdk } from "./sdk.js";
11
+ const REPLICA_CAPABLE = new Set(["issues", "issue", "pulls", "projects"]);
12
+ export async function cmdQuery(args, ctx, deps = {}) {
13
+ const [sub, ...rest] = positionals(args);
14
+ if (!sub)
15
+ throw new UsageError("query needs a subcommand: issues | issue <id> | pulls | pull <id> | projects | cycles | search <terms> | changes --since <n>");
16
+ const cfg = requireConfig(ctx);
17
+ const filters = {
18
+ team: flagString(args, "team"),
19
+ project: flagString(args, "project"),
20
+ state: flagString(args, "state"),
21
+ limit: flagInt(args, "limit", 50),
22
+ };
23
+ const forced = flagString(args, "source");
24
+ if (forced && forced !== "replica" && forced !== "api")
25
+ throw new UsageError("--source must be replica or api");
26
+ const status = replicaStatus(ctx, cfg);
27
+ let source;
28
+ let why;
29
+ if (!REPLICA_CAPABLE.has(sub)) {
30
+ source = "api";
31
+ why = `${sub} is api-only`;
32
+ }
33
+ else if (forced) {
34
+ source = forced;
35
+ why = `--source ${forced}`;
36
+ }
37
+ else if (status.verdict === "fresh") {
38
+ source = "replica";
39
+ why = `cursor ${status.cursor}`;
40
+ }
41
+ else {
42
+ source = "api";
43
+ why = `replica ${status.verdict}`;
44
+ }
45
+ ctx.stderr(source === "replica" ? `source: replica (${why})` : `source: api (${why})`);
46
+ let result;
47
+ if (source === "replica") {
48
+ if (status.verdict === "absent" || status.verdict === "not-configured") {
49
+ throw new UsageError(`--source replica but the replica is ${status.verdict}`);
50
+ }
51
+ result = await fromReplica(ctx, cfg, sub, rest, filters, deps);
52
+ }
53
+ else {
54
+ result = await fromApi(ctx, cfg, sub, rest, filters, args);
55
+ }
56
+ if (result === null) {
57
+ ctx.stderr(`${sub} ${rest[0] ?? ""}: not found`);
58
+ return 1;
59
+ }
60
+ printResult(ctx, args, sub, result);
61
+ return 0;
62
+ }
63
+ async function fromReplica(ctx, cfg, sub, rest, f, deps) {
64
+ const sdk = await loadSdk();
65
+ const dbPath = replicaDbPath(cfg, ctx.home);
66
+ const engine = await engineFor(sdk, dbPath, ctx, { ...deps.engineDeps, readonly: true });
67
+ const replica = await sdk.CatalystReplica.openReadOnly({ dbPath, engine, log: () => { } });
68
+ try {
69
+ switch (sub) {
70
+ case "issues": {
71
+ const rows = replica.issues({ limit: Math.max(f.limit, 200) });
72
+ return applyFilters(rows, f).slice(0, f.limit);
73
+ }
74
+ case "issue":
75
+ return replica.issue(needArg(rest, "issue <identifier>"));
76
+ case "pulls":
77
+ return replica.pulls({ limit: f.limit });
78
+ case "projects":
79
+ return applyFilters(replica.projects({ limit: Math.max(f.limit, 200) }), { limit: f.limit, state: f.state }).slice(0, f.limit);
80
+ default:
81
+ throw new UsageError(`${sub} is not readable from the replica`);
82
+ }
83
+ }
84
+ finally {
85
+ await replica.close();
86
+ }
87
+ }
88
+ async function fromApi(ctx, cfg, sub, rest, f, args) {
89
+ const api = apiClient(cfg, ctx);
90
+ const common = { team: f.team, project: f.project, state: f.state, limit: f.limit };
91
+ switch (sub) {
92
+ case "issues": {
93
+ const res = await api.getJson("/api/v1/issues", { query: common });
94
+ return applyFilters(rowsOf(res.body), f).slice(0, f.limit);
95
+ }
96
+ case "issue": {
97
+ const id = needArg(rest, "issue <identifier>");
98
+ const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(id)}`, { accept: [404] });
99
+ return res.status === 404 ? null : res.body;
100
+ }
101
+ case "pulls": {
102
+ const res = await api.getJson("/api/v1/pulls", { query: { limit: f.limit, ticket: flagString(args, "ticket") } });
103
+ return rowsOf(res.body).slice(0, f.limit);
104
+ }
105
+ case "pull": {
106
+ const id = needArg(rest, "pull <node id>");
107
+ const res = await api.getJson(`/api/v1/pulls/${encodeURIComponent(id)}`, { accept: [404] });
108
+ return res.status === 404 ? null : res.body;
109
+ }
110
+ case "projects": {
111
+ const res = await api.getJson("/api/v1/projects", { query: { limit: f.limit } });
112
+ return rowsOf(res.body).slice(0, f.limit);
113
+ }
114
+ case "cycles": {
115
+ const res = await api.getJson("/api/v1/cycles", { query: { team: f.team, limit: f.limit } });
116
+ return rowsOf(res.body);
117
+ }
118
+ case "search": {
119
+ const q = rest.join(" ").trim();
120
+ if (!q)
121
+ throw new UsageError("search needs terms: query search <terms>");
122
+ const res = await api.getJson("/api/v1/search", { query: { q, limit: f.limit } });
123
+ return rowsOf(res.body);
124
+ }
125
+ case "changes": {
126
+ const since = flagString(args, "since");
127
+ if (since === undefined)
128
+ throw new UsageError("changes needs --since <cursor>, or --since head to start from now");
129
+ return await readChanges(api, since, f.limit);
130
+ }
131
+ default:
132
+ throw new UsageError(`unknown query subcommand: ${sub}`);
133
+ }
134
+ }
135
+ /** CTC-137 stamps the live head seq on EVERY `/changes` response — the 200 stream and both 409s. */
136
+ const HEAD_SEQ_HEADER = "x-catalyst-head-seq";
137
+ /**
138
+ * `query changes --since <cursor|head>`.
139
+ *
140
+ * ⛔ THE CHANGEFEED EVICTS, so a refusal must name a cursor that works. `buildChanges` answers a
141
+ * `since` below the oldest retained seq with 409 `cursor_underflow`, and one past the head with 409
142
+ * `cursor_ahead_of_head` — both `{resync: true}`. `--since 0`, the form the docs show, is therefore
143
+ * a 409 on any tenant whose log has rotated, and 0.2.0 surfaced it as a bare "GET /api/v1/changes
144
+ * failed (409)" that left the customer with no way to learn a usable cursor. The head is stamped on
145
+ * the refusal itself, so there is always something actionable to say.
146
+ */
147
+ async function readChanges(api, since, limit) {
148
+ const resolved = since === "head" ? String(await headCursor(api)) : since;
149
+ // ⛔ NDJSON ON 200, JSON ON A REFUSAL — see `getNdjson`. Reading this with `getJson` turned every
150
+ // real success into "returned a non-JSON body" while every refusal parsed, which is why the verb
151
+ // looked fine: `--since 0` 409s on a rotated feed, so no 200 was ever reached to fail on.
152
+ const res = await api.getNdjson("/api/v1/changes", {
153
+ query: { since: resolved, limit },
154
+ accept: [409],
155
+ });
156
+ const head = res.headers.get(HEAD_SEQ_HEADER);
157
+ if (res.status !== 409) {
158
+ return { since: Number(resolved), head: head === null ? null : Number(head), changes: res.body };
159
+ }
160
+ const where = head === null ? "" : ` The feed's live cursor is ${head}.`;
161
+ throw new CliError(`cursor ${resolved} is not usable: it is either past the feed's head or no longer in the change log, which keeps only recent changes.${where} Re-run with \`--since head\` to start from now, or with a cursor the feed still holds.`, "changefeed-resync", 1, 409);
162
+ }
163
+ /** The live head, read off any `/changes` response's own header — a 409 carries it too, which is
164
+ * what makes this work on a tenant whose log has rotated past every cursor the caller could guess. */
165
+ async function headCursor(api) {
166
+ const probe = await api.getNdjson("/api/v1/changes", { query: { since: "0", limit: 1 }, accept: [409] });
167
+ const raw = probe.headers.get(HEAD_SEQ_HEADER);
168
+ const head = raw === null ? Number.NaN : Number(raw);
169
+ if (!Number.isFinite(head)) {
170
+ throw new CliError(`--since head needs the feed's live cursor, and this cloud did not send one (no ${HEAD_SEQ_HEADER} header). Pass an explicit --since <cursor>.`, "changefeed-head-unknown", 1);
171
+ }
172
+ return head;
173
+ }
174
+ function needArg(rest, usage) {
175
+ const v = rest[0];
176
+ if (!v)
177
+ throw new UsageError(`query ${usage}`);
178
+ return v;
179
+ }
180
+ /** The API answers a list route with either a bare array or `{rows|items|issues|...: []}`. */
181
+ export function rowsOf(body) {
182
+ if (Array.isArray(body))
183
+ return body;
184
+ if (body && typeof body === "object") {
185
+ for (const key of ["rows", "items", "issues", "pulls", "projects", "cycles", "results", "changes", "data"]) {
186
+ const v = body[key];
187
+ if (Array.isArray(v))
188
+ return v;
189
+ }
190
+ }
191
+ return [];
192
+ }
193
+ export function applyFilters(rows, f) {
194
+ return rows.filter((r) => {
195
+ if (f.team && !(String(r.identifier ?? "").toUpperCase().startsWith(`${f.team.toUpperCase()}-`) || r.team_id === f.team || r.team_key === f.team))
196
+ return false;
197
+ if (f.project && r.project_id !== f.project)
198
+ return false;
199
+ if (f.state && String(r.state ?? "").toLowerCase() !== f.state.toLowerCase())
200
+ return false;
201
+ return true;
202
+ });
203
+ }
204
+ function printResult(ctx, args, sub, result) {
205
+ if (args.json || !Array.isArray(result)) {
206
+ ctx.stdout(JSON.stringify(result, null, args.json ? 0 : 2));
207
+ return;
208
+ }
209
+ for (const row of result)
210
+ ctx.stdout(summaryLine(sub, row));
211
+ }
212
+ export function summaryLine(sub, r) {
213
+ switch (sub) {
214
+ case "issues":
215
+ return `${r.identifier ?? r.id} ${r.state ?? "?"} ${r.title ?? ""}${r.assignee_name || r.assignee ? ` (${r.assignee_name ?? r.assignee})` : ""}`;
216
+ case "pulls":
217
+ return `#${r.number ?? "?"} ${r.state ?? "?"}${r.merged ? " merged" : ""} ${r.title ?? ""} [${r.node_id ?? ""}]`;
218
+ case "projects":
219
+ return `${r.id} ${r.state ?? "?"} ${r.name ?? ""}`;
220
+ case "cycles":
221
+ return `cycle ${r.number ?? r.id} ${r.name ?? ""} ${r.starts_at ?? ""} → ${r.ends_at ?? ""}`;
222
+ default:
223
+ return JSON.stringify(r);
224
+ }
225
+ }
package/dist/ready.js ADDED
@@ -0,0 +1,123 @@
1
+ // ready.ts — `ready`: the machine checks plus the tenant's own readiness vector from the contract,
2
+ // one verdict, and per failure the fix and who can apply it. The replica is optional, so its absence
3
+ // is a note, never a failure; the SDK loading IS a check, because the replica and watch need it.
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { defaultSkillsDirFor, loadConfig, readManifest } from "./config.js";
7
+ import { contractVersionInRange, readContractCache } from "./contract.js";
8
+ import { CliError } from "./errors.js";
9
+ import { replicaStatus, statusLine } from "./replica.js";
10
+ import { loadSdk } from "./sdk.js";
11
+ function whoCanAnswer(doc) {
12
+ const roles = doc.humans.map((h) => `${h.role} ${h.linearUserId}`);
13
+ return roles.length ? roles.join(", ") : "a tenant owner or admin (none resolved on the contract)";
14
+ }
15
+ export async function readyReport(ctx, deps) {
16
+ const checks = [];
17
+ const nodeMajor = deps.nodeMajor ?? Number(process.versions.node.split(".")[0]);
18
+ checks.push(nodeMajor >= 22
19
+ ? { id: "node", ok: true, line: `node: ${nodeMajor} (22 or newer required)` }
20
+ : { id: "node", ok: false, line: `node: ${nodeMajor} is too old`, fix: "install Node 22 or newer (the SDK's built-in SQLite engine needs it)", who: "you" });
21
+ let cfg = null;
22
+ try {
23
+ cfg = loadConfig(ctx.home);
24
+ checks.push(cfg
25
+ ? { id: "config", ok: true, line: `config: joined ${cfg.name} (${cfg.slug}) as ${cfg.principal}` }
26
+ : { id: "config", ok: false, line: "config: not connected", fix: "CATALYST_CLOUD_TOKEN=<account key> npx @catalyst-cloud/catalyst-skills login", who: "you (the key comes from your tenant admin)" });
27
+ }
28
+ catch (err) {
29
+ checks.push({ id: "config", ok: false, line: `config: ${err instanceof CliError ? err.message : String(err)}`, fix: "re-run login to rewrite it", who: "you" });
30
+ }
31
+ const cache = readContractCache(ctx.home);
32
+ const range = readManifest().tenantContractRange;
33
+ if (!cache) {
34
+ checks.push({ id: "contract", ok: false, line: "contract: not cached", fix: "catalyst-skills contract --refresh (needs an account key, not a workstation key)", who: "you" });
35
+ }
36
+ else if (contractVersionInRange(cache.contractVersion, range) !== true) {
37
+ checks.push({ id: "contract", ok: false, line: `contract: version ${cache.contractVersion} is outside this bundle's range ${range}`, fix: "npm update -g @catalyst-cloud/catalyst-skills", who: "you" });
38
+ }
39
+ else {
40
+ checks.push({ id: "contract", ok: true, line: `contract: ${cache.contractVersion} cached ${cache.fetchedAt} (range ${range})` });
41
+ }
42
+ if (cfg) {
43
+ checks.push(cfg.cliPath && existsSync(cfg.cliPath)
44
+ ? { id: "cliPath", ok: true, line: `cliPath: ${cfg.cliPath}` }
45
+ : { id: "cliPath", ok: false, line: `cliPath: ${cfg.cliPath ? `${cfg.cliPath} does not exist` : "not recorded"}`, fix: "re-run login so the skill scripts can find this CLI", who: "you" });
46
+ }
47
+ // The skills are installed by the customer's own agent (a plugin, or `npx skills add`), so an
48
+ // empty copy directory is the normal case and must not read as NOT READY. A PARTIAL copy is the
49
+ // one broken state this check can see: half a set this package put there and never finished.
50
+ const skillsDir = cfg?.skillsDir ?? defaultSkillsDirFor(ctx.home);
51
+ const present = deps.skillNames.filter((n) => existsSync(join(skillsDir, n, "SKILL.md")));
52
+ const missing = deps.skillNames.filter((n) => !present.includes(n));
53
+ if (present.length === 0) {
54
+ checks.push({ id: "skills", ok: true, note: true, line: `skills: none copied to ${skillsDir} — you are reading one, so your agent installed them its own way` });
55
+ }
56
+ else if (missing.length === 0) {
57
+ checks.push({ id: "skills", ok: true, line: `skills: all ${deps.skillNames.length} present in ${skillsDir}` });
58
+ }
59
+ else {
60
+ checks.push({ id: "skills", ok: false, line: `skills: ${present.length} of ${deps.skillNames.length} in ${skillsDir}, missing ${missing.join(", ")}`, fix: "catalyst-skills install", who: "you" });
61
+ }
62
+ try {
63
+ await (deps.loadSdk ?? loadSdk)();
64
+ checks.push({ id: "sdk", ok: true, line: "sdk: loads (replica and watch are available)" });
65
+ }
66
+ catch (err) {
67
+ checks.push({ id: "sdk", ok: false, line: `sdk: ${err instanceof Error ? err.message : String(err)}`, fix: "run under Node 22.15 or newer (or bun); every read still works through the API meanwhile", who: "you" });
68
+ }
69
+ const replica = replicaStatus(ctx, cfg);
70
+ checks.push({ id: "replica", ok: replica.verdict === "fresh", note: true, line: statusLine(replica) });
71
+ if (cache) {
72
+ const doc = cache.doc;
73
+ const who = whoCanAnswer(doc);
74
+ for (const team of doc.teams) {
75
+ const label = team.key ?? team.id;
76
+ if (team.readiness.status === "unchecked") {
77
+ checks.push({ id: `team:${label}`, ok: true, note: true, line: `team ${label}: readiness not checked yet` });
78
+ continue;
79
+ }
80
+ const bad = team.readiness.checks.filter((c) => c.state !== "pass");
81
+ if (bad.length === 0 && team.readiness.status === "ready") {
82
+ checks.push({ id: `team:${label}`, ok: true, line: `team ${label}: ready` });
83
+ continue;
84
+ }
85
+ for (const c of bad) {
86
+ const meta = doc.readinessChecks.find((r) => r.id === c.id);
87
+ const needsAnswer = meta?.needsAnswer ?? true;
88
+ checks.push({
89
+ id: `team:${label}:${c.id}`,
90
+ ok: !needsAnswer && c.state !== "fail",
91
+ note: !needsAnswer,
92
+ line: `team ${label}: ${c.id} is ${c.state}${c.reason ? ` (${c.reason}${c.count !== undefined ? ` ×${c.count}` : ""})` : ""}${meta ? `, ${meta.severity}` : ""}`,
93
+ fix: `open settings for team ${label} and resolve ${c.id}`,
94
+ who: needsAnswer ? who : "nobody yet; it is informational",
95
+ });
96
+ }
97
+ if (bad.length === 0 && team.readiness.status !== "ready") {
98
+ checks.push({ id: `team:${label}`, ok: team.readiness.status !== "blocked", note: team.readiness.status === "degraded", line: `team ${label}: ${team.readiness.status}`, fix: `open settings for team ${label}`, who });
99
+ }
100
+ }
101
+ }
102
+ const ready = checks.every((c) => c.ok || c.note);
103
+ return { ready, checks };
104
+ }
105
+ export async function cmdReady(args, ctx, deps) {
106
+ const report = await readyReport(ctx, deps);
107
+ if (args.json) {
108
+ ctx.stdout(JSON.stringify(report));
109
+ }
110
+ else {
111
+ for (const c of report.checks) {
112
+ ctx.stdout(`${c.note ? "note" : c.ok ? "ok " : "FAIL"} ${c.line}`);
113
+ if (!c.ok && !c.note) {
114
+ if (c.fix)
115
+ ctx.stdout(` fix: ${c.fix}`);
116
+ if (c.who)
117
+ ctx.stdout(` who: ${c.who}`);
118
+ }
119
+ }
120
+ ctx.stdout(report.ready ? "READY" : "NOT READY");
121
+ }
122
+ return report.ready ? 0 : 1;
123
+ }