@catalyst-cloud/cli 0.8.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.
Files changed (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -0
  4. package/bin/catalyst-skills.js +8 -0
  5. package/bin/catalyst.js +5 -0
  6. package/bin/launch.js +154 -0
  7. package/dist/args.js +280 -0
  8. package/dist/ask.js +161 -0
  9. package/dist/browser.js +20 -0
  10. package/dist/cli.js +397 -0
  11. package/dist/config.js +241 -0
  12. package/dist/contract-types.js +4 -0
  13. package/dist/contract.js +184 -0
  14. package/dist/detach.js +10 -0
  15. package/dist/environment.js +207 -0
  16. package/dist/errors.js +27 -0
  17. package/dist/events.js +106 -0
  18. package/dist/execution.js +451 -0
  19. package/dist/oauth.js +300 -0
  20. package/dist/pagination.js +76 -0
  21. package/dist/prompt.js +35 -0
  22. package/dist/published.js +79 -0
  23. package/dist/query.js +248 -0
  24. package/dist/ready.js +380 -0
  25. package/dist/release.js +142 -0
  26. package/dist/replica.js +614 -0
  27. package/dist/runtime-store.js +135 -0
  28. package/dist/runtime-verb.js +66 -0
  29. package/dist/runtime.js +87 -0
  30. package/dist/sdk.js +29 -0
  31. package/dist/secret.js +190 -0
  32. package/dist/semver.js +18 -0
  33. package/dist/skill-shape.js +189 -0
  34. package/dist/skills.js +129 -0
  35. package/dist/transport.js +205 -0
  36. package/dist/ts-deps-loader.js +113 -0
  37. package/dist/watch/consumer.js +141 -0
  38. package/dist/watch/cursor-file.js +62 -0
  39. package/dist/watch.js +175 -0
  40. package/dist/write.js +224 -0
  41. package/package.json +60 -0
  42. package/skills/catalyst-github/SKILL.md +35 -0
  43. package/skills/catalyst-github/agents/openai.yaml +6 -0
  44. package/skills/catalyst-github/agents/portability.yaml +4 -0
  45. package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
  46. package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
  47. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
  48. package/skills/catalyst-github/scripts/lib/cli.mjs +103 -0
  49. package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
  50. package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
  51. package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
  52. package/skills/catalyst-linear/SKILL.md +43 -0
  53. package/skills/catalyst-linear/agents/openai.yaml +6 -0
  54. package/skills/catalyst-linear/agents/portability.yaml +5 -0
  55. package/skills/catalyst-linear/references/reading-a-ticket.md +52 -0
  56. package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
  57. package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
  58. package/skills/catalyst-linear/scripts/comment.mjs +59 -0
  59. package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
  60. package/skills/catalyst-linear/scripts/label.mjs +48 -0
  61. package/skills/catalyst-linear/scripts/lib/cli.mjs +164 -0
  62. package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
  63. package/skills/catalyst-linear/scripts/move.mjs +41 -0
  64. package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
  65. package/skills/catalyst-linear/scripts/search.mjs +49 -0
  66. package/skills/catalyst-onboard/SKILL.md +57 -0
  67. package/skills/catalyst-onboard/agents/openai.yaml +6 -0
  68. package/skills/catalyst-onboard/agents/portability.yaml +5 -0
  69. package/skills/catalyst-onboard/references/declaring-a-repository.md +23 -0
  70. package/skills/catalyst-onboard/references/skill-sources.md +35 -0
  71. package/skills/catalyst-onboard/references/the-one-path.md +149 -0
  72. package/skills/catalyst-onboard/references/what-a-phase-needs.md +46 -0
  73. package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
  74. package/skills/catalyst-onboard/references/who-fixes-what.md +44 -0
  75. package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
  76. package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
  77. package/skills/catalyst-onboard/scripts/where-am-i.mjs +345 -0
  78. package/skills/catalyst-setup/SKILL.md +36 -0
  79. package/skills/catalyst-setup/agents/openai.yaml +6 -0
  80. package/skills/catalyst-setup/agents/portability.yaml +4 -0
  81. package/skills/catalyst-setup/references/what-each-check-means.md +88 -0
  82. package/skills/catalyst-setup/scripts/check.mjs +75 -0
  83. package/skills/catalyst-setup/scripts/lib/cli.mjs +103 -0
  84. package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
  85. package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
  86. package/skills/connect-me/SKILL.md +63 -0
  87. package/skills/connect-me/agents/openai.yaml +6 -0
  88. package/skills/connect-me/agents/portability.yaml +5 -0
  89. package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
  90. package/skills/connect-me/scripts/lib/cli.mjs +185 -0
  91. package/skills/connect-me/scripts/lib/credential.mjs +29 -0
  92. package/skills/connect-me/scripts/verify-connection.mjs +68 -0
  93. package/skills/how-catalyst-works/SKILL.md +43 -0
  94. package/skills/how-catalyst-works/agents/openai.yaml +6 -0
  95. package/skills/how-catalyst-works/agents/portability.yaml +4 -0
  96. package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
  97. package/skills/how-catalyst-works/references/stages-and-mapping.md +56 -0
  98. package/skills/how-catalyst-works/references/the-ladder.md +41 -0
  99. package/skills/how-catalyst-works/references/what-catalyst-is.md +30 -0
  100. package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
  101. package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
  102. package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
  103. package/skills/how-catalyst-works/scripts/lib/cli.mjs +164 -0
  104. package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
  105. package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
  106. package/skills/how-catalyst-works/scripts/whats-running.mjs +65 -0
  107. package/skills/run-this-project/SKILL.md +45 -0
  108. package/skills/run-this-project/agents/openai.yaml +6 -0
  109. package/skills/run-this-project/agents/portability.yaml +5 -0
  110. package/skills/run-this-project/assets/stall-policy.json +15 -0
  111. package/skills/run-this-project/references/making-work-ready.md +60 -0
  112. package/skills/run-this-project/references/reacting-to-events.md +76 -0
  113. package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
  114. package/skills/run-this-project/scripts/lib/cli.mjs +185 -0
  115. package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
  116. package/skills/run-this-project/scripts/make-ready.mjs +64 -0
  117. package/skills/run-this-project/scripts/scope-status.mjs +0 -0
  118. package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
  119. package/skills/unstick/SKILL.md +41 -0
  120. package/skills/unstick/agents/openai.yaml +6 -0
  121. package/skills/unstick/agents/portability.yaml +5 -0
  122. package/skills/unstick/references/playbook.md +51 -0
  123. package/skills/unstick/scripts/lib/cli.mjs +135 -0
  124. package/skills/unstick/scripts/lib/credential.mjs +29 -0
  125. package/skills/unstick/scripts/unstick.mjs +57 -0
  126. package/skills/what-needs-me/SKILL.md +41 -0
  127. package/skills/what-needs-me/agents/openai.yaml +6 -0
  128. package/skills/what-needs-me/agents/portability.yaml +5 -0
  129. package/skills/what-needs-me/references/raising-a-decision.md +41 -0
  130. package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
  131. package/skills/what-needs-me/references/settling-an-answer.md +37 -0
  132. package/skills/what-needs-me/scripts/inbox.mjs +56 -0
  133. package/skills/what-needs-me/scripts/lib/cli.mjs +135 -0
  134. package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
  135. package/skills/what-needs-me/scripts/raise.mjs +53 -0
  136. package/skills/what-needs-me/scripts/settle.mjs +73 -0
  137. package/skills/whats-happening/SKILL.md +43 -0
  138. package/skills/whats-happening/agents/openai.yaml +6 -0
  139. package/skills/whats-happening/agents/portability.yaml +4 -0
  140. package/skills/whats-happening/assets/status-reply.json +77 -0
  141. package/skills/whats-happening/references/reading-the-board.md +43 -0
  142. package/skills/whats-happening/references/reprioritising.md +37 -0
  143. package/skills/whats-happening/references/routing-work.md +36 -0
  144. package/skills/whats-happening/references/status-reply.md +34 -0
  145. package/skills/whats-happening/references/why-is-it-stuck.md +62 -0
  146. package/skills/whats-happening/scripts/explain.mjs +28 -0
  147. package/skills/whats-happening/scripts/lib/cli.mjs +135 -0
  148. package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
  149. package/skills/whats-happening/scripts/snapshot.mjs +149 -0
  150. package/vendor/README.md +9 -0
  151. package/vendor/paths/index.d.ts +85 -0
  152. package/vendor/paths/index.js +148 -0
  153. package/vendor/paths/legacy-installer.d.ts +36 -0
  154. package/vendor/paths/legacy-installer.js +154 -0
  155. package/vendor/paths/node.d.ts +18 -0
  156. package/vendor/paths/node.js +102 -0
  157. package/vendor/paths/provenance.json +17 -0
@@ -0,0 +1,141 @@
1
+ import { readCursorFile, writeCursorFile } from "./cursor-file.js";
2
+ export const LOG_PREFIX = "[live-events]";
3
+ function defaultWsFactory(url) {
4
+ const Ctor = globalThis.WebSocket;
5
+ if (!Ctor)
6
+ throw new Error("global WebSocket unavailable; pass wsFactory (Node >= 22 exposes one)");
7
+ return new Ctor(url);
8
+ }
9
+ export function printFrame(frame) {
10
+ console.log(`${LOG_PREFIX} frame ${JSON.stringify(frame)}`);
11
+ }
12
+ async function fetchHeadCursor(opts, signal) {
13
+ const url = `${opts.baseUrl.replace(/\/+$/, "")}/snapshot?head=1`;
14
+ const res = await (opts.fetchImpl ?? fetch)(url, { headers: { authorization: `Bearer ${await opts.getToken()}` }, signal });
15
+ if (!res.ok) {
16
+ const body = (await res.text()).slice(0, 200);
17
+ throw new Error(`head-only snapshot fetch failed: ${res.status} ${res.statusText} at ${url} — ${body}`);
18
+ }
19
+ const head = (await res.json());
20
+ if (head.accountId !== opts.accountId) {
21
+ throw new Error(`head-only snapshot is for account ${JSON.stringify(head.accountId)} but this session is for ${JSON.stringify(opts.accountId)} — the key and the configured account disagree`);
22
+ }
23
+ if (typeof head.cursor !== "number" || !Number.isInteger(head.cursor) || head.cursor < 0) {
24
+ throw new Error(`head-only snapshot returned a non-integer cursor: ${JSON.stringify(head.cursor)}`);
25
+ }
26
+ return head.cursor;
27
+ }
28
+ /**
29
+ * Build the events-only client. Construction reads the cursor file ONCE (throwing on a corrupt file
30
+ * or a cross-account reuse); the position is then kept in memory and persisted after every
31
+ * SUCCESSFUL reaction. React first, persist second: an async reaction is awaited before its cursor
32
+ * persists and later frames queue behind it in seq order; a failed reaction halts the stream and
33
+ * closes the socket so the SDK's reconnect replays from the last good cursor.
34
+ */
35
+ export function createLiveEventsClient(sdk, opts) {
36
+ let state = opts.fromHead ? null : readCursorFile(opts.cursorFile, opts.accountId);
37
+ const log = opts.log ??
38
+ ((level, msg, extra) => console[level === "error" ? "error" : "log"](`${LOG_PREFIX} ${msg}`, extra ?? ""));
39
+ const persist = (next) => {
40
+ state = next;
41
+ writeCursorFile(opts.cursorFile, next);
42
+ };
43
+ const reseedHead = async (signal) => {
44
+ const cursor = await fetchHeadCursor(opts, signal);
45
+ persist({ account: opts.accountId, cursor });
46
+ log("info", `cursor re-seeded to tenant head ${cursor} (head-only; no rows applied)`);
47
+ opts.onReseed?.(cursor);
48
+ return cursor;
49
+ };
50
+ const react = opts.onEvent ?? printFrame;
51
+ let socket = null;
52
+ let halted = false;
53
+ let inFlight = null;
54
+ const queued = [];
55
+ const advance = (frame) => {
56
+ if (state && state.cursor >= frame.seq)
57
+ return;
58
+ persist({ account: frame.accountId, cursor: frame.seq });
59
+ };
60
+ const fail = (frame, err) => {
61
+ halted = true;
62
+ queued.length = 0;
63
+ inFlight = null;
64
+ log("error", `reaction failed for ${frame.entity} seq=${frame.seq}; cursor left at ${state?.cursor ?? "none"} — reconnecting so the replay re-offers it`, err);
65
+ const ws = socket;
66
+ socket = null;
67
+ try {
68
+ ws?.close();
69
+ }
70
+ catch {
71
+ // already closing/closed
72
+ }
73
+ };
74
+ const settle = (frame) => {
75
+ advance(frame);
76
+ inFlight = null;
77
+ const next = queued.shift();
78
+ if (next)
79
+ run(next);
80
+ };
81
+ const run = (frame) => {
82
+ let result;
83
+ try {
84
+ result = react(frame);
85
+ }
86
+ catch (err) {
87
+ fail(frame, err);
88
+ return;
89
+ }
90
+ if (result && typeof result.then === "function") {
91
+ const p = result.then(() => {
92
+ if (inFlight === p)
93
+ settle(frame);
94
+ }, (err) => {
95
+ if (inFlight === p)
96
+ fail(frame, err);
97
+ });
98
+ inFlight = p;
99
+ return;
100
+ }
101
+ advance(frame);
102
+ };
103
+ const wrappedFactory = (url) => {
104
+ const ws = (opts.wsFactory ?? defaultWsFactory)(url);
105
+ socket = ws;
106
+ halted = false;
107
+ queued.length = 0;
108
+ inFlight = null;
109
+ return ws;
110
+ };
111
+ const client = new sdk.LiveSyncClient({
112
+ baseUrl: opts.baseUrl,
113
+ accountId: opts.accountId,
114
+ auth: opts.auth,
115
+ reseed: reseedHead,
116
+ getCursor: () => state?.cursor ?? null,
117
+ onChange: (frame) => {
118
+ if (frame.accountId !== opts.accountId) {
119
+ const msg = `refusing frame for account ${JSON.stringify(frame.accountId)} — this session is for ${JSON.stringify(opts.accountId)} (cursor left at ${state?.cursor ?? "none"})`;
120
+ log("error", msg);
121
+ throw new Error(msg);
122
+ }
123
+ if (halted)
124
+ return;
125
+ if (inFlight) {
126
+ queued.push(frame);
127
+ return;
128
+ }
129
+ run(frame);
130
+ },
131
+ onStatus: opts.onStatus ??
132
+ ((status) => {
133
+ log("info", `status=${status}`);
134
+ }),
135
+ backoffMs: opts.backoffMs,
136
+ maxBackoffMs: opts.maxBackoffMs,
137
+ wsFactory: wrappedFactory,
138
+ log,
139
+ });
140
+ return { client, cursor: () => state?.cursor ?? null, reseedHead };
141
+ }
@@ -0,0 +1,62 @@
1
+ // watch/cursor-file.ts — the durable cursor store for the events-only `watch` consumer, ported whole
2
+ // from the cloud's tested host-sync implementation. The file is the whole state:
3
+ //
4
+ // { "account": "<tenant id>", "cursor": 41 }
5
+ //
6
+ // The account field is load-bearing: a cursor is a position in ONE tenant's change log, so the file
7
+ // refuses to be read for a different account than the one stamped in it (a named throw naming both
8
+ // accounts). A torn or corrupt file also fails loudly; the only "no state yet" is the file's absence.
9
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { dirname } from "node:path";
11
+ export class CursorFileError extends Error {
12
+ path;
13
+ constructor(message, path) {
14
+ super(message);
15
+ this.path = path;
16
+ this.name = "CursorFileError";
17
+ }
18
+ }
19
+ function validateShape(parsed) {
20
+ if (typeof parsed !== "object" || parsed === null)
21
+ return { ok: false, error: "content is not a JSON object" };
22
+ const { account, cursor } = parsed;
23
+ if (typeof account !== "string" || account.trim() === "")
24
+ return { ok: false, error: "account is missing or not a non-empty string" };
25
+ if (typeof cursor !== "number" || !Number.isInteger(cursor) || cursor < 0)
26
+ return { ok: false, error: "cursor is missing or not a non-negative integer" };
27
+ return { ok: true, state: { account, cursor } };
28
+ }
29
+ /** Returns null ONLY when the file is absent. Throws CursorFileError on an unreadable, corrupt, or
30
+ * wrongly shaped file, and on a cross-account file when `expectAccount` is given. */
31
+ export function readCursorFile(path, expectAccount) {
32
+ if (!existsSync(path))
33
+ return null;
34
+ let raw;
35
+ try {
36
+ raw = readFileSync(path, "utf8");
37
+ }
38
+ catch (err) {
39
+ throw new CursorFileError(`cannot read cursor file: ${err instanceof Error ? err.message : String(err)}`, path);
40
+ }
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(raw);
44
+ }
45
+ catch {
46
+ throw new CursorFileError("cursor file is not valid JSON (torn write?) — delete it to re-seed from the tenant head, at the cost of replaying nothing in between", path);
47
+ }
48
+ const shape = validateShape(parsed);
49
+ if (!shape.ok)
50
+ throw new CursorFileError(`cursor file is malformed: ${shape.error}`, path);
51
+ if (expectAccount !== undefined && shape.state.account !== expectAccount) {
52
+ throw new CursorFileError(`cursor file belongs to account ${JSON.stringify(shape.state.account)} but this session is for ${JSON.stringify(expectAccount)} — a cursor is a position in ONE tenant's feed; move or delete the file rather than replaying one tenant's position against another`, path);
53
+ }
54
+ return shape.state;
55
+ }
56
+ /** Atomic: a sibling temp file, then rename over the target. */
57
+ export function writeCursorFile(path, state) {
58
+ mkdirSync(dirname(path), { recursive: true });
59
+ const tmp = `${path}.tmp`;
60
+ writeFileSync(tmp, `${JSON.stringify(state)}\n`, "utf8");
61
+ renameSync(tmp, path);
62
+ }
package/dist/watch.js ADDED
@@ -0,0 +1,175 @@
1
+ // watch.ts — `watch [--team K] [--ticket T]... [--project P] [--exec CMD] [--cursor-file] [--from]`:
2
+ // one JSON line per in-scope change frame, cursor advanced after the reaction, over the SDK's live
3
+ // stream. Scope is filtered on the client from the frame's entity and row; a project scope resolves
4
+ // an issue reference to its project through GET /api/v1/issues/:ref, cached in memory.
5
+ import { spawn } from "node:child_process";
6
+ import { flagList, flagString } from "./args.js";
7
+ import { apiBase, requireConfig, watchCursorPathFor } from "./config.js";
8
+ import { loadContract } from "./contract.js";
9
+ import { CliError, UsageError } from "./errors.js";
10
+ import { apiClient } from "./transport.js";
11
+ import { authStrategyFor, bearerFor } from "./oauth.js";
12
+ import { loadSdk } from "./sdk.js";
13
+ import { createLiveEventsClient } from "./watch/consumer.js";
14
+ import { CursorFileError } from "./watch/cursor-file.js";
15
+ const ROW_ISSUE_REFS = ["identifier", "linear_issue_identifier", "issue_identifier", "issue_id", "linear_issue_id"];
16
+ /** Decide whether one frame falls inside the scope. An empty scope admits everything. Rows that only
17
+ * carry an issue reference (comments, sessions, PRs) resolve it through `resolve` for project scope. */
18
+ export async function inScope(frame, scope, resolve) {
19
+ const hasScope = Boolean(scope.teamId || scope.teamKey || (scope.tickets && scope.tickets.length) || scope.projectId);
20
+ if (!hasScope)
21
+ return true;
22
+ const row = frame.row ?? {};
23
+ const refs = ROW_ISSUE_REFS.map((k) => row[k]).filter((v) => typeof v === "string" && v !== "");
24
+ const upperTickets = (scope.tickets ?? []).map((t) => t.toUpperCase());
25
+ if (upperTickets.length > 0) {
26
+ if (refs.some((r) => upperTickets.includes(r.toUpperCase())))
27
+ return true;
28
+ for (const r of refs) {
29
+ const info = await resolve(r);
30
+ if (info?.identifier && upperTickets.includes(info.identifier.toUpperCase()))
31
+ return true;
32
+ }
33
+ }
34
+ if (scope.projectId) {
35
+ if (row.project_id === scope.projectId || row.id === scope.projectId)
36
+ return true;
37
+ for (const r of refs) {
38
+ const info = await resolve(r);
39
+ if (info?.projectId === scope.projectId)
40
+ return true;
41
+ }
42
+ }
43
+ if (scope.teamId || scope.teamKey) {
44
+ if (scope.teamId && (row.team_id === scope.teamId || row.teamId === scope.teamId))
45
+ return true;
46
+ if (scope.teamKey && refs.some((r) => /^[A-Za-z]+-\d+$/.test(r) && r.toUpperCase().startsWith(`${scope.teamKey.toUpperCase()}-`)))
47
+ return true;
48
+ for (const r of refs) {
49
+ const info = await resolve(r);
50
+ if (info && scope.teamId && info.teamId === scope.teamId)
51
+ return true;
52
+ if (info?.identifier && scope.teamKey && info.identifier.toUpperCase().startsWith(`${scope.teamKey.toUpperCase()}-`))
53
+ return true;
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+ /** Resolve issue references through the API, cached per process. */
59
+ export function apiIssueResolver(cfg, ctx) {
60
+ const api = apiClient(cfg, ctx);
61
+ const cache = new Map();
62
+ return async (ref) => {
63
+ if (cache.has(ref))
64
+ return cache.get(ref);
65
+ let out = null;
66
+ try {
67
+ const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(ref)}`, { accept: [404] });
68
+ if (res.status !== 404 && res.body) {
69
+ out = {
70
+ id: typeof res.body.id === "string" ? res.body.id : null,
71
+ identifier: typeof res.body.identifier === "string" ? res.body.identifier : null,
72
+ projectId: typeof res.body.project_id === "string" ? res.body.project_id : null,
73
+ teamId: typeof res.body.team_id === "string" ? res.body.team_id : null,
74
+ };
75
+ }
76
+ }
77
+ catch {
78
+ out = null;
79
+ }
80
+ cache.set(ref, out);
81
+ return out;
82
+ };
83
+ }
84
+ /**
85
+ * Run `--exec CMD` once with the frame on stdin; a non-zero exit is a failed reaction.
86
+ *
87
+ * The child's EXIT CODE is the whole contract, and a broken pipe is not a failure on its own. A
88
+ * handler that exits before reading the frame is ordinary and often correct — `grep -q`, `head -1`,
89
+ * a script that fails fast — and so is one that simply died. In every one of those cases the write
90
+ * lands on a pipe with nothing at the other end and raises EPIPE asynchronously. Node delivers that
91
+ * as an `error` event on the child's stdin, and a stream with no `error` listener rethrows it as an
92
+ * unhandled error, which takes down the whole watch: one misbehaving handler and the customer stops
93
+ * receiving their tenant's events. So the pipe is allowed to break, and the exit code still decides.
94
+ */
95
+ export function execReaction(cmd, env) {
96
+ return (frame) => new Promise((resolve, reject) => {
97
+ const child = spawn("sh", ["-c", cmd], { stdio: ["pipe", "inherit", "inherit"], env });
98
+ child.on("error", reject);
99
+ child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`--exec exited ${code}`))));
100
+ // Listen before writing: attaching afterwards races the synchronous part of `end()`.
101
+ child.stdin.on("error", () => { });
102
+ try {
103
+ if (child.stdin.writable)
104
+ child.stdin.end(`${JSON.stringify(frame)}\n`);
105
+ }
106
+ catch {
107
+ // A stdin already destroyed throws here rather than emitting; same verdict, wait for close.
108
+ }
109
+ });
110
+ }
111
+ export async function runWatch(ctx, cfg, opts, deps = {}) {
112
+ const sdk = deps.sdk ?? (await loadSdk());
113
+ const resolve = deps.resolver ?? apiIssueResolver(cfg, ctx);
114
+ const react = opts.exec ? execReaction(opts.exec, ctx.env) : undefined;
115
+ let handle;
116
+ try {
117
+ handle = buildHandle();
118
+ }
119
+ catch (err) {
120
+ if (err instanceof CursorFileError)
121
+ throw new CliError(`${err.message} (${err.path})`, "cursor-file");
122
+ throw err;
123
+ }
124
+ deps.onHandle?.(handle);
125
+ const stopped = deps.waitForStop ??
126
+ (() => new Promise((done) => {
127
+ process.once("SIGINT", () => done());
128
+ process.once("SIGTERM", () => done());
129
+ }));
130
+ const running = handle.client.start();
131
+ await stopped();
132
+ handle.client.stop();
133
+ await running;
134
+ return 0;
135
+ function buildHandle() {
136
+ return createLiveEventsClient(sdk, {
137
+ baseUrl: apiBase(cfg),
138
+ accountId: cfg.account,
139
+ auth: authStrategyFor(ctx, cfg),
140
+ getToken: () => bearerFor(ctx, cfg),
141
+ cursorFile: opts.cursorFile ?? watchCursorPathFor(ctx.home),
142
+ fromHead: opts.fromHead,
143
+ fetchImpl: ctx.fetch,
144
+ wsFactory: deps.wsFactory,
145
+ backoffMs: deps.backoffMs,
146
+ maxBackoffMs: deps.maxBackoffMs,
147
+ log: (level, msg) => ctx.stderr(`[watch ${level}] ${msg}`),
148
+ onStatus: (s) => ctx.stderr(`[watch] status=${s}`),
149
+ onReseed: (cursor) => ctx.stderr(`[watch] resync: cursor moved to head ${cursor}`),
150
+ onEvent: async (frame) => {
151
+ if (!(await inScope(frame, opts.scope, resolve)))
152
+ return;
153
+ ctx.stdout(JSON.stringify(frame));
154
+ if (react)
155
+ await react(frame);
156
+ },
157
+ });
158
+ }
159
+ }
160
+ export async function cmdWatch(args, ctx, deps = {}) {
161
+ const cfg = requireConfig(ctx);
162
+ const from = flagString(args, "from") ?? "cursor";
163
+ if (from !== "cursor" && from !== "head")
164
+ throw new UsageError("--from must be cursor or head");
165
+ const scope = { tickets: flagList(args, "ticket"), projectId: flagString(args, "project") };
166
+ const teamKey = flagString(args, "team");
167
+ if (teamKey) {
168
+ const { doc } = await loadContract(ctx, cfg);
169
+ const team = doc.teams.find((t) => (t.key ?? "").toUpperCase() === teamKey.toUpperCase());
170
+ scope.teamKey = teamKey;
171
+ if (team)
172
+ scope.teamId = team.id;
173
+ }
174
+ return runWatch(ctx, cfg, { scope, exec: flagString(args, "exec"), cursorFile: flagString(args, "cursor-file"), fromHead: from === "head" }, deps);
175
+ }
package/dist/write.js ADDED
@@ -0,0 +1,224 @@
1
+ // write.ts — writes to Linear as the app actor through the agent proxy. Every route path comes from
2
+ // the contract's route table (never a literal); every stage id from `teams[].stages` by slot; every
3
+ // label id from `teams[].labels`; the bookkeeping marker from `vocabulary`. `delegate` is never offered.
4
+ import { readFileSync } from "node:fs";
5
+ import { flagBool, flagInt, flagList, flagString, positionals } from "./args.js";
6
+ import { requireConfig } from "./config.js";
7
+ import { bookkeepingPrefix, labelId, loadContract, routePath, stageIdForSlot, teamByKey, teamForTicket } from "./contract.js";
8
+ import { CliError, UsageError } from "./errors.js";
9
+ import { apiClient } from "./transport.js";
10
+ /** A ticket identifier → its Linear id (and team/project) through GET /api/v1/issues/:identifier. */
11
+ export async function resolveIssue(api, ticket) {
12
+ const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(ticket)}`, { accept: [404] });
13
+ if (res.status === 404 || !res.body || typeof res.body.id !== "string") {
14
+ throw new CliError(`ticket ${ticket} is not in the mirror — check the identifier`, "ticket-unknown");
15
+ }
16
+ return {
17
+ id: res.body.id,
18
+ identifier: typeof res.body.identifier === "string" ? res.body.identifier : ticket,
19
+ teamId: typeof res.body.team_id === "string" ? res.body.team_id : null,
20
+ projectId: typeof res.body.project_id === "string" ? res.body.project_id : null,
21
+ };
22
+ }
23
+ /** GET /api/v1/workflow-stages, read tolerantly: `{teams:[{teamId|id|key, states|stages:[...]}]}`,
24
+ * `{stages:[...]}` or a bare array of states each carrying `team_id|teamId`. */
25
+ export async function fetchWorkflowStates(api) {
26
+ const res = await api.getJson("/api/v1/workflow-stages");
27
+ const out = [];
28
+ const push = (s, teamId) => {
29
+ if (typeof s.id !== "string")
30
+ return;
31
+ out.push({
32
+ id: s.id,
33
+ name: String(s.name ?? ""),
34
+ type: String(s.type ?? ""),
35
+ teamId: (typeof s.team_id === "string" ? s.team_id : typeof s.teamId === "string" ? s.teamId : teamId) ?? null,
36
+ });
37
+ };
38
+ const body = res.body;
39
+ if (Array.isArray(body)) {
40
+ for (const s of body)
41
+ push(s, null);
42
+ }
43
+ else if (body && typeof body === "object") {
44
+ const teams = body.teams;
45
+ if (Array.isArray(teams)) {
46
+ for (const t of teams) {
47
+ const teamId = typeof t.teamId === "string" ? t.teamId : typeof t.id === "string" ? t.id : typeof t.team_id === "string" ? t.team_id : null;
48
+ const states = (t.states ?? t.stages);
49
+ if (Array.isArray(states))
50
+ for (const s of states)
51
+ push(s, teamId);
52
+ }
53
+ }
54
+ const stages = body.stages ?? body.states;
55
+ if (Array.isArray(stages))
56
+ for (const s of stages)
57
+ push(s, null);
58
+ }
59
+ return out;
60
+ }
61
+ /** The first state of `type` on `teamId`, in the order the route lists them. */
62
+ export function firstStateOfType(states, teamId, type) {
63
+ const hit = states.find((s) => s.teamId === teamId && s.type.toLowerCase() === type.toLowerCase());
64
+ if (!hit) {
65
+ const seen = [...new Set(states.filter((s) => s.teamId === teamId).map((s) => s.type))].join(", ") || "none";
66
+ throw new CliError(`team ${teamId} has no state of type "${type}" (types seen: ${seen})`, "state-type-unknown");
67
+ }
68
+ return hit;
69
+ }
70
+ /** Post to a contract route by name; a budget refusal names the contract's daily budget. */
71
+ export async function postAgent(api, doc, routeName, body) {
72
+ try {
73
+ const res = await api.postJson(routePath(doc, routeName), body);
74
+ return res.body;
75
+ }
76
+ catch (err) {
77
+ if (err instanceof CliError && err.code === "budget") {
78
+ throw new CliError(`${err.message} — the contract's thresholds.hostDailyWriteBudget is ${doc.thresholds.hostDailyWriteBudget} writes per host per day`, "budget", 2, 429);
79
+ }
80
+ throw err;
81
+ }
82
+ }
83
+ export async function cmdWrite(args, ctx, deps = {}) {
84
+ const [sub, ...rest] = positionals(args);
85
+ if (!sub)
86
+ throw new UsageError("write needs a subcommand: comment | state | label | create | reaction | attachment | session");
87
+ const cfg = requireConfig(ctx);
88
+ const { doc } = await loadContract(ctx, cfg);
89
+ const api = apiClient(cfg, ctx);
90
+ const asUser = flagBool(args, "as-user");
91
+ let result;
92
+ switch (sub) {
93
+ case "comment": {
94
+ const ticket = needTicket(rest, "comment <ticket>");
95
+ let body = flagString(args, "body");
96
+ if (flagBool(args, "stdin"))
97
+ body = (await (deps.readStdin ?? readStdin)()).trimEnd();
98
+ if (!body)
99
+ throw new UsageError("write comment needs --body <text> or --stdin");
100
+ if (flagBool(args, "bookkeeping"))
101
+ body = `${bookkeepingPrefix(doc)} ${body}`;
102
+ const issue = await resolveIssue(api, ticket);
103
+ result = await postAgent(api, doc, "issue-comment", {
104
+ issueId: issue.id,
105
+ body,
106
+ ...(flagString(args, "parent") ? { parentId: flagString(args, "parent") } : {}),
107
+ ...(asUser ? { createAsUser: true } : {}),
108
+ });
109
+ break;
110
+ }
111
+ case "state": {
112
+ const ticket = needTicket(rest, "state <ticket>");
113
+ const issue = await resolveIssue(api, ticket);
114
+ const slot = flagString(args, "slot");
115
+ const stateId = flagString(args, "state-id");
116
+ const stateType = flagString(args, "state-type");
117
+ let target;
118
+ if (stateId)
119
+ target = stateId;
120
+ else if (slot)
121
+ target = stageIdForSlot(teamForTicket(doc, issue.identifier), slot);
122
+ else if (stateType) {
123
+ const team = teamForTicket(doc, issue.identifier);
124
+ target = firstStateOfType(await fetchWorkflowStates(api), issue.teamId ?? team.id, stateType).id;
125
+ }
126
+ else
127
+ throw new UsageError("write state needs --slot <slot>, --state-id <id> or --state-type <type>");
128
+ result = await postAgent(api, doc, "issue-state", { issueId: issue.id, stateId: target });
129
+ break;
130
+ }
131
+ case "label": {
132
+ const ticket = needTicket(rest, "label <ticket>");
133
+ const add = flagList(args, "add");
134
+ const remove = flagList(args, "remove");
135
+ if (add.length === 0 && remove.length === 0)
136
+ throw new UsageError("write label needs --add <name|id> and/or --remove <name|id>");
137
+ const issue = await resolveIssue(api, ticket);
138
+ const team = teamForTicket(doc, issue.identifier);
139
+ const results = [];
140
+ if (add.length)
141
+ results.push(await postAgent(api, doc, "issue-label", { issueId: issue.id, labelIds: add.map((n) => labelId(team, n)), mode: "add" }));
142
+ if (remove.length)
143
+ results.push(await postAgent(api, doc, "issue-label", { issueId: issue.id, labelIds: remove.map((n) => labelId(team, n)), mode: "remove" }));
144
+ result = results.length === 1 ? results[0] : results;
145
+ break;
146
+ }
147
+ case "create": {
148
+ const teamKey = flagString(args, "team");
149
+ const title = flagString(args, "title");
150
+ if (!teamKey || !title)
151
+ throw new UsageError("write create needs --team <key> and --title <text>");
152
+ const team = teamByKey(doc, teamKey);
153
+ const labels = flagList(args, "label").map((n) => labelId(team, n));
154
+ const priority = flagString(args, "priority") !== undefined ? flagInt(args, "priority", 0) : undefined;
155
+ result = await postAgent(api, doc, "issue-create", {
156
+ teamId: team.id,
157
+ title,
158
+ ...(labels.length ? { labelIds: labels } : {}),
159
+ ...(priority !== undefined ? { priority } : {}),
160
+ ...(asUser ? { createAsUser: true } : {}),
161
+ });
162
+ break;
163
+ }
164
+ case "reaction": {
165
+ const emoji = flagString(args, "emoji");
166
+ if (!emoji)
167
+ throw new UsageError("write reaction needs --emoji <e>");
168
+ const commentId = flagString(args, "comment");
169
+ const body = { emoji, ...(asUser ? { createAsUser: true } : {}) };
170
+ if (commentId)
171
+ body.commentId = commentId;
172
+ else
173
+ body.issueId = (await resolveIssue(api, needTicket(rest, "reaction <ticket>|--comment <id>"))).id;
174
+ result = await postAgent(api, doc, "reaction", body);
175
+ break;
176
+ }
177
+ case "attachment": {
178
+ const ticket = needTicket(rest, "attachment <ticket>");
179
+ const title = flagString(args, "title");
180
+ const url = flagString(args, "url");
181
+ if (!title || !url)
182
+ throw new UsageError("write attachment needs --title <t> and --url <u>");
183
+ const issue = await resolveIssue(api, ticket);
184
+ result = await postAgent(api, doc, "attachment", { issueId: issue.id, title, url });
185
+ break;
186
+ }
187
+ case "session": {
188
+ const ticket = needTicket(rest, "session <ticket>");
189
+ const issue = await resolveIssue(api, ticket);
190
+ const planFile = flagString(args, "plan-file");
191
+ const plan = planFile ? JSON.parse(readFileSync(planFile, "utf8")) : undefined;
192
+ result = await postAgent(api, doc, "session", {
193
+ issueId: issue.id,
194
+ ...(flagString(args, "title") ? { title: flagString(args, "title") } : {}),
195
+ ...(plan !== undefined ? { plan } : {}),
196
+ ...(flagString(args, "activity") ? { activity: flagString(args, "activity") } : {}),
197
+ ...(flagString(args, "url") ? { url: flagString(args, "url") } : {}),
198
+ });
199
+ break;
200
+ }
201
+ case "delegate":
202
+ throw new UsageError("write delegate is not offered: delegation is an operator action, not a tenant write");
203
+ default:
204
+ throw new UsageError(`unknown write subcommand: ${sub}`);
205
+ }
206
+ ctx.stdout(args.json ? JSON.stringify(result ?? {}) : describeWrite(sub, result));
207
+ return 0;
208
+ }
209
+ function needTicket(rest, usage) {
210
+ const t = rest[0];
211
+ if (!t)
212
+ throw new UsageError(`write ${usage}`);
213
+ return t;
214
+ }
215
+ function describeWrite(sub, result) {
216
+ const id = result && typeof result === "object" ? result.id ?? result.commentId ?? result.issueId : undefined;
217
+ return `${sub}: ok${id ? ` (${String(id)})` : ""}`;
218
+ }
219
+ async function readStdin() {
220
+ const chunks = [];
221
+ for await (const chunk of process.stdin)
222
+ chunks.push(Buffer.from(chunk));
223
+ return Buffer.concat(chunks).toString("utf8");
224
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@catalyst-cloud/cli",
3
+ "version": "0.8.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/coalesce-labs/catalyst-cloud-skills.git"
13
+ },
14
+ "homepage": "https://github.com/coalesce-labs/catalyst-cloud-skills#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/coalesce-labs/catalyst-cloud-skills/issues"
17
+ },
18
+ "engines": {
19
+ "node": ">=22.15"
20
+ },
21
+ "bin": {
22
+ "catalyst": "bin/catalyst.js",
23
+ "catalyst-skills": "bin/catalyst-skills.js"
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "dist",
28
+ "skills",
29
+ "README.md",
30
+ "CHANGELOG.md",
31
+ "LICENSE",
32
+ "vendor"
33
+ ],
34
+ "catalystCloud": {
35
+ "tenantContractRange": "1.x",
36
+ "pinnedNode": "24.21.0"
37
+ },
38
+ "scripts": {
39
+ "typecheck": "tsc --noEmit",
40
+ "build": "tsc -p tsconfig.build.json",
41
+ "prepare": "tsc -p tsconfig.build.json",
42
+ "test": "vitest run",
43
+ "test:coverage": "vitest run --coverage",
44
+ "version:sync": "node scripts/sync-plugin-version.mjs",
45
+ "skill-lib:sync": "node scripts/sync-skill-lib.mjs",
46
+ "skills:scan": "node scripts/scan-skills.mjs",
47
+ "evals": "node scripts/run-evals.mjs",
48
+ "paths:check": "node scripts/vendor-paths.mjs --check"
49
+ },
50
+ "dependencies": {
51
+ "@catalyst-cloud/sdk": "^0.10.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^26.3.0",
55
+ "@vitest/coverage-v8": "^4.1.11",
56
+ "typescript": "^7.0.2",
57
+ "vitest": "^4.1.11",
58
+ "yaml": "^2.9.0"
59
+ }
60
+ }