@catalyst-cloud/cli 0.9.5 → 0.13.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 (147) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +28 -29
  3. package/dist/args.js +19 -1
  4. package/dist/ask.js +30 -18
  5. package/dist/capabilities.js +5 -0
  6. package/dist/cli.js +24 -6
  7. package/dist/env/declaration-rules.js +163 -0
  8. package/dist/env/group.js +51 -0
  9. package/dist/env/inventory.js +102 -0
  10. package/dist/env/render.js +39 -0
  11. package/dist/env/scan-env-example.js +62 -0
  12. package/dist/env/scan-source.js +43 -0
  13. package/dist/env/scan-workflow.js +123 -0
  14. package/dist/env/scan-wrangler.js +120 -0
  15. package/dist/env/types.js +1 -0
  16. package/dist/env/walk.js +87 -0
  17. package/dist/env.js +76 -0
  18. package/dist/environment.js +1 -1
  19. package/dist/legacy.js +224 -0
  20. package/dist/project.js +129 -0
  21. package/dist/release.js +1 -1
  22. package/dist/repo.js +297 -0
  23. package/dist/runtime-verb.js +1 -1
  24. package/dist/secret.js +1 -1
  25. package/dist/skill-shape.js +21 -13
  26. package/dist/skills.js +44 -4
  27. package/dist/transport.js +26 -1
  28. package/package.json +7 -6
  29. package/skills/catalyst-github/SKILL.md +7 -7
  30. package/skills/catalyst-github/references/is-it-mergeable.md +8 -2
  31. package/skills/catalyst-github/references/what-a-pr-accumulates.md +3 -3
  32. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +3 -3
  33. package/skills/catalyst-github/scripts/lib/cli.mjs +9 -7
  34. package/skills/catalyst-github/scripts/lib/credential.mjs +2 -2
  35. package/skills/catalyst-github/scripts/read-pr.mjs +2 -2
  36. package/skills/catalyst-linear/SKILL.md +17 -19
  37. package/skills/catalyst-linear/agents/openai.yaml +1 -1
  38. package/skills/catalyst-linear/agents/portability.yaml +1 -1
  39. package/skills/catalyst-linear/references/reading-a-ticket.md +9 -26
  40. package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +2 -2
  41. package/skills/catalyst-linear/references/writing-to-linear.md +7 -9
  42. package/skills/catalyst-linear/scripts/comment.mjs +1 -1
  43. package/skills/catalyst-linear/scripts/create-ticket.mjs +1 -1
  44. package/skills/catalyst-linear/scripts/label.mjs +1 -1
  45. package/skills/catalyst-linear/scripts/lib/cli.mjs +9 -7
  46. package/skills/catalyst-linear/scripts/lib/credential.mjs +2 -2
  47. package/skills/catalyst-linear/scripts/move.mjs +1 -1
  48. package/skills/catalyst-linear/scripts/read-ticket.mjs +1 -1
  49. package/skills/catalyst-linear/scripts/search.mjs +1 -1
  50. package/skills/catalyst-onboard/SKILL.md +37 -57
  51. package/skills/catalyst-onboard/agents/openai.yaml +2 -2
  52. package/skills/catalyst-onboard/agents/portability.yaml +1 -1
  53. package/skills/catalyst-onboard/references/connecting-this-machine.md +35 -0
  54. package/skills/catalyst-onboard/references/declaring-a-repository.md +7 -17
  55. package/skills/catalyst-onboard/references/local-sync.md +19 -7
  56. package/skills/catalyst-onboard/references/reading-ready.md +79 -0
  57. package/skills/catalyst-onboard/references/replacing-a-credential.md +8 -33
  58. package/skills/catalyst-onboard/references/repository-agent-setup.md +23 -0
  59. package/skills/catalyst-onboard/references/skill-sources.md +13 -19
  60. package/skills/catalyst-onboard/references/the-one-path.md +18 -74
  61. package/skills/catalyst-onboard/references/what-a-phase-needs.md +21 -28
  62. package/skills/catalyst-onboard/references/what-the-browser-owns.md +20 -37
  63. package/skills/{catalyst-setup → catalyst-onboard}/scripts/check.mjs +10 -9
  64. package/skills/catalyst-onboard/scripts/lib/cli.mjs +6 -4
  65. package/skills/catalyst-onboard/scripts/lib/credential.mjs +2 -2
  66. package/skills/catalyst-onboard/scripts/where-am-i.mjs +60 -4
  67. package/skills/run-this-project/SKILL.md +23 -25
  68. package/skills/run-this-project/agents/openai.yaml +1 -1
  69. package/skills/run-this-project/agents/portability.yaml +1 -1
  70. package/skills/run-this-project/references/making-work-ready.md +20 -37
  71. package/skills/run-this-project/references/reacting-to-events.md +25 -48
  72. package/skills/run-this-project/references/stalls-and-escalation.md +20 -42
  73. package/skills/run-this-project/scripts/lib/cli.mjs +7 -5
  74. package/skills/run-this-project/scripts/lib/credential.mjs +2 -2
  75. package/skills/run-this-project/scripts/scope-status.mjs +0 -0
  76. package/skills/run-this-project/scripts/watch-scope.mjs +1 -1
  77. package/skills/unstick/SKILL.md +12 -12
  78. package/skills/unstick/agents/openai.yaml +1 -1
  79. package/skills/unstick/agents/portability.yaml +1 -1
  80. package/skills/unstick/references/playbook.md +13 -13
  81. package/skills/unstick/scripts/lib/cli.mjs +6 -4
  82. package/skills/unstick/scripts/lib/credential.mjs +2 -2
  83. package/skills/unstick/scripts/unstick.mjs +1 -1
  84. package/skills/what-needs-me/SKILL.md +12 -14
  85. package/skills/what-needs-me/agents/openai.yaml +2 -2
  86. package/skills/what-needs-me/agents/portability.yaml +1 -1
  87. package/skills/what-needs-me/references/raising-a-decision.md +8 -16
  88. package/skills/what-needs-me/references/reading-the-inbox.md +9 -12
  89. package/skills/what-needs-me/references/settling-an-answer.md +6 -12
  90. package/skills/what-needs-me/scripts/inbox.mjs +14 -13
  91. package/skills/what-needs-me/scripts/lib/cli.mjs +6 -4
  92. package/skills/what-needs-me/scripts/lib/credential.mjs +2 -2
  93. package/skills/what-needs-me/scripts/raise.mjs +1 -1
  94. package/skills/what-needs-me/scripts/settle.mjs +1 -1
  95. package/skills/what-this-repo-needs/SKILL.md +31 -0
  96. package/skills/what-this-repo-needs/agents/openai.yaml +6 -0
  97. package/skills/what-this-repo-needs/agents/portability.yaml +4 -0
  98. package/skills/what-this-repo-needs/references/what-each-group-means.md +38 -0
  99. package/skills/what-this-repo-needs/scripts/check.mjs +64 -0
  100. package/skills/what-this-repo-needs/scripts/inventory.mjs +61 -0
  101. package/skills/{catalyst-setup → what-this-repo-needs}/scripts/lib/cli.mjs +35 -22
  102. package/skills/{catalyst-setup → what-this-repo-needs}/scripts/lib/credential.mjs +2 -2
  103. package/skills/whats-happening/SKILL.md +27 -21
  104. package/skills/whats-happening/agents/openai.yaml +1 -1
  105. package/skills/whats-happening/references/coding-accounts.md +24 -0
  106. package/skills/whats-happening/references/routing-work.md +11 -29
  107. package/skills/whats-happening/references/settings-and-where-they-live.md +20 -0
  108. package/skills/whats-happening/references/stages-and-mapping.md +25 -0
  109. package/skills/whats-happening/references/status-reply.md +21 -21
  110. package/skills/whats-happening/references/the-ladder.md +30 -0
  111. package/skills/whats-happening/references/what-runs-next.md +32 -0
  112. package/skills/whats-happening/references/when-a-phase-fails.md +27 -0
  113. package/skills/whats-happening/references/why-is-it-stuck.md +33 -40
  114. package/skills/whats-happening/scripts/explain.mjs +1 -1
  115. package/skills/whats-happening/scripts/lib/cli.mjs +7 -5
  116. package/skills/whats-happening/scripts/lib/credential.mjs +2 -2
  117. package/skills/{how-catalyst-works → whats-happening}/scripts/show-my-map.mjs +18 -32
  118. package/skills/whats-happening/scripts/snapshot.mjs +10 -6
  119. package/skills/catalyst-onboard/references/choosing-a-coding-account.md +0 -52
  120. package/skills/catalyst-onboard/references/who-fixes-what.md +0 -64
  121. package/skills/catalyst-setup/SKILL.md +0 -36
  122. package/skills/catalyst-setup/agents/openai.yaml +0 -6
  123. package/skills/catalyst-setup/agents/portability.yaml +0 -4
  124. package/skills/catalyst-setup/references/what-each-check-means.md +0 -88
  125. package/skills/catalyst-setup/scripts/replica-status.mjs +0 -46
  126. package/skills/connect-me/SKILL.md +0 -63
  127. package/skills/connect-me/agents/openai.yaml +0 -6
  128. package/skills/connect-me/agents/portability.yaml +0 -5
  129. package/skills/connect-me/references/keeping-the-replica-running.md +0 -88
  130. package/skills/connect-me/scripts/lib/cli.mjs +0 -185
  131. package/skills/connect-me/scripts/lib/credential.mjs +0 -29
  132. package/skills/connect-me/scripts/verify-connection.mjs +0 -68
  133. package/skills/how-catalyst-works/SKILL.md +0 -43
  134. package/skills/how-catalyst-works/agents/openai.yaml +0 -6
  135. package/skills/how-catalyst-works/agents/portability.yaml +0 -4
  136. package/skills/how-catalyst-works/references/coding-accounts.md +0 -51
  137. package/skills/how-catalyst-works/references/stages-and-mapping.md +0 -56
  138. package/skills/how-catalyst-works/references/the-ladder.md +0 -41
  139. package/skills/how-catalyst-works/references/what-catalyst-is.md +0 -30
  140. package/skills/how-catalyst-works/references/what-runs-next.md +0 -88
  141. package/skills/how-catalyst-works/references/when-a-phase-fails.md +0 -57
  142. package/skills/how-catalyst-works/scripts/explain-ticket.mjs +0 -41
  143. package/skills/how-catalyst-works/scripts/lib/cli.mjs +0 -164
  144. package/skills/how-catalyst-works/scripts/lib/credential.mjs +0 -29
  145. package/skills/how-catalyst-works/scripts/whats-running.mjs +0 -65
  146. package/skills/whats-happening/references/reading-the-board.md +0 -43
  147. package/skills/whats-happening/references/reprioritising.md +0 -37
@@ -0,0 +1,120 @@
1
+ const BINDING_KEY_BY_TABLE = {
2
+ kv_namespaces: "binding",
3
+ d1_databases: "binding",
4
+ r2_buckets: "binding",
5
+ "durable_objects.bindings": "name",
6
+ "queues.producers": "binding",
7
+ "queues.consumers": "binding",
8
+ };
9
+ // ⛔ TABLE_RE AND QUOTED_RE ARE END-ANCHORED, SO AN INLINE `#` COMMENT MUST BE STRIPPED FIRST
10
+ // (validate attempt 29, CR-1 / CR-4). `[vars] # public settings` never matched TABLE_RE, so
11
+ // `currentTable` stayed null and the scanner returned NOTHING for the whole file — zero bindings,
12
+ // zero vars, zero notes: a silent, total failure in a tool whose entire value is completeness. And
13
+ // `binding = "SESSIONS" # the session store` never matched QUOTED_RE, so the `: value` fallback
14
+ // below made the raw remainder — quotes, comment and all — the binding NAME the customer is asked to
15
+ // keep, drop or move. `stripInlineComment` is quote-aware, so a `#` inside a value (`id = "ab#cd"`)
16
+ // is still value material and is left alone.
17
+ const QUOTED_RE = /^"(.*)"$|^'(.*)'$/;
18
+ const KEY_VALUE_RE = /^([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.*)$/;
19
+ const TABLE_RE = /^\[\[?([^\]]+)\]\]?$/;
20
+ const ENV_TABLE_RE = /^env\.([^.]+)\.(.+)$/;
21
+ export function scanWrangler(file, text) {
22
+ const sightings = [];
23
+ const lines = text.split("\n");
24
+ let currentTable = null;
25
+ let currentEnvironment;
26
+ // Tracks an open triple-quoted string, so a line INSIDE one (e.g. `a = not_a_name`) is never
27
+ // mined for a key — the key that opened the string (e.g. `MULTI = """`) already was.
28
+ let insideMultiline = null;
29
+ for (let i = 0; i < lines.length; i++) {
30
+ const trimmed = lines[i].trim();
31
+ if (insideMultiline) {
32
+ if (trimmed.includes(insideMultiline))
33
+ insideMultiline = null;
34
+ continue;
35
+ }
36
+ if (trimmed === "" || trimmed.startsWith("#"))
37
+ continue;
38
+ const uncommented = stripInlineComment(trimmed).trim();
39
+ if (uncommented === "")
40
+ continue;
41
+ const tableMatch = TABLE_RE.exec(uncommented);
42
+ if (tableMatch) {
43
+ let name = tableMatch[1].trim();
44
+ currentEnvironment = undefined;
45
+ const envMatch = ENV_TABLE_RE.exec(name);
46
+ if (envMatch) {
47
+ currentEnvironment = envMatch[1];
48
+ name = envMatch[2];
49
+ }
50
+ currentTable = name;
51
+ continue;
52
+ }
53
+ if (currentTable === null)
54
+ continue;
55
+ const kv = KEY_VALUE_RE.exec(uncommented);
56
+ if (!kv)
57
+ continue;
58
+ const key = kv[1];
59
+ const value = kv[2].trim();
60
+ if (isOpeningMultiline(value, '"""'))
61
+ insideMultiline = '"""';
62
+ else if (isOpeningMultiline(value, "'''"))
63
+ insideMultiline = "'''";
64
+ if (currentTable === "vars" || currentTable.endsWith(".vars")) {
65
+ sightings.push({
66
+ name: key,
67
+ finding: { file, line: i + 1, consumer: "wrangler.toml vars", consumerKind: "vars" },
68
+ isWranglerVar: true,
69
+ wranglerEnvironment: currentEnvironment,
70
+ localSource: "value committed in wrangler.toml — nothing to declare",
71
+ });
72
+ continue;
73
+ }
74
+ const bindingKey = BINDING_KEY_BY_TABLE[currentTable];
75
+ if (bindingKey && key === bindingKey) {
76
+ const qm = QUOTED_RE.exec(value);
77
+ const bindingName = qm ? (qm[1] ?? qm[2] ?? "") : value;
78
+ if (bindingName) {
79
+ sightings.push({
80
+ name: bindingName,
81
+ finding: { file, line: i + 1, consumer: `${currentTable} binding`, consumerKind: currentTable },
82
+ isBinding: true,
83
+ wranglerEnvironment: currentEnvironment,
84
+ localSource: "Cloudflare binding",
85
+ });
86
+ }
87
+ }
88
+ }
89
+ return sightings;
90
+ }
91
+ /**
92
+ * The line with any TOML inline comment removed. A `#` only starts a comment outside a string, so
93
+ * the scan tracks the quote it is inside. A line whose quotes do not balance — a `"""` opener, most
94
+ * importantly — is returned untouched rather than guessed at.
95
+ */
96
+ function stripInlineComment(line) {
97
+ let quote = null;
98
+ for (let i = 0; i < line.length; i++) {
99
+ const ch = line[i];
100
+ if (quote !== null) {
101
+ if (ch === "\\")
102
+ i++;
103
+ else if (ch === quote)
104
+ quote = null;
105
+ continue;
106
+ }
107
+ if (ch === '"' || ch === "'") {
108
+ quote = ch;
109
+ continue;
110
+ }
111
+ if (ch === "#")
112
+ return line.slice(0, i);
113
+ }
114
+ return line;
115
+ }
116
+ function isOpeningMultiline(value, marker) {
117
+ if (!value.startsWith(marker))
118
+ return false;
119
+ return !(value.length > marker.length && value.endsWith(marker));
120
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ // walk.ts — the one definition of "the files this repository is made of", shared by every scanner
2
+ // that needs to enumerate a tree. Skips the directories no scan should ever descend into.
3
+ //
4
+ // ⛔ A SYMLINK IS REPOSITORY CONTENT, AND REPOSITORY CONTENT IS UNTRUSTED (validate attempt 29,
5
+ // M-2 / CR-5). `statSync` FOLLOWS a link, so the previous walk had no way to tell a real directory
6
+ // from a link out of the tree, and no visited set to stop a cycle. Measured: a single `pkg/up -> ..`
7
+ // link reported one name 41 times with absurd file:line pairs, and a committed `elsewhere -> /tmp/x`
8
+ // walked a directory outside the repository entirely. So every entry is classified with a DIRENT
9
+ // (never a followed stat), and a link is followed only when its real path is inside the repository
10
+ // root and has not been walked before.
11
+ import { readdirSync, realpathSync, statSync } from "node:fs";
12
+ import { join, relative, resolve, sep } from "node:path";
13
+ export const SKIP_DIRS = new Set(["node_modules", "dist", "coverage", ".git"]);
14
+ /** Every file under `root`, relative paths, POSIX-separated, skipping SKIP_DIRS. */
15
+ export function walkRepo(root) {
16
+ const out = [];
17
+ let realRoot;
18
+ try {
19
+ realRoot = realpathSync(root);
20
+ }
21
+ catch {
22
+ return out;
23
+ }
24
+ // Real paths already descended into. Seeded with the root so a link back to it is a cycle.
25
+ const walked = new Set([realRoot]);
26
+ const stack = [root];
27
+ while (stack.length > 0) {
28
+ const dir = stack.pop();
29
+ let entries;
30
+ try {
31
+ entries = readdirSync(dir, { withFileTypes: true });
32
+ }
33
+ catch {
34
+ continue;
35
+ }
36
+ for (const entry of entries) {
37
+ const name = entry.name;
38
+ const full = join(dir, name);
39
+ let isDirectory = entry.isDirectory();
40
+ if (entry.isSymbolicLink()) {
41
+ let real;
42
+ try {
43
+ real = realpathSync(full);
44
+ }
45
+ catch {
46
+ continue;
47
+ }
48
+ if (!contains(realRoot, real))
49
+ continue; // points out of the repository
50
+ try {
51
+ isDirectory = statSync(full).isDirectory();
52
+ }
53
+ catch {
54
+ continue;
55
+ }
56
+ }
57
+ if (isDirectory) {
58
+ if (SKIP_DIRS.has(name))
59
+ continue;
60
+ // Every directory is keyed by its REAL path, link or not: `pkg/up -> pkg` is a cycle only
61
+ // because the link and the directory resolve to the same place, and checking the link alone
62
+ // would still walk the tree one extra time under its second name.
63
+ let real;
64
+ try {
65
+ real = realpathSync(full);
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ if (walked.has(real))
71
+ continue; // a cycle, or a second route to the same tree
72
+ walked.add(real);
73
+ stack.push(full);
74
+ }
75
+ else {
76
+ out.push(relative(root, full).split("\\").join("/"));
77
+ }
78
+ }
79
+ }
80
+ return out.sort();
81
+ }
82
+ /** True when `child` is `root` itself or lies beneath it. Both must already be real paths. */
83
+ export function contains(root, child) {
84
+ const a = resolve(root);
85
+ const b = resolve(child);
86
+ return b === a || b.startsWith(a.endsWith(sep) ? a : a + sep);
87
+ }
package/dist/env.js ADDED
@@ -0,0 +1,76 @@
1
+ // env.ts — `catalyst env inventory` / `env check`: a LOCAL, OFFLINE, repo-scoped reporter and
2
+ // validator. Unlike every other verb in this file's siblings, `cmdEnv` never calls `requireConfig` or
3
+ // `apiClient` — it needs neither a login nor a network call, which is the whole point of the feature:
4
+ // a person reviews what a repository declares without connecting anything first.
5
+ import { readFileSync, statSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ import { positionals } from "./args.js";
8
+ import { validateDeclaration } from "./env/declaration-rules.js";
9
+ import { inventoryRepo } from "./env/inventory.js";
10
+ import { inventoryToJson, renderCheck, renderInventory } from "./env/render.js";
11
+ import { UsageError } from "./errors.js";
12
+ export async function cmdEnv(args, ctx, deps = {}) {
13
+ const [sub, ...rest] = positionals(args);
14
+ if (sub === undefined)
15
+ throw new UsageError("env needs a subcommand: inventory | check");
16
+ if (sub === "inventory") {
17
+ if (rest.length > 1)
18
+ throw new UsageError(`env inventory takes at most one path (got an extra "${rest[1]}")`);
19
+ const root = resolve(rest[0] ?? ".");
20
+ // C-3: walkRepo swallows a readdir failure and returns [], so without this an unreadable or
21
+ // mistyped path printed three empty groups and exited 0 — a typo that reads as "declares nothing".
22
+ let stat;
23
+ try {
24
+ stat = statSync(root);
25
+ }
26
+ catch {
27
+ ctx.stderr(`${root} does not exist — env inventory takes a path to a repository`);
28
+ return 1;
29
+ }
30
+ if (!stat.isDirectory()) {
31
+ ctx.stderr(`${root} is not a directory — env inventory takes a path to a repository`);
32
+ return 1;
33
+ }
34
+ const inv = inventoryRepo(root, deps);
35
+ if (args.json)
36
+ ctx.stdout(JSON.stringify(inventoryToJson(inv)));
37
+ else
38
+ for (const line of renderInventory(inv))
39
+ ctx.stdout(line);
40
+ return 0;
41
+ }
42
+ if (sub === "check") {
43
+ const file = rest[0];
44
+ if (!file)
45
+ throw new UsageError("env check needs a file: catalyst env check <path to catalyst.env.json>");
46
+ if (rest.length > 1)
47
+ throw new UsageError(`env check takes exactly one file (got an extra "${rest[1]}")`);
48
+ let text;
49
+ try {
50
+ text = readFileSync(file, "utf8");
51
+ }
52
+ catch (err) {
53
+ ctx.stderr(`could not read ${file}: ${err instanceof Error ? err.message : String(err)}`);
54
+ return 1;
55
+ }
56
+ let doc;
57
+ try {
58
+ doc = JSON.parse(text);
59
+ }
60
+ catch {
61
+ // C-4/S-2: JSON.parse's own message embeds the first ten characters of the input, so
62
+ // forwarding it prints content from the file — on a verb documented as never printing a value.
63
+ ctx.stderr(`${file} is not valid JSON`);
64
+ return 1;
65
+ }
66
+ const errors = validateDeclaration(doc);
67
+ const state = errors.length === 0 ? "valid" : "invalid";
68
+ if (args.json)
69
+ ctx.stdout(JSON.stringify({ state, errors }));
70
+ else
71
+ for (const line of renderCheck({ state, errors }, file))
72
+ ctx.stdout(line);
73
+ return state === "valid" ? 0 : 1;
74
+ }
75
+ throw new UsageError(`unknown env subcommand "${sub}": inventory | check`);
76
+ }
@@ -1,4 +1,4 @@
1
- // environment.ts — `catalyst-skills environment`: the tenant-scope environment declaration, through
1
+ // environment.ts — `catalyst environment`: the tenant-scope environment declaration, through
2
2
  // the contract's account-environment routes. This is the ONE setup write a person's own key can
3
3
  // perform: the cloud has admitted it since the routes shipped, and until this verb existed nothing
4
4
  // could call it, so every setup action was a settings page.
package/dist/legacy.js ADDED
@@ -0,0 +1,224 @@
1
+ // legacy.ts — `catalyst legacy`: leftovers of the old LOCAL Catalyst runtime (the archived repository
2
+ // whose plugin, daemons, scheduled jobs and state folders a machine may still carry), found from a
3
+ // FIXED list and removed only on a yes, through the tools that own each piece. The cloud runtime
4
+ // replaced all of it; what is left can start old jobs or shadow current commands, so removal is
5
+ // recommended, and never automatic: a run with no terminal and no --yes only reports.
6
+ //
7
+ // The list is fixed on purpose. A prefix match would one day catch a current job (the housekeeping
8
+ // job, the sandbox credit guard, jobs a person made); this matches names the old runtime installed,
9
+ // read at the source commit below, and nothing else. Data folders are a separate yes (--data).
10
+ import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, unlinkSync } from "node:fs";
11
+ import { spawnSync } from "node:child_process";
12
+ import { join } from "node:path";
13
+ import { flagBool, positionals } from "./args.js";
14
+ import { UsageError } from "./errors.js";
15
+ import { promptSecret, stdinIsTty } from "./prompt.js";
16
+ /** The archived repository's commit the list below was read at. */
17
+ export const LEGACY_SOURCE_COMMIT = "73bc0645252ce8be38f8c87be6b67b950b3f0b56";
18
+ /** The old runtime's marketplace source, as Claude Code records it in known_marketplaces.json. */
19
+ export const OLD_MARKETPLACE_REPO = "coalesce-labs/catalyst";
20
+ /** launchd labels the old runtime installed under ~/Library/LaunchAgents (macOS). `com.catalyst.role.<name>`
21
+ * is the old role supervisor's per-role instance, matched by that exact prefix. Never `dev.catalystcloud.*`
22
+ * (the current housekeeping job and runner jobs) and never `dev.catalyst.*` (the sandbox credit guard). */
23
+ export const OLD_LAUNCHD_LABELS = [
24
+ "com.catalyst.agent",
25
+ "com.catalyst.reaper-watch",
26
+ "com.catalyst.quiet-fleet",
27
+ "com.catalyst.dead-man",
28
+ "com.catalyst.holding-sentinel",
29
+ "com.catalyst.orch-monitor",
30
+ "com.catalyst.role",
31
+ "ai.coalesce.catalyst-stack",
32
+ "ai.coalesce.catalyst-thoughts-sync",
33
+ "ai.coalesce.catalyst-orphan-sweep",
34
+ "ai.coalesce.catalyst-log-shipper",
35
+ "ai.coalesce.catalyst-cloud-sync",
36
+ "ai.coalesce.catalyst-usage-page",
37
+ "ai.coalesce.catalyst-claude-update",
38
+ "ai.coalesce.catalyst-updater",
39
+ "ai.coalesce.catalyst-replica-sync",
40
+ "ai.coalesce.catalyst-channel-watcher",
41
+ "ai.coalesce.catalyst-event-mirror",
42
+ "ai.coalesce.catalyst-monitor",
43
+ ];
44
+ const OLD_ROLE_PREFIX = "com.catalyst.role.";
45
+ /** systemd user units (~/.config/systemd/user) the old runtime's workstation pieces used on Linux. System
46
+ * units under /etc/systemd/system are a host's, current, and never looked at. */
47
+ export const OLD_USER_UNITS = ["catalyst.service", "catalyst-monitor.service"];
48
+ /** Command files the old runtime put on the person's PATH. `~/.local/bin/catalyst` is NOT one: that
49
+ * name belongs to the current CLI wherever npm put it. */
50
+ export const OLD_BINS = [".catalyst/bin", ".local/bin/catalyst-events", ".local/bin/catalyst-filter", ".local/bin/catalyst-hud", ".local/bin/catalyst-monitor", ".local/bin/catalyst-myown"];
51
+ /** State the old runtime kept. `~/.config/catalyst-cloud` is the current CLI's and never touched. */
52
+ export const OLD_DATA_DIRS = [".catalyst", ".config/catalyst", ".local/state/catalyst", ".local/state/catalyst-fleet-runner", ".local/state/catalyst-ledger"];
53
+ const RECOMMEND = "Remove them: the cloud runtime replaced them, and left in place they can start old jobs or shadow current commands.";
54
+ function readJson(path) {
55
+ try {
56
+ const v = JSON.parse(readFileSync(path, "utf8"));
57
+ return v !== null && typeof v === "object" ? v : null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /** Marketplaces whose recorded source is the old repository, and the plugins installed from them. */
64
+ function findPlugins(home) {
65
+ const dir = join(home, ".claude", "plugins");
66
+ const marketplaces = readJson(join(dir, "known_marketplaces.json")) ?? {};
67
+ const old = Object.entries(marketplaces)
68
+ .filter(([, v]) => {
69
+ const src = v?.source;
70
+ return typeof src?.repo === "string" && src.repo.toLowerCase() === OLD_MARKETPLACE_REPO;
71
+ })
72
+ .map(([name]) => name);
73
+ if (old.length === 0)
74
+ return [];
75
+ const installed = (readJson(join(dir, "installed_plugins.json")) ?? {}).plugins ?? {};
76
+ const items = [];
77
+ for (const key of Object.keys(installed)) {
78
+ const at = key.lastIndexOf("@");
79
+ if (at > 0 && old.includes(key.slice(at + 1)))
80
+ items.push({ kind: "plugin", name: key, path: join(dir, "installed_plugins.json") });
81
+ }
82
+ for (const name of old)
83
+ items.push({ kind: "marketplace", name, path: join(dir, "known_marketplaces.json") });
84
+ return items;
85
+ }
86
+ function findJobs(home, platform) {
87
+ if (platform === "darwin") {
88
+ const dir = join(home, "Library", "LaunchAgents");
89
+ let files = [];
90
+ try {
91
+ files = readdirSync(dir);
92
+ }
93
+ catch {
94
+ return [];
95
+ }
96
+ return files
97
+ .filter((f) => f.endsWith(".plist"))
98
+ .map((f) => f.slice(0, -".plist".length))
99
+ .filter((label) => OLD_LAUNCHD_LABELS.includes(label) || label.startsWith(OLD_ROLE_PREFIX))
100
+ .sort()
101
+ .map((label) => ({ kind: "job", name: label, path: join(dir, `${label}.plist`) }));
102
+ }
103
+ if (platform === "linux") {
104
+ const dir = join(home, ".config", "systemd", "user");
105
+ return OLD_USER_UNITS.filter((u) => existsSync(join(dir, u))).map((u) => ({ kind: "job", name: u, path: join(dir, u) }));
106
+ }
107
+ return [];
108
+ }
109
+ const tilde = (rel) => `~/${rel}`;
110
+ function findFiles(home) {
111
+ const bins = OLD_BINS.filter((rel) => existsSync(join(home, rel))).map((rel) => ({ kind: "bin", name: tilde(rel), path: join(home, rel) }));
112
+ const data = OLD_DATA_DIRS.filter((rel) => existsSync(join(home, rel))).map((rel) => ({ kind: "data", name: tilde(rel), path: join(home, rel), data: true }));
113
+ return [...bins, ...data];
114
+ }
115
+ /** Every old piece on this machine, in removal order: plugins, their marketplace, jobs, bins, data. Never writes. */
116
+ export function findLegacy(home, platform) {
117
+ return [...findPlugins(home), ...findJobs(home, platform), ...findFiles(home)];
118
+ }
119
+ const defaultRun = (cmd, args) => {
120
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 60_000 });
121
+ return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.error ? r.error.message : (r.stderr ?? "") };
122
+ };
123
+ /** Remove one item through the tool that owns it. Returns null on success, else the reason it is still there. */
124
+ function remove(item, run, platform, uid) {
125
+ const tool = (cmd, args, what) => {
126
+ const r = run(cmd, args);
127
+ return r.status === 0 ? null : `${what} failed: ${(r.stderr || r.stdout).trim().split("\n")[0] || `exit ${r.status}`}`;
128
+ };
129
+ try {
130
+ if (item.kind === "plugin")
131
+ return tool("claude", ["plugin", "uninstall", item.name], "claude plugin uninstall");
132
+ if (item.kind === "marketplace")
133
+ return tool("claude", ["plugin", "marketplace", "remove", item.name], "claude plugin marketplace remove");
134
+ if (item.kind === "job") {
135
+ const why = platform === "darwin" ? tool("launchctl", ["bootout", `gui/${uid}/${item.name}`], "launchctl bootout") : tool("systemctl", ["--user", "disable", "--now", item.name], "systemctl --user disable --now");
136
+ if (why !== null)
137
+ return why;
138
+ unlinkSync(item.path);
139
+ return null;
140
+ }
141
+ // a bin or a data folder: a file goes with unlink, a directory (~/.catalyst/bin, the data) with rm -rf
142
+ if (lstatSync(item.path).isDirectory())
143
+ rmSync(item.path, { recursive: true, force: true });
144
+ else
145
+ unlinkSync(item.path);
146
+ return null;
147
+ }
148
+ catch (err) {
149
+ return `could not remove: ${err instanceof Error ? err.message : String(err)}`;
150
+ }
151
+ }
152
+ export async function cmdLegacy(args, ctx, deps = {}) {
153
+ if (positionals(args).length > 0)
154
+ throw new UsageError("legacy takes no positional argument; flags are --remove, --data, --yes, --json");
155
+ const wantRemove = flagBool(args, "remove");
156
+ const withData = flagBool(args, "data");
157
+ if (withData && !wantRemove)
158
+ throw new UsageError("--data belongs with --remove");
159
+ const platform = deps.platform ?? process.platform;
160
+ const uid = deps.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
161
+ const run = deps.run ?? defaultRun;
162
+ const found = findLegacy(ctx.home, platform);
163
+ const emit = (body, lines) => { if (args.json)
164
+ ctx.stdout(JSON.stringify(body));
165
+ else
166
+ for (const l of lines)
167
+ ctx.stdout(l); };
168
+ if (found.length === 0) {
169
+ emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found: [], removed: [], remaining: [] }, ["no leftovers of the old local Catalyst runtime on this machine"]);
170
+ return 0;
171
+ }
172
+ const listLines = [
173
+ `Leftovers of the old local Catalyst runtime on this machine (${found.length}):`,
174
+ ...found.map((f) => ` ${f.kind}: ${f.name}${f.kind === "job" ? ` — ${f.path}` : f.data ? " (kept unless --data)" : ""}`),
175
+ ];
176
+ if (!wantRemove) {
177
+ emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found, removed: [], remaining: found }, [...listLines, RECOMMEND, "Run: catalyst legacy --remove (add --data to delete its data folders too)"]);
178
+ return 1;
179
+ }
180
+ // the one question, only on a terminal; --yes answers it without asking
181
+ let go = flagBool(args, "yes");
182
+ if (!go) {
183
+ const tty = (deps.isTty ?? stdinIsTty)();
184
+ if (!tty) {
185
+ emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found, removed: [], remaining: found, asked: false }, [...listLines, RECOMMEND, "nothing removed: no terminal to ask on; run catalyst legacy --remove --yes to remove these without a question (add --data for the data folders)"]);
186
+ return 1;
187
+ }
188
+ for (const l of listLines)
189
+ ctx.stdout(l);
190
+ ctx.stdout(RECOMMEND);
191
+ const answer = (await (deps.prompt ?? ((q) => promptSecret(q)))(`Remove ${found.length} item${found.length === 1 ? "" : "s"}${withData ? ", data folders included" : ", keeping the data folders"}? [y/N] `)).trim().toLowerCase();
192
+ go = answer === "y" || answer === "yes";
193
+ if (!go) {
194
+ emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found, removed: [], remaining: found, asked: true }, ["nothing removed; run catalyst legacy --remove again when ready"]);
195
+ return 1;
196
+ }
197
+ }
198
+ const removed = [];
199
+ const failed = [];
200
+ const kept = [];
201
+ const lines = [];
202
+ for (const item of found) {
203
+ if (item.data && !withData) {
204
+ kept.push(item);
205
+ lines.push(`kept: ${item.kind} ${item.name} (run with --data to delete it)`);
206
+ continue;
207
+ }
208
+ const why = remove(item, run, platform, uid);
209
+ if (why === null) {
210
+ removed.push(item);
211
+ lines.push(`removed: ${item.kind} ${item.name}`);
212
+ }
213
+ else {
214
+ failed.push({ item, why });
215
+ lines.push(`still present: ${item.kind} ${item.name} (${why})`);
216
+ }
217
+ }
218
+ // re-check from the same list, so the report says what the machine holds now, not what was attempted
219
+ const remaining = findLegacy(ctx.home, platform).filter((f) => !(f.data && !withData));
220
+ const keptNote = kept.length > 0 ? " except the data folders you kept" : "";
221
+ lines.push(remaining.length === 0 ? `re-checked: nothing of the old runtime remains${keptNote}` : `re-checked: ${remaining.length} item${remaining.length === 1 ? "" : "s"} of the old runtime remain${remaining.length === 1 ? "s" : ""}${keptNote}: ${remaining.map((r) => `${r.kind} ${r.name}`).join(", ")}`);
222
+ emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found, removed, remaining: [...remaining, ...kept], kept, failed }, lines);
223
+ return remaining.length === 0 ? 0 : 1;
224
+ }
@@ -0,0 +1,129 @@
1
+ // project.ts — `catalyst project wip-limit get|set`: a project's new-start WIP limit, on the
2
+ // person's own login. Any active member reads it; a workspace owner or admin sets it. The limit is
3
+ // per Catalyst project (one Linear team across all its repositories): the project's stored value,
4
+ // else the workspace value, else the default the cloud reports. It holds only NEW starts, so the
5
+ // reply carries the work in progress beside the limit: that count, not a runner count, is what a
6
+ // person compares. The route is the cloud's own member route; the reply shape is the cloud's, so a
7
+ // field this CLI does not know is passed through under --json untouched.
8
+ import { flagString, positionals } from "./args.js";
9
+ import { requireConfig } from "./config.js";
10
+ import { loadContract } from "./contract.js";
11
+ import { UsageError } from "./errors.js";
12
+ import { apiClient } from "./transport.js";
13
+ /** The limit's bounds, as the cloud clamps them. A value outside is refused here, before any request. */
14
+ export const WIP_LIMIT_MIN = 0;
15
+ export const WIP_LIMIT_MAX = 9999;
16
+ /** The agent twin of the member route: a bearer reads it with `?team=<key or id>` and writes it with a
17
+ * body naming the team, the way the team-workflow verbs do. The contract's route table lists these
18
+ * two strings, so `capabilities` can judge them. */
19
+ export const WIP_LIMIT_ROUTE = "/api/v1/agent/team-wip-limit";
20
+ export const wipLimitPath = (team) => `${WIP_LIMIT_ROUTE}?team=${encodeURIComponent(team)}`;
21
+ const SOURCE_TEXT = { project: "set for this project", flag: "workspace value", default: "default" };
22
+ const sourceText = (source) => SOURCE_TEXT[String(source)] ?? String(source ?? "unknown");
23
+ /** The project `--team` names, or the only mapped project when the flag is absent. Never a guess between two. */
24
+ export function resolveTeam(doc, wanted) {
25
+ const teams = Array.isArray(doc.teams) ? doc.teams : [];
26
+ if (wanted !== undefined) {
27
+ const lower = wanted.toLocaleLowerCase();
28
+ const found = teams.find((t) => (t.key ?? "").toLocaleLowerCase() === lower || t.id === wanted);
29
+ if (!found)
30
+ throw new UsageError(`no mapped project has the team key ${wanted}${teams.length ? ` (mapped: ${teams.map((t) => t.key ?? t.id).join(", ")})` : " (no project is mapped yet)"}`);
31
+ return found;
32
+ }
33
+ if (teams.length === 1)
34
+ return teams[0];
35
+ if (teams.length === 0)
36
+ throw new UsageError("no project is mapped yet; map one first (catalyst team map <KEY>)");
37
+ throw new UsageError(`--team is needed: ${teams.length} projects are mapped (${teams.map((t) => t.key ?? t.id).join(", ")})`);
38
+ }
39
+ /** `set`'s value: an integer inside the cloud's bounds, or the word default. Parsed before any request. */
40
+ export function parseLimit(raw) {
41
+ if (raw === undefined)
42
+ throw new UsageError("project wip-limit set needs a value: an integer, or default");
43
+ if (raw === "default")
44
+ return null;
45
+ if (!/^-?\d+$/.test(raw))
46
+ throw new UsageError(`the limit must be an integer from ${WIP_LIMIT_MIN} to ${WIP_LIMIT_MAX}, or default (got ${raw})`);
47
+ const n = Number(raw);
48
+ if (n < WIP_LIMIT_MIN || n > WIP_LIMIT_MAX)
49
+ throw new UsageError(`the limit must be from ${WIP_LIMIT_MIN} to ${WIP_LIMIT_MAX} (got ${raw}); 0 holds every new start`);
50
+ return n;
51
+ }
52
+ /** The in-progress tickets as identifiers, tolerant of the cloud sending strings or objects; [] when absent. */
53
+ export function ticketIds(value) {
54
+ if (!Array.isArray(value))
55
+ return [];
56
+ return value
57
+ .map((t) => (typeof t === "string" ? t : t && typeof t === "object" ? String(t.identifier ?? t.id ?? "") : ""))
58
+ .filter((t) => t !== "");
59
+ }
60
+ /** One line naming what holds the WIP, capped so a big team does not scroll; null when the cloud sent none. */
61
+ function inProgressLine(view) {
62
+ const ids = ticketIds(view.inProgressTickets);
63
+ if (ids.length === 0)
64
+ return null;
65
+ const shown = ids.slice(0, 12);
66
+ return `in progress: ${shown.join(", ")}${ids.length > shown.length ? ` and ${ids.length - shown.length} more` : ""} — these are what a new ticket waits on; unstick them, not the queue`;
67
+ }
68
+ function line(key, view) {
69
+ const limit = view.limit;
70
+ const inProgress = view.inProgress;
71
+ const at = typeof limit === "number" && typeof inProgress === "number" && inProgress >= limit ? " — at the limit: a new ticket waits until one in progress finishes" : "";
72
+ return `${key}: ${typeof inProgress === "number" ? inProgress : "?"} in progress, limit ${typeof limit === "number" ? limit : "?"} (${sourceText(view.source)})${at}`;
73
+ }
74
+ export async function cmdProject(args, ctx) {
75
+ const [sub, action, value, ...extra] = positionals(args);
76
+ if (sub !== "wip-limit")
77
+ throw new UsageError("project needs wip-limit <get|set <n>|default>");
78
+ if (action !== "get" && action !== "set")
79
+ throw new UsageError("project wip-limit needs get, or set <n>|default");
80
+ if (action === "get" && value !== undefined)
81
+ throw new UsageError("project wip-limit get takes no value; set does");
82
+ if (extra.length > 0)
83
+ throw new UsageError("project wip-limit takes one value at most");
84
+ const limit = action === "set" ? parseLimit(value) : null;
85
+ const cfg = requireConfig(ctx);
86
+ const { doc } = await loadContract(ctx, cfg);
87
+ const team = resolveTeam(doc, flagString(args, "team"));
88
+ const key = team.key ?? team.id;
89
+ const client = apiClient(cfg, ctx);
90
+ const accept = [400, 403, 404];
91
+ const emit = (body, lines) => { if (args.json)
92
+ ctx.stdout(JSON.stringify(body));
93
+ else
94
+ for (const l of lines)
95
+ ctx.stdout(l); };
96
+ const refuse = (status, reply) => {
97
+ // The cloud's error bodies carry `error` (the code to match on) and a human `message`.
98
+ const body = reply ?? {};
99
+ const detail = body.message || body.reason ? ` (${String(body.message ?? body.reason)})` : "";
100
+ const why = status === 403
101
+ ? `setting the limit needs a workspace owner or admin; any member can read it with catalyst project wip-limit get${detail}`
102
+ : status === 404 && body.error === "team-unknown"
103
+ ? `this cloud does not know a team ${key}; check the key with catalyst team list${detail}`
104
+ : status === 404
105
+ ? "this Catalyst Cloud does not serve the wip-limit route yet (needs a newer cloud)"
106
+ : `${String(body.error ?? body.code ?? "bad request")}${detail}`;
107
+ emit({ status, team: { id: team.id, key }, ...body }, [`refused (${status}): ${why}`]);
108
+ return 1;
109
+ };
110
+ const before = await client.getJson(wipLimitPath(team.id), { accept, parseAccepted: true });
111
+ if (before.status !== 200)
112
+ return refuse(before.status, before.body);
113
+ if (action === "get") {
114
+ emit({ team: { id: team.id, key }, ...before.body }, [
115
+ line(key, before.body),
116
+ ...(inProgressLine(before.body) === null ? [] : [inProgressLine(before.body)]),
117
+ `change it: catalyst project wip-limit set <n> --team ${key} (owner or admin; set default returns to the workspace value)`,
118
+ ]);
119
+ return 0;
120
+ }
121
+ const after = await client.postJson(WIP_LIMIT_ROUTE, { team: team.id, limit }, { accept });
122
+ if (after.status !== 200)
123
+ return refuse(after.status, after.body);
124
+ emit({ team: { id: team.id, key }, ...after.body, previous: { limit: before.body.limit, source: before.body.source } }, [
125
+ `${key}: limit ${String(before.body.limit)} (${sourceText(before.body.source)}) → ${String(after.body.limit)} (${sourceText(after.body.source)})${after.body.limit === 0 ? "; 0 holds every new start" : ""}`,
126
+ line(key, after.body),
127
+ ]);
128
+ return 0;
129
+ }
package/dist/release.js CHANGED
@@ -1,4 +1,4 @@
1
- // release.ts — `catalyst-skills release`: a person's release of a parked or held ticket, or of every
1
+ // release.ts — `catalyst release`: a person's release of a parked or held ticket, or of every
2
2
  // ticket on one team parked under one failure class, through the contract's `ticket-release` and
3
3
  // `ticket-release-class` routes. The cloud reads every governor holding the ticket and either releases
4
4
  // all of them or releases nothing and names, per governor, the human action that does fix it; this verb