@catalyst-cloud/cli 0.13.0 → 0.14.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 (149) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +27 -30
  3. package/dist/args.js +299 -61
  4. package/dist/ask.js +30 -18
  5. package/dist/capabilities.js +7 -0
  6. package/dist/cli.js +125 -20
  7. package/dist/config.js +3 -1
  8. package/dist/env/draft.js +134 -0
  9. package/dist/env/legacy.js +54 -0
  10. package/dist/env/parse-env.js +50 -0
  11. package/dist/env/settings-toml.js +66 -0
  12. package/dist/env.js +126 -26
  13. package/dist/environment.js +1 -1
  14. package/dist/legacy.js +224 -0
  15. package/dist/onboard-consent.js +65 -0
  16. package/dist/onboard.js +836 -0
  17. package/dist/project.js +191 -0
  18. package/dist/ready.js +11 -1
  19. package/dist/release.js +1 -1
  20. package/dist/repo.js +297 -0
  21. package/dist/runtime-verb.js +1 -1
  22. package/dist/secret.js +25 -4
  23. package/dist/skill-shape.js +21 -13
  24. package/dist/skills.js +44 -4
  25. package/dist/transport.js +26 -1
  26. package/dist/var.js +118 -0
  27. package/package.json +6 -4
  28. package/skills/catalyst-github/SKILL.md +7 -7
  29. package/skills/catalyst-github/references/is-it-mergeable.md +8 -2
  30. package/skills/catalyst-github/references/what-a-pr-accumulates.md +3 -3
  31. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +3 -3
  32. package/skills/catalyst-github/scripts/lib/cli.mjs +9 -7
  33. package/skills/catalyst-github/scripts/lib/credential.mjs +2 -2
  34. package/skills/catalyst-github/scripts/read-pr.mjs +2 -2
  35. package/skills/catalyst-linear/SKILL.md +17 -19
  36. package/skills/catalyst-linear/agents/openai.yaml +1 -1
  37. package/skills/catalyst-linear/agents/portability.yaml +1 -1
  38. package/skills/catalyst-linear/references/reading-a-ticket.md +9 -26
  39. package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +2 -2
  40. package/skills/catalyst-linear/references/writing-to-linear.md +7 -9
  41. package/skills/catalyst-linear/scripts/comment.mjs +1 -1
  42. package/skills/catalyst-linear/scripts/create-ticket.mjs +1 -1
  43. package/skills/catalyst-linear/scripts/label.mjs +1 -1
  44. package/skills/catalyst-linear/scripts/lib/cli.mjs +9 -7
  45. package/skills/catalyst-linear/scripts/lib/credential.mjs +2 -2
  46. package/skills/catalyst-linear/scripts/move.mjs +1 -1
  47. package/skills/catalyst-linear/scripts/read-ticket.mjs +1 -1
  48. package/skills/catalyst-linear/scripts/search.mjs +1 -1
  49. package/skills/catalyst-onboard/SKILL.md +37 -57
  50. package/skills/catalyst-onboard/agents/openai.yaml +2 -2
  51. package/skills/catalyst-onboard/agents/portability.yaml +1 -1
  52. package/skills/catalyst-onboard/references/connecting-this-machine.md +35 -0
  53. package/skills/catalyst-onboard/references/declaring-a-repository.md +18 -21
  54. package/skills/catalyst-onboard/references/local-sync.md +19 -7
  55. package/skills/catalyst-onboard/references/reading-ready.md +79 -0
  56. package/skills/catalyst-onboard/references/replacing-a-credential.md +8 -33
  57. package/skills/catalyst-onboard/references/repository-agent-setup.md +23 -0
  58. package/skills/catalyst-onboard/references/skill-sources.md +13 -19
  59. package/skills/catalyst-onboard/references/the-one-path.md +18 -74
  60. package/skills/catalyst-onboard/references/what-a-phase-needs.md +21 -28
  61. package/skills/catalyst-onboard/references/what-the-browser-owns.md +20 -37
  62. package/skills/{catalyst-setup → catalyst-onboard}/scripts/check.mjs +10 -9
  63. package/skills/catalyst-onboard/scripts/lib/cli.mjs +6 -4
  64. package/skills/catalyst-onboard/scripts/lib/credential.mjs +2 -2
  65. package/skills/catalyst-onboard/scripts/lib/project-coverage.mjs +53 -0
  66. package/skills/catalyst-onboard/scripts/where-am-i.mjs +929 -184
  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 +6 -13
  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 +10 -19
  96. package/skills/what-this-repo-needs/references/what-each-group-means.md +14 -44
  97. package/skills/what-this-repo-needs/scripts/check.mjs +13 -14
  98. package/skills/what-this-repo-needs/scripts/inventory.mjs +2 -2
  99. package/skills/what-this-repo-needs/scripts/lib/cli.mjs +11 -9
  100. package/skills/what-this-repo-needs/scripts/lib/credential.mjs +2 -2
  101. package/skills/whats-happening/SKILL.md +20 -30
  102. package/skills/whats-happening/agents/openai.yaml +1 -1
  103. package/skills/whats-happening/references/coding-accounts.md +11 -0
  104. package/skills/whats-happening/references/routing-work.md +4 -33
  105. package/skills/whats-happening/references/settings-and-where-they-live.md +17 -0
  106. package/skills/whats-happening/references/stages-and-mapping.md +19 -0
  107. package/skills/whats-happening/references/status-reply.md +11 -31
  108. package/skills/whats-happening/references/the-ladder.md +20 -0
  109. package/skills/whats-happening/references/what-runs-next.md +13 -0
  110. package/skills/whats-happening/references/when-a-phase-fails.md +21 -0
  111. package/skills/whats-happening/references/why-is-it-stuck.md +38 -62
  112. package/skills/whats-happening/scripts/explain.mjs +1 -1
  113. package/skills/whats-happening/scripts/lib/cli.mjs +7 -5
  114. package/skills/whats-happening/scripts/lib/credential.mjs +2 -2
  115. package/skills/{how-catalyst-works → whats-happening}/scripts/show-my-map.mjs +18 -32
  116. package/skills/whats-happening/scripts/snapshot.mjs +10 -6
  117. package/dist/env/declaration-rules.js +0 -163
  118. package/skills/catalyst-onboard/references/choosing-a-coding-account.md +0 -52
  119. package/skills/catalyst-onboard/references/who-fixes-what.md +0 -64
  120. package/skills/catalyst-setup/SKILL.md +0 -36
  121. package/skills/catalyst-setup/agents/openai.yaml +0 -6
  122. package/skills/catalyst-setup/agents/portability.yaml +0 -4
  123. package/skills/catalyst-setup/references/what-each-check-means.md +0 -88
  124. package/skills/catalyst-setup/scripts/lib/cli.mjs +0 -103
  125. package/skills/catalyst-setup/scripts/lib/credential.mjs +0 -29
  126. package/skills/catalyst-setup/scripts/replica-status.mjs +0 -46
  127. package/skills/connect-me/SKILL.md +0 -63
  128. package/skills/connect-me/agents/openai.yaml +0 -6
  129. package/skills/connect-me/agents/portability.yaml +0 -5
  130. package/skills/connect-me/references/keeping-the-replica-running.md +0 -88
  131. package/skills/connect-me/scripts/lib/cli.mjs +0 -185
  132. package/skills/connect-me/scripts/lib/credential.mjs +0 -29
  133. package/skills/connect-me/scripts/verify-connection.mjs +0 -68
  134. package/skills/how-catalyst-works/SKILL.md +0 -45
  135. package/skills/how-catalyst-works/agents/openai.yaml +0 -6
  136. package/skills/how-catalyst-works/agents/portability.yaml +0 -4
  137. package/skills/how-catalyst-works/references/coding-accounts.md +0 -51
  138. package/skills/how-catalyst-works/references/settings-and-where-they-live.md +0 -20
  139. package/skills/how-catalyst-works/references/stages-and-mapping.md +0 -56
  140. package/skills/how-catalyst-works/references/the-ladder.md +0 -41
  141. package/skills/how-catalyst-works/references/what-catalyst-is.md +0 -30
  142. package/skills/how-catalyst-works/references/what-runs-next.md +0 -95
  143. package/skills/how-catalyst-works/references/when-a-phase-fails.md +0 -57
  144. package/skills/how-catalyst-works/scripts/explain-ticket.mjs +0 -41
  145. package/skills/how-catalyst-works/scripts/lib/cli.mjs +0 -164
  146. package/skills/how-catalyst-works/scripts/lib/credential.mjs +0 -29
  147. package/skills/how-catalyst-works/scripts/whats-running.mjs +0 -65
  148. package/skills/whats-happening/references/reading-the-board.md +0 -43
  149. package/skills/whats-happening/references/reprioritising.md +0 -37
package/dist/env.js CHANGED
@@ -2,17 +2,103 @@
2
2
  // validator. Unlike every other verb in this file's siblings, `cmdEnv` never calls `requireConfig` or
3
3
  // `apiClient` — it needs neither a login nor a network call, which is the whole point of the feature:
4
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";
5
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { flagBool, flagString, positionals } from "./args.js";
8
+ import { validateSettingsToml } from "./env/settings-toml.js";
9
+ import { convertLegacyEnvironmentJson } from "./env/legacy.js";
10
+ import { draftEnvironment } from "./env/draft.js";
9
11
  import { inventoryRepo } from "./env/inventory.js";
10
- import { inventoryToJson, renderCheck, renderInventory } from "./env/render.js";
12
+ import { inventoryToJson, renderInventory } from "./env/render.js";
11
13
  import { UsageError } from "./errors.js";
12
14
  export async function cmdEnv(args, ctx, deps = {}) {
13
15
  const [sub, ...rest] = positionals(args);
14
16
  if (sub === undefined)
15
- throw new UsageError("env needs a subcommand: inventory | check");
17
+ throw new UsageError("env needs a subcommand: inventory | check | migrate | draft");
18
+ if (sub === "draft") {
19
+ if (rest.length > 0)
20
+ throw new UsageError("env draft takes no positional arguments; use --root DIR");
21
+ const root = resolve(flagString(args, "root") ?? ".");
22
+ const target = join(root, ".catalyst/catalyst.toml");
23
+ const draft = draftEnvironment(root, deps);
24
+ if (draft.state === "invalid") {
25
+ if (args.json)
26
+ ctx.stdout(JSON.stringify(draft));
27
+ else
28
+ for (const error of draft.errors)
29
+ ctx.stderr(error);
30
+ return 1;
31
+ }
32
+ const summary = {
33
+ state: "valid",
34
+ names: draft.names,
35
+ secretNames: draft.secretNames,
36
+ setupCount: draft.setup.length,
37
+ verifyCount: draft.verify.length,
38
+ sources: draft.sources,
39
+ destination: ".catalyst/catalyst.toml",
40
+ toml: draft.toml,
41
+ };
42
+ const write = flagBool(args, "write");
43
+ if (write && existsSync(target)) {
44
+ ctx.stderr(`${summary.destination} already exists; no file was written. Review the draft with --diff and merge it by hand.`);
45
+ return 1;
46
+ }
47
+ if (write) {
48
+ const checked = validateSettingsToml(draft.toml);
49
+ if (checked.state !== "valid") {
50
+ ctx.stderr(`draft failed local validation: ${checked.state === "invalid" ? checked.errors.join("; ") : "no environment table"}`);
51
+ return 1;
52
+ }
53
+ if (deps.writeFile)
54
+ deps.writeFile(target, draft.toml);
55
+ else {
56
+ mkdirSync(dirname(target), { recursive: true });
57
+ writeFileSync(target, draft.toml, { encoding: "utf8", flag: "wx" });
58
+ }
59
+ }
60
+ if (args.json)
61
+ ctx.stdout(JSON.stringify({ ...summary, written: write }));
62
+ else if (flagBool(args, "diff")) {
63
+ ctx.stdout(`--- /dev/null\n+++ b/${summary.destination} (draft)\n@@\n${draft.toml.split("\n").filter(Boolean).map((line) => `+${line}`).join("\n")}`);
64
+ }
65
+ else {
66
+ ctx.stdout(`Drafted ${summary.destination} from ${draft.sources.length ? draft.sources.join(", ") : "no environment sources"}.`);
67
+ ctx.stdout(`Names: ${draft.names.length} (${draft.secretNames.length} secret; all optional)`);
68
+ ctx.stdout(`Setup: ${draft.setup.length}; verify: ${draft.verify.length}`);
69
+ ctx.stdout(draft.toml);
70
+ }
71
+ if (write && !args.json)
72
+ ctx.stdout(`Wrote ${summary.destination}; review and commit it through your repository's pull request.`);
73
+ return 0;
74
+ }
75
+ if (sub === "migrate") {
76
+ if (rest.length > 1)
77
+ throw new UsageError(`env migrate takes at most one file (got an extra "${rest[1]}")`);
78
+ const file = rest[0] ?? "catalyst.env.json";
79
+ let text;
80
+ try {
81
+ text = readFileSync(file, "utf8");
82
+ }
83
+ catch {
84
+ ctx.stderr(`could not read legacy declaration ${file}`);
85
+ return 1;
86
+ }
87
+ const result = convertLegacyEnvironmentJson(text);
88
+ if (result.state === "invalid") {
89
+ if (args.json)
90
+ ctx.stdout(JSON.stringify(result));
91
+ else
92
+ for (const error of result.errors)
93
+ ctx.stderr(error);
94
+ return 1;
95
+ }
96
+ if (args.json)
97
+ ctx.stdout(JSON.stringify(result));
98
+ else
99
+ ctx.stdout(result.toml);
100
+ return 0;
101
+ }
16
102
  if (sub === "inventory") {
17
103
  if (rest.length > 1)
18
104
  throw new UsageError(`env inventory takes at most one path (got an extra "${rest[1]}")`);
@@ -40,37 +126,51 @@ export async function cmdEnv(args, ctx, deps = {}) {
40
126
  return 0;
41
127
  }
42
128
  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>");
129
+ const file = rest[0] ?? ".catalyst/catalyst.toml";
46
130
  if (rest.length > 1)
47
131
  throw new UsageError(`env check takes exactly one file (got an extra "${rest[1]}")`);
132
+ if (file.endsWith("catalyst.env.json")) {
133
+ const message = "catalyst.env.json is a legacy declaration and is no longer read; validate .catalyst/catalyst.toml instead";
134
+ if (args.json)
135
+ ctx.stdout(JSON.stringify({ state: "invalid", errors: [message] }));
136
+ else
137
+ ctx.stderr(message);
138
+ return 1;
139
+ }
48
140
  let text;
49
141
  try {
50
142
  text = readFileSync(file, "utf8");
51
143
  }
52
144
  catch (err) {
145
+ if (rest[0] === undefined && !existsSync("catalyst.env.json")) {
146
+ if (args.json)
147
+ ctx.stdout(JSON.stringify({ state: "no-source", errors: [] }));
148
+ else
149
+ ctx.stdout("no .catalyst/catalyst.toml found in this repository");
150
+ return 0;
151
+ }
152
+ if (rest[0] === undefined && existsSync("catalyst.env.json")) {
153
+ const message = "No .catalyst/catalyst.toml found; this repository has a legacy catalyst.env.json, which is no longer read. Convert it to the [environment] table in .catalyst/catalyst.toml.";
154
+ if (args.json)
155
+ ctx.stdout(JSON.stringify({ state: "invalid", errors: [message] }));
156
+ else
157
+ ctx.stderr(message);
158
+ return 1;
159
+ }
53
160
  ctx.stderr(`could not read ${file}: ${err instanceof Error ? err.message : String(err)}`);
54
161
  return 1;
55
162
  }
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";
163
+ const result = validateSettingsToml(text);
68
164
  if (args.json)
69
- ctx.stdout(JSON.stringify({ state, errors }));
165
+ ctx.stdout(JSON.stringify(result));
166
+ else if (result.state === "valid")
167
+ ctx.stdout(`valid — ${file} contains ${result.variableNames.length} environment variable names`);
168
+ else if (result.state === "no-source")
169
+ ctx.stdout(`no [environment] table in ${file}`);
70
170
  else
71
- for (const line of renderCheck({ state, errors }, file))
72
- ctx.stdout(line);
73
- return state === "valid" ? 0 : 1;
171
+ for (const error of result.errors)
172
+ ctx.stderr(`${file}: ${error}`);
173
+ return result.state === "invalid" ? 1 : 0;
74
174
  }
75
- throw new UsageError(`unknown env subcommand "${sub}": inventory | check`);
175
+ throw new UsageError(`unknown env subcommand "${sub}": inventory | check | migrate | draft`);
76
176
  }
@@ -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,65 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { CliError, UsageError } from "./errors.js";
3
+ /** Bound callbacks too: a transport that forgets to honor abort cannot hold setup forever. */
4
+ function bounded(work, signal) {
5
+ return new Promise((resolve, reject) => {
6
+ const abort = () => reject(new Error("consent_deadline"));
7
+ if (signal.aborted) {
8
+ reject(new Error("consent_deadline"));
9
+ return;
10
+ }
11
+ signal.addEventListener("abort", abort, { once: true });
12
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
13
+ });
14
+ }
15
+ function safeReason(reason, fallback) {
16
+ return reason && /^[a-z][a-z0-9_]{1,63}$/.test(reason) ? reason : fallback;
17
+ }
18
+ /** Poll live consent every two seconds for at most ten minutes, using actual elapsed time. */
19
+ export async function pollConsent(options) {
20
+ const timeoutMs = options.timeoutMs ?? 600_000;
21
+ const intervalMs = options.intervalMs ?? 2_000;
22
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 600_000 ||
23
+ !Number.isSafeInteger(intervalMs) || intervalMs <= 0)
24
+ throw new UsageError("consent timeout must be 1 to 600000 milliseconds, with a positive poll interval");
25
+ const now = options.now ?? (() => performance.now());
26
+ const started = now();
27
+ const deadline = new AbortController();
28
+ const timer = setTimeout(() => deadline.abort(), timeoutMs);
29
+ const signal = options.signal ? AbortSignal.any([options.signal, deadline.signal]) : deadline.signal;
30
+ const elapsedMs = () => Math.max(0, Math.round(now() - started));
31
+ const waiting = () => ({ state: "waiting", reason: options.signal?.aborted ? "interrupted" : "consent_timeout", elapsedMs: elapsedMs() });
32
+ const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
33
+ try {
34
+ while (!signal.aborted && elapsedMs() < timeoutMs) {
35
+ let status;
36
+ try {
37
+ status = await bounded(options.readStatus(signal), signal);
38
+ }
39
+ catch (error) {
40
+ if (signal.aborted)
41
+ return waiting();
42
+ if (error instanceof CliError && (error.status === 401 || error.status === 403))
43
+ return { state: "refused", reason: "consent_identity_refused", elapsedMs: elapsedMs() };
44
+ return { state: "failed", reason: "consent_status_failed", elapsedMs: elapsedMs() };
45
+ }
46
+ if (elapsedMs() >= timeoutMs || signal.aborted)
47
+ return waiting();
48
+ if (status.outcome === "connected")
49
+ return { state: "done", elapsedMs: elapsedMs() };
50
+ if (status.outcome === "refused" || status.outcome === "failed")
51
+ return { state: status.outcome, reason: safeReason(status.reason, `consent_${status.outcome}`), elapsedMs: elapsedMs() };
52
+ const remaining = timeoutMs - elapsedMs();
53
+ try {
54
+ await bounded(sleep(Math.min(intervalMs, remaining)), signal);
55
+ }
56
+ catch {
57
+ return waiting();
58
+ }
59
+ }
60
+ return waiting();
61
+ }
62
+ finally {
63
+ clearTimeout(timer);
64
+ }
65
+ }