@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
@@ -0,0 +1,191 @@
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
+ /** Admin-readable inventory of every Catalyst project, including projects with no saved stages. */
22
+ export const PROJECT_LIST_ROUTE_SUFFIX = "/tenant/repositories";
23
+ const SOURCE_TEXT = {
24
+ project: "set for this project",
25
+ flag: "workspace value",
26
+ default: "default",
27
+ };
28
+ const sourceText = (source) => SOURCE_TEXT[String(source)] ?? String(source ?? "unknown");
29
+ /** The project `--team` names, or the only mapped project when the flag is absent. Never a guess between two. */
30
+ export function resolveTeam(doc, wanted) {
31
+ const teams = Array.isArray(doc.teams) ? doc.teams : [];
32
+ if (wanted !== undefined) {
33
+ const lower = wanted.toLocaleLowerCase();
34
+ const found = teams.find((t) => (t.key ?? "").toLocaleLowerCase() === lower || t.id === wanted);
35
+ if (!found)
36
+ 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)"}`);
37
+ return found;
38
+ }
39
+ if (teams.length === 1)
40
+ return teams[0];
41
+ if (teams.length === 0)
42
+ throw new UsageError("no project is mapped yet; map one first (catalyst team map <KEY>)");
43
+ throw new UsageError(`--team is needed: ${teams.length} projects are mapped (${teams.map((t) => t.key ?? t.id).join(", ")})`);
44
+ }
45
+ /** `set`'s value: an integer inside the cloud's bounds, or the word default. Parsed before any request. */
46
+ export function parseLimit(raw) {
47
+ if (raw === undefined)
48
+ throw new UsageError("project wip-limit set needs a value: an integer, or default");
49
+ if (raw === "default")
50
+ return null;
51
+ if (!/^-?\d+$/.test(raw))
52
+ throw new UsageError(`the limit must be an integer from ${WIP_LIMIT_MIN} to ${WIP_LIMIT_MAX}, or default (got ${raw})`);
53
+ const n = Number(raw);
54
+ if (n < WIP_LIMIT_MIN || n > WIP_LIMIT_MAX)
55
+ throw new UsageError(`the limit must be from ${WIP_LIMIT_MIN} to ${WIP_LIMIT_MAX} (got ${raw}); 0 holds every new start`);
56
+ return n;
57
+ }
58
+ /** The in-progress tickets as identifiers, tolerant of the cloud sending strings or objects; [] when absent. */
59
+ export function ticketIds(value) {
60
+ if (!Array.isArray(value))
61
+ return [];
62
+ return value
63
+ .map((t) => typeof t === "string"
64
+ ? t
65
+ : t && typeof t === "object"
66
+ ? String(t.identifier ??
67
+ t.id ??
68
+ "")
69
+ : "")
70
+ .filter((t) => t !== "");
71
+ }
72
+ /** One line naming what holds the WIP, capped so a big team does not scroll; null when the cloud sent none. */
73
+ function inProgressLine(view) {
74
+ const ids = ticketIds(view.inProgressTickets);
75
+ if (ids.length === 0)
76
+ return null;
77
+ const shown = ids.slice(0, 12);
78
+ 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`;
79
+ }
80
+ function line(key, view) {
81
+ const limit = view.limit;
82
+ const inProgress = view.inProgress;
83
+ const at = typeof limit === "number" &&
84
+ typeof inProgress === "number" &&
85
+ inProgress >= limit
86
+ ? " — at the limit: a new ticket waits until one in progress finishes"
87
+ : "";
88
+ return `${key}: ${typeof inProgress === "number" ? inProgress : "?"} in progress, limit ${typeof limit === "number" ? limit : "?"} (${sourceText(view.source)})${at}`;
89
+ }
90
+ export async function cmdProject(args, ctx) {
91
+ const [sub, action, value, ...extra] = positionals(args);
92
+ if (sub === "list") {
93
+ if (action !== undefined || value !== undefined || extra.length > 0)
94
+ throw new UsageError("project list takes no arguments");
95
+ const cfg = requireConfig(ctx);
96
+ const { doc } = await loadContract(ctx, cfg);
97
+ const route = doc.routes.find((r) => r.method === "GET" && r.path.endsWith(PROJECT_LIST_ROUTE_SUFFIX));
98
+ if (!route)
99
+ throw new UsageError("this cloud does not publish the tenant project list route yet (needs a newer cloud)");
100
+ const response = await apiClient(cfg, ctx).getJson(route.path, { accept: [403, 404] });
101
+ if (response.status === 403) {
102
+ const message = response.body
103
+ ?.message;
104
+ throw new UsageError(`listing tenant projects needs a workspace owner or admin${message ? ` (${message})` : ""}`);
105
+ }
106
+ if (response.status === 404)
107
+ throw new UsageError("this cloud does not serve tenant project listing yet (needs a newer cloud)");
108
+ const projects = Array.isArray(response.body?.repositories)
109
+ ? response.body.repositories
110
+ : [];
111
+ if (projects.length === 0 && !Array.isArray(response.body?.repositories)) {
112
+ throw new UsageError("the tenant project list response did not contain repositories");
113
+ }
114
+ if (args.json)
115
+ ctx.stdout(JSON.stringify(projects));
116
+ else {
117
+ for (const project of projects) {
118
+ ctx.stdout(`${project.name} | ${project.linearTeamKey ?? project.linearTeamId} | ${project.githubRepoOwner}/${project.githubRepoName} | ${project.status} | ${project.id}`);
119
+ }
120
+ }
121
+ return 0;
122
+ }
123
+ if (sub !== "wip-limit")
124
+ throw new UsageError("project needs wip-limit <get|set <n>|default>");
125
+ if (action !== "get" && action !== "set")
126
+ throw new UsageError("project wip-limit needs get, or set <n>|default");
127
+ if (action === "get" && value !== undefined)
128
+ throw new UsageError("project wip-limit get takes no value; set does");
129
+ if (extra.length > 0)
130
+ throw new UsageError("project wip-limit takes one value at most");
131
+ const limit = action === "set" ? parseLimit(value) : null;
132
+ const cfg = requireConfig(ctx);
133
+ const { doc } = await loadContract(ctx, cfg);
134
+ const team = resolveTeam(doc, flagString(args, "team"));
135
+ const key = team.key ?? team.id;
136
+ const client = apiClient(cfg, ctx);
137
+ const accept = [400, 403, 404];
138
+ const emit = (body, lines) => {
139
+ if (args.json)
140
+ ctx.stdout(JSON.stringify(body));
141
+ else
142
+ for (const l of lines)
143
+ ctx.stdout(l);
144
+ };
145
+ const refuse = (status, reply) => {
146
+ // The cloud's error bodies carry `error` (the code to match on) and a human `message`.
147
+ const body = reply ?? {};
148
+ const detail = body.message || body.reason
149
+ ? ` (${String(body.message ?? body.reason)})`
150
+ : "";
151
+ const why = status === 403
152
+ ? `setting the limit needs a workspace owner or admin; any member can read it with catalyst project wip-limit get${detail}`
153
+ : status === 404 && body.error === "team-unknown"
154
+ ? `this cloud does not know a team ${key}; check the key with catalyst team list${detail}`
155
+ : status === 404
156
+ ? "this Catalyst Cloud does not serve the wip-limit route yet (needs a newer cloud)"
157
+ : `${String(body.error ?? body.code ?? "bad request")}${detail}`;
158
+ emit({ status, team: { id: team.id, key }, ...body }, [
159
+ `refused (${status}): ${why}`,
160
+ ]);
161
+ return 1;
162
+ };
163
+ const before = await client.getJson(wipLimitPath(team.id), {
164
+ accept,
165
+ parseAccepted: true,
166
+ });
167
+ if (before.status !== 200)
168
+ return refuse(before.status, before.body);
169
+ if (action === "get") {
170
+ emit({ team: { id: team.id, key }, ...before.body }, [
171
+ line(key, before.body),
172
+ ...(inProgressLine(before.body) === null
173
+ ? []
174
+ : [inProgressLine(before.body)]),
175
+ `change it: catalyst project wip-limit set <n> --team ${key} (owner or admin; set default returns to the workspace value)`,
176
+ ]);
177
+ return 0;
178
+ }
179
+ const after = await client.postJson(WIP_LIMIT_ROUTE, { team: team.id, limit }, { accept });
180
+ if (after.status !== 200)
181
+ return refuse(after.status, after.body);
182
+ emit({
183
+ team: { id: team.id, key },
184
+ ...after.body,
185
+ previous: { limit: before.body.limit, source: before.body.source },
186
+ }, [
187
+ `${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" : ""}`,
188
+ line(key, after.body),
189
+ ]);
190
+ return 0;
191
+ }
package/dist/ready.js CHANGED
@@ -131,12 +131,22 @@ function unresolvedLine(u) {
131
131
  * identifier or a repository name, never a value, and nothing here reads a value. Any other check
132
132
  * keeps the generic line. */
133
133
  function teamCheckFix(label, c) {
134
+ const automationRules = {
135
+ linear_automation_pr_open: "On PR open",
136
+ linear_automation_pr_review: "On PR review request or activity",
137
+ linear_automation_pr_ready: "On PR ready for merge",
138
+ linear_automation_pr_merge: "On PR merge",
139
+ };
140
+ const rule = automationRules[c.id];
141
+ if (rule) {
142
+ return `in Linear, open Settings → Teams → ${label} → Workflow → Workflows & automations → Pull request and commit automations and set ${rule} to No action, including branch-specific overrides; then run catalyst team check ${label}`;
143
+ }
134
144
  const unresolved = unresolvedList(c.unresolved);
135
145
  const unresolvedNames = new Set(unresolved.map((u) => u.name));
136
146
  const names = nameList(c.names).filter((n) => !unresolvedNames.has(n));
137
147
  const parts = [];
138
148
  if (names.length > 0) {
139
- parts.push(`set ${names.join(", ")} on the repository's Environment page under Settings → Repositories ` +
149
+ parts.push(`set ${names.join(", ")} on the repository's Environment page under Settings → Your projects → the project → Repositories → the repository ` +
140
150
  `(team ${label}; ${names.length === 1 ? "it has" : "they have"} no value at repository or account scope)`);
141
151
  }
142
152
  for (const u of unresolved)
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
package/dist/repo.js ADDED
@@ -0,0 +1,297 @@
1
+ // repo.ts — `catalyst repo agents-block` and `catalyst repo agent-setup`: a repository's agent setup,
2
+ // read and changed in a checkout the person names, never through git and never through the cloud.
3
+ //
4
+ // repo agents-block <path> [--write] the one Catalyst block in AGENTS.md, marker-delimited,
5
+ // replaced in place on a rerun (never duplicated)
6
+ // repo agent-setup <path> [--apply] [--with-check]
7
+ // what the repository holds for agents, whether it is
8
+ // portable, the plan that would make it so, and --apply
9
+ //
10
+ // Portable means: AGENTS.md is canonical; CLAUDE.md is a thin file that imports it with a line that
11
+ // is exactly `@AGENTS.md` plus Claude-only notes; `.agents/skills` and `.agents/rules` are canonical
12
+ // and `.claude/skills` and `.claude/rules` are relative symlinks to them. Every write lands in the
13
+ // working tree only: committing, branching and the pull request are the person's, in the open.
14
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, statSync, symlinkSync, writeFileSync } from "node:fs";
15
+ import { dirname, join, relative, resolve } from "node:path";
16
+ import { flagBool, positionals } from "./args.js";
17
+ import { UsageError } from "./errors.js";
18
+ export const BLOCK_START = "<!-- catalyst:start -->";
19
+ export const BLOCK_END = "<!-- catalyst:end -->";
20
+ /** The two skills the block names. ONE place each: the packs are being consolidated, and a rename
21
+ * lands here, then `repo agents-block --write` brings every repository's block up to date. */
22
+ export const BLOCK_PROCESS_SKILL = "whats-happening";
23
+ export const BLOCK_DECISION_SKILL = "catalyst-sop";
24
+ /** ⭐ THE block, word for word. Harness-agnostic; names the skills, never the process. */
25
+ export const CATALYST_AGENTS_BLOCK = [
26
+ BLOCK_START,
27
+ "## Catalyst",
28
+ "",
29
+ `This repository is worked through Catalyst: tickets move through a pipeline of agent-run phases, and pull requests merge through its queue. Before you plan, branch, review or merge here, load the skill that explains the process instead of inferring it from this file: \`${BLOCK_PROCESS_SKILL}\` (how a ticket runs, what each phase produces, why work waits), and \`${BLOCK_DECISION_SKILL}\` when it is installed (what to do when work stalls on a decision). Keep this block as it is; onboarding maintains it.`,
30
+ BLOCK_END,
31
+ ].join("\n");
32
+ /** Where the block sits in `text`, or null. Only the FIRST start marker and the first end after it count. */
33
+ function locateBlock(text) {
34
+ const start = text.indexOf(BLOCK_START);
35
+ if (start === -1)
36
+ return null;
37
+ const endAt = text.indexOf(BLOCK_END, start);
38
+ if (endAt === -1)
39
+ return null;
40
+ return { start, end: endAt + BLOCK_END.length };
41
+ }
42
+ /** The block's state in an AGENTS.md text (`null` text = no file). */
43
+ export function blockState(text) {
44
+ if (text === null)
45
+ return "absent";
46
+ const at = locateBlock(text);
47
+ if (at === null)
48
+ return "missing";
49
+ return text.slice(at.start, at.end) === CATALYST_AGENTS_BLOCK ? "current" : "stale";
50
+ }
51
+ /** The text with the block current: replaced in place, appended after one blank line, or the whole
52
+ * file. Pure and idempotent: applying it to its own output changes nothing. */
53
+ export function withBlock(text) {
54
+ if (text === null || text.trim() === "")
55
+ return `${CATALYST_AGENTS_BLOCK}\n`;
56
+ const at = locateBlock(text);
57
+ if (at !== null)
58
+ return `${text.slice(0, at.start)}${CATALYST_AGENTS_BLOCK}${text.slice(at.end)}`;
59
+ const body = text.replace(/\s+$/, "");
60
+ return `${body}\n\n${CATALYST_AGENTS_BLOCK}\n`;
61
+ }
62
+ function linkState(root, rel, canonicalRel) {
63
+ const p = join(root, rel);
64
+ let st;
65
+ try {
66
+ st = lstatSync(p);
67
+ }
68
+ catch {
69
+ return { kind: "absent" };
70
+ }
71
+ if (st.isSymbolicLink()) {
72
+ const target = readlinkSync(p);
73
+ const resolved = resolve(dirname(p), target);
74
+ return { kind: "symlink", target, resolvesToCanonical: resolved === resolve(root, canonicalRel) && existsSync(resolved) };
75
+ }
76
+ return st.isDirectory() ? { kind: "dir" } : { kind: "file" };
77
+ }
78
+ const OTHER_MARKERS = [".codex", ".cursor", ".cursorrules", ".windsurfrules", "GEMINI.md", ".github/copilot-instructions.md"];
79
+ /** Read a checkout's agent setup. Never writes. */
80
+ export function inspectRepo(root) {
81
+ const agentsPath = join(root, "AGENTS.md");
82
+ const claudePath = join(root, "CLAUDE.md");
83
+ const agentsText = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : null;
84
+ const claudeText = existsSync(claudePath) ? readFileSync(claudePath, "utf8") : null;
85
+ const claudeLines = claudeText === null ? [] : claudeText.split("\n");
86
+ const importsAgentsMd = claudeLines.some((l) => l.trim() === "@AGENTS.md");
87
+ const otherLines = claudeLines.filter((l) => l.trim() !== "" && l.trim() !== "@AGENTS.md").length;
88
+ const report = {
89
+ path: root,
90
+ agentsMd: { present: agentsText !== null, lines: agentsText === null ? 0 : agentsText.replace(/\n$/, "").split("\n").length, bytes: agentsText === null ? 0 : Buffer.byteLength(agentsText), block: blockState(agentsText) },
91
+ claudeMd: { present: claudeText !== null, importsAgentsMd, otherLines },
92
+ agentsDir: { skills: isDir(join(root, ".agents", "skills")), rules: isDir(join(root, ".agents", "rules")) },
93
+ claudeDir: { skills: linkState(root, ".claude/skills", ".agents/skills"), rules: linkState(root, ".claude/rules", ".agents/rules") },
94
+ others: OTHER_MARKERS.filter((m) => existsSync(join(root, m))),
95
+ verdict: "portable",
96
+ plan: [],
97
+ blockers: [],
98
+ };
99
+ // CLAUDE.md with guidance of its own and no import: its content belongs in AGENTS.md.
100
+ if (report.claudeMd.present && !importsAgentsMd) {
101
+ report.plan.push(report.agentsMd.present
102
+ ? "append CLAUDE.md's content to AGENTS.md and leave CLAUDE.md as the thin importer (`@AGENTS.md` plus Claude-only notes)"
103
+ : "create AGENTS.md from CLAUDE.md's content and leave CLAUDE.md as the thin importer (`@AGENTS.md`)");
104
+ }
105
+ else if (!report.agentsMd.present && !report.claudeMd.present) {
106
+ report.plan.push("create AGENTS.md (the Catalyst block starts it; add the repository's own guidance there)");
107
+ }
108
+ for (const name of ["skills", "rules"]) {
109
+ const c = report.claudeDir[name];
110
+ const canonical = report.agentsDir[name];
111
+ if (c.kind === "dir" && !canonical)
112
+ report.plan.push(`move .claude/${name} to .agents/${name} and leave .claude/${name} as a relative symlink to it`);
113
+ else if (c.kind === "dir" && canonical)
114
+ report.blockers.push(`.claude/${name} and .agents/${name} are both real directories: merge them by hand, then leave .claude/${name} as a relative symlink`);
115
+ else if (c.kind === "symlink" && !c.resolvesToCanonical)
116
+ report.blockers.push(`.claude/${name} is a symlink to ${c.target}, not to .agents/${name}: point it there by hand`);
117
+ else if (c.kind === "absent" && canonical)
118
+ report.plan.push(`add .claude/${name} as a relative symlink to .agents/${name}`);
119
+ else if (c.kind === "file")
120
+ report.blockers.push(`.claude/${name} is a file, not a directory: look at it by hand`);
121
+ }
122
+ if (report.blockers.length > 0)
123
+ report.verdict = "needs_hand_merge";
124
+ else if (report.plan.length > 0)
125
+ report.verdict = "convertible";
126
+ return report;
127
+ }
128
+ function isDir(p) {
129
+ try {
130
+ return statSync(p).isDirectory();
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ }
136
+ const THIN_CLAUDE_MD = "# CLAUDE.md\n\nAll project context for any coding agent is in AGENTS.md, imported below. Only Claude-specific notes belong in this file.\n\n@AGENTS.md\n";
137
+ /** Perform a convertible plan in the working tree. Refuses when anything needs a hand merge. */
138
+ export function applyPlan(root, before) {
139
+ if (before.verdict === "needs_hand_merge")
140
+ throw new UsageError(`nothing applied: ${before.blockers.join("; ")}`);
141
+ const done = [];
142
+ const agentsPath = join(root, "AGENTS.md");
143
+ const claudePath = join(root, "CLAUDE.md");
144
+ if (before.claudeMd.present && !before.claudeMd.importsAgentsMd) {
145
+ const claudeText = readFileSync(claudePath, "utf8");
146
+ const existing = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8").replace(/\s+$/, "") : "";
147
+ const merged = existing === "" ? claudeText.replace(/\s+$/, "") + "\n" : `${existing}\n\n${claudeText.replace(/\s+$/, "")}\n`;
148
+ writeFileSync(agentsPath, merged);
149
+ writeFileSync(claudePath, THIN_CLAUDE_MD);
150
+ done.push(before.agentsMd.present ? "appended CLAUDE.md's content to AGENTS.md; CLAUDE.md now imports it" : "created AGENTS.md from CLAUDE.md; CLAUDE.md now imports it");
151
+ }
152
+ else if (!before.agentsMd.present && !before.claudeMd.present) {
153
+ writeFileSync(agentsPath, withBlock(null));
154
+ done.push("created AGENTS.md holding the Catalyst block");
155
+ }
156
+ for (const name of ["skills", "rules"]) {
157
+ const c = before.claudeDir[name];
158
+ const canonicalPath = join(root, ".agents", name);
159
+ const claudeLink = join(root, ".claude", name);
160
+ if (c.kind === "dir" && !before.agentsDir[name]) {
161
+ mkdirSync(join(root, ".agents"), { recursive: true });
162
+ renameSync(claudeLink, canonicalPath);
163
+ symlinkSync(relative(dirname(claudeLink), canonicalPath), claudeLink, "dir");
164
+ done.push(`moved .claude/${name} to .agents/${name} and left a relative symlink`);
165
+ }
166
+ else if (c.kind === "absent" && before.agentsDir[name]) {
167
+ mkdirSync(join(root, ".claude"), { recursive: true });
168
+ symlinkSync(relative(dirname(claudeLink), canonicalPath), claudeLink, "dir");
169
+ done.push(`added .claude/${name} as a relative symlink to .agents/${name}`);
170
+ }
171
+ }
172
+ return done;
173
+ }
174
+ /** The lightweight check a customer can run in CI: node only, no dependencies, exit 1 on a finding. */
175
+ export const AGENTS_MD_CHECK_SCRIPT = `#!/usr/bin/env node
176
+ // agents-md-check.mjs — keeps this repository's agent setup portable. Run it in CI:
177
+ // node scripts/agents-md-check.mjs
178
+ // It fails when CLAUDE.md stops importing AGENTS.md, when .claude/skills or .claude/rules stops being
179
+ // a relative symlink into .agents/, when AGENTS.md outgrows its budget, or when a repository path
180
+ // AGENTS.md cites no longer exists. Budgets: AGENTS_MD_MAX_LINES and AGENTS_MD_MAX_BYTES (env).
181
+ import { existsSync, lstatSync, readFileSync, readlinkSync } from "node:fs";
182
+ import { dirname, resolve } from "node:path";
183
+
184
+ const root = process.cwd();
185
+ const maxLines = Number(process.env.AGENTS_MD_MAX_LINES ?? 120);
186
+ const maxBytes = Number(process.env.AGENTS_MD_MAX_BYTES ?? 16 * 1024);
187
+ const findings = [];
188
+ const agents = existsSync("AGENTS.md") ? readFileSync("AGENTS.md", "utf8") : null;
189
+ if (agents === null) findings.push("AGENTS.md is missing");
190
+ else {
191
+ const lines = agents.replace(/\\n$/, "").split("\\n");
192
+ if (lines.length > maxLines) findings.push(\`AGENTS.md is \${lines.length} lines (budget \${maxLines})\`);
193
+ if (Buffer.byteLength(agents) > maxBytes) findings.push(\`AGENTS.md is \${Buffer.byteLength(agents)} bytes (budget \${maxBytes})\`);
194
+ for (const m of agents.matchAll(/\`([\\w.-]+(?:\\/[\\w.-]+)+)\`/g)) {
195
+ const p = m[1];
196
+ if (!existsSync(resolve(root, p))) findings.push(\`AGENTS.md cites \${p}, which does not exist\`);
197
+ }
198
+ }
199
+ if (existsSync("CLAUDE.md")) {
200
+ const n = readFileSync("CLAUDE.md", "utf8").split("\\n").filter((l) => l.trim() === "@AGENTS.md").length;
201
+ if (n !== 1) findings.push(\`CLAUDE.md must import AGENTS.md on exactly one line reading @AGENTS.md (found \${n})\`);
202
+ }
203
+ for (const name of ["skills", "rules"]) {
204
+ const link = \`.claude/\${name}\`;
205
+ const canonical = \`.agents/\${name}\`;
206
+ if (!existsSync(canonical) && !existsSync(link)) continue;
207
+ let st;
208
+ try { st = lstatSync(link); } catch { findings.push(\`\${link} is missing; make it a relative symlink to \${canonical}\`); continue; }
209
+ if (!st.isSymbolicLink()) { findings.push(\`\${link} is a real \${st.isDirectory() ? "directory" : "file"}; make \${canonical} canonical and \${link} a relative symlink\`); continue; }
210
+ const target = readlinkSync(link);
211
+ if (target.startsWith("/")) findings.push(\`\${link} is an absolute symlink (\${target}); make it relative\`);
212
+ if (resolve(dirname(link), target) !== resolve(root, canonical)) findings.push(\`\${link} points at \${target}, not \${canonical}\`);
213
+ }
214
+ for (const f of findings) console.error(\`agents-md-check: \${f}\`);
215
+ if (findings.length > 0) process.exit(1);
216
+ console.log("agents-md-check: portable");
217
+ `;
218
+ const describeLink = (s) => (s.kind === "symlink" ? `symlink → ${s.target}${s.resolvesToCanonical ? "" : " (not the canonical directory)"}` : s.kind === "dir" ? "real directory" : s.kind === "file" ? "a file" : "absent");
219
+ export function reportLines(r) {
220
+ const lines = [
221
+ `repository: ${r.path}`,
222
+ `AGENTS.md: ${r.agentsMd.present ? `${r.agentsMd.lines} lines, ${r.agentsMd.bytes} bytes; Catalyst block ${r.agentsMd.block}` : "absent"}`,
223
+ `CLAUDE.md: ${r.claudeMd.present ? (r.claudeMd.importsAgentsMd ? `imports AGENTS.md${r.claudeMd.otherLines ? ` plus ${r.claudeMd.otherLines} lines of Claude-only notes` : ""}` : `${r.claudeMd.otherLines} lines of its own guidance, no @AGENTS.md import`) : "absent"}`,
224
+ `.agents/skills: ${r.agentsDir.skills ? "present" : "absent"}; .claude/skills: ${describeLink(r.claudeDir.skills)}`,
225
+ `.agents/rules: ${r.agentsDir.rules ? "present" : "absent"}; .claude/rules: ${describeLink(r.claudeDir.rules)}`,
226
+ ];
227
+ if (r.others.length > 0)
228
+ lines.push(`also present: ${r.others.join(", ")}`);
229
+ lines.push(`verdict: ${r.verdict === "portable" ? "portable (AGENTS.md canonical, Claude files import or link to it)" : r.verdict === "convertible" ? "convertible; --apply performs this plan in the working tree:" : "needs a hand merge before --apply:"}`);
230
+ for (const p of r.plan)
231
+ lines.push(` - ${p}`);
232
+ for (const b of r.blockers)
233
+ lines.push(` ⛔ ${b}`);
234
+ if (r.agentsMd.block !== "current")
235
+ lines.push(` - the Catalyst block is ${r.agentsMd.block}: catalyst repo agents-block ${r.path} --write`);
236
+ return lines;
237
+ }
238
+ function checkoutRoot(args, sub) {
239
+ const [, path, ...extra] = positionals(args);
240
+ if (!path)
241
+ throw new UsageError(`repo ${sub} needs the checkout's path`);
242
+ if (extra.length > 0)
243
+ throw new UsageError(`repo ${sub} takes one path`);
244
+ const root = resolve(path);
245
+ if (!isDir(root))
246
+ throw new UsageError(`${path} is not a directory on this machine`);
247
+ return root;
248
+ }
249
+ export async function cmdRepo(args, ctx) {
250
+ const [sub] = positionals(args);
251
+ if (sub !== "agents-block" && sub !== "agent-setup")
252
+ throw new UsageError("repo needs agents-block <path> [--write] or agent-setup <path> [--apply] [--with-check]");
253
+ const root = checkoutRoot(args, sub);
254
+ const emit = (body, lines) => { if (args.json)
255
+ ctx.stdout(JSON.stringify(body));
256
+ else
257
+ for (const l of lines)
258
+ ctx.stdout(l); };
259
+ if (sub === "agents-block") {
260
+ if (flagBool(args, "apply") || flagBool(args, "with-check"))
261
+ throw new UsageError("--apply and --with-check belong to repo agent-setup");
262
+ const file = join(root, "AGENTS.md");
263
+ const before = existsSync(file) ? readFileSync(file, "utf8") : null;
264
+ const state = blockState(before);
265
+ if (!flagBool(args, "write")) {
266
+ const hint = state === "current" ? "nothing to do" : `run with --write to ${state === "absent" ? "create AGENTS.md with it" : state === "missing" ? "append it" : "bring it up to date"} (in the working tree; commit it on a branch and open a pull request)`;
267
+ emit({ path: root, agentsMd: before !== null, block: state, changed: false }, [`AGENTS.md: Catalyst block ${state} — ${hint}`]);
268
+ return state === "current" ? 0 : 1;
269
+ }
270
+ const after = withBlock(before);
271
+ const changed = after !== before;
272
+ if (changed)
273
+ writeFileSync(file, after);
274
+ emit({ path: root, agentsMd: true, block: "current", changed, was: state }, [
275
+ changed ? `AGENTS.md: Catalyst block ${state === "absent" ? "written to a new AGENTS.md" : state === "missing" ? "appended" : "updated in place"}; commit it on a branch and open a pull request` : "AGENTS.md: Catalyst block already current; nothing changed",
276
+ ]);
277
+ return 0;
278
+ }
279
+ if (flagBool(args, "write"))
280
+ throw new UsageError("--write belongs to repo agents-block");
281
+ const before = inspectRepo(root);
282
+ const applied = [];
283
+ if (flagBool(args, "apply"))
284
+ applied.push(...applyPlan(root, before));
285
+ if (flagBool(args, "with-check")) {
286
+ const dir = join(root, "scripts");
287
+ mkdirSync(dir, { recursive: true });
288
+ const target = join(dir, "agents-md-check.mjs");
289
+ const same = existsSync(target) && readFileSync(target, "utf8") === AGENTS_MD_CHECK_SCRIPT;
290
+ if (!same)
291
+ writeFileSync(target, AGENTS_MD_CHECK_SCRIPT, { mode: 0o755 });
292
+ applied.push(same ? "scripts/agents-md-check.mjs already current" : "wrote scripts/agents-md-check.mjs (run it in CI: node scripts/agents-md-check.mjs)");
293
+ }
294
+ const after = applied.length > 0 ? inspectRepo(root) : before;
295
+ emit({ ...after, applied }, [...(applied.length ? ["applied:", ...applied.map((a) => ` - ${a}`)] : []), ...reportLines(after)]);
296
+ return after.verdict === "portable" && after.agentsMd.block === "current" ? 0 : 1;
297
+ }
@@ -1,4 +1,4 @@
1
- // runtime-verb.ts — CTC-2158, Tier 2. `catalyst-skills runtime status|install|path|uninstall`: the
1
+ // runtime-verb.ts — CTC-2158, Tier 2. `catalyst runtime status|install|path|uninstall`: the
2
2
  // verb every unsupported-runtime message points at. It downloads the pinned Node named by
3
3
  // package.json's `catalystCloud.pinnedNode`, verifies it, and unpacks it under this CLI's own
4
4
  // cache — it never touches the machine's default Node and needs no admin rights.
package/dist/secret.js CHANGED
@@ -1,4 +1,4 @@
1
- // secret.ts — `catalyst-skills secret set|import`: put a repository's secrets into the cloud from the
1
+ // secret.ts — `catalyst secret set|import`: put a repository's secrets into the cloud from the
2
2
  // terminal, as the person who is logged in (CTC-3549). The cloud gates both on an admin or owner seat,
3
3
  // encrypts the value, and records who wrote it and where it came from, never the value itself.
4
4
  //
@@ -25,6 +25,7 @@ import { requireConfig } from "./config.js";
25
25
  import { CliError, UsageError } from "./errors.js";
26
26
  import { promptSecret, stdinIsTty } from "./prompt.js";
27
27
  import { apiClient } from "./transport.js";
28
+ import { filterEnvFileNames } from "./env/parse-env.js";
28
29
  export const SECRETS_PATH = "/me/secrets";
29
30
  export const SECRETS_IMPORT_PATH = "/me/secrets/import";
30
31
  const NAME_RE = /^[A-Z][A-Z0-9_]*$/;
@@ -105,7 +106,19 @@ export async function cmdSecret(args, ctx, deps = {}) {
105
106
  throw new UsageError(sub === "set" ? "secret set takes one NAME" : "secret import takes one file (a .env file)");
106
107
  }
107
108
  const repo = requireRepo(args, sub);
108
- return sub === "set" ? setOne(args, ctx, deps, rest[0], repo) : importFile(args, ctx, rest[0], repo);
109
+ return sub === "set" ? setOne(args, ctx, deps, rest[0], repo) : importFile(args, ctx, rest[0], repo, requestedNames(args));
110
+ }
111
+ function requestedNames(args) {
112
+ const raw = flagList(args, "names");
113
+ if (raw.length === 0)
114
+ return undefined;
115
+ const names = [...new Set(raw.flatMap((part) => part.split(",").map((name) => name.trim())).filter(Boolean))].sort();
116
+ if (names.length === 0)
117
+ throw new UsageError("--names needs one or more variable names");
118
+ const invalid = names.find((name) => !NAME_RE.test(name));
119
+ if (invalid !== undefined)
120
+ throw new UsageError(`--names includes an invalid env-style name: ${invalid}`);
121
+ return names;
109
122
  }
110
123
  async function setOne(args, ctx, deps, name, repo) {
111
124
  if (!NAME_RE.test(name))
@@ -150,7 +163,7 @@ async function setOne(args, ctx, deps, name, repo) {
150
163
  ctx.stdout(line);
151
164
  return 0;
152
165
  }
153
- async function importFile(args, ctx, file, repo) {
166
+ async function importFile(args, ctx, file, repo, names) {
154
167
  if (flagString(args, "command") !== undefined)
155
168
  throw new UsageError("--command belongs to secret set");
156
169
  let text;
@@ -160,6 +173,9 @@ async function importFile(args, ctx, file, repo) {
160
173
  catch (err) {
161
174
  throw new UsageError(`could not read ${file}: ${err instanceof Error ? err.message : String(err)}`);
162
175
  }
176
+ const filtered = names === undefined ? { text, found: [] } : filterEnvFileNames(text, new Set(names));
177
+ if (names !== undefined)
178
+ text = filtered.text;
163
179
  const cfg = requireConfig(ctx);
164
180
  const rotate = flagList(args, "rotate");
165
181
  const res = await apiClient(cfg, ctx).postJson(SECRETS_IMPORT_PATH, { repo, text, rotateExisting: rotate, source: `import: ${basename(file)}` }, { accept: ACCEPT });
@@ -175,13 +191,18 @@ async function importFile(args, ctx, file, repo) {
175
191
  const rotated = body.rotated ?? [];
176
192
  const errors = body.errors ?? [];
177
193
  if (args.json) {
178
- ctx.stdout(JSON.stringify({ repo, created, rotated, errors, declared: body.declared ?? null }));
194
+ ctx.stdout(JSON.stringify({ repo, created, rotated, errors, declared: body.declared ?? null, ...(names === undefined ? {} : { requested: names, notFoundInFile: names.filter((name) => !filtered.found.includes(name)) }) }));
179
195
  return errors.length > 0 ? 1 : 0;
180
196
  }
181
197
  const stored = created.length + rotated.length;
182
198
  ctx.stdout(stored === 0 ? `stored nothing for ${repo}` : `stored ${stored} for ${repo}: ${[...created, ...rotated].join(", ")}`);
183
199
  if (rotated.length > 0)
184
200
  ctx.stdout(` replaced: ${rotated.join(", ")}`);
201
+ if (names !== undefined) {
202
+ const missing = names.filter((name) => !filtered.found.includes(name));
203
+ if (missing.length > 0)
204
+ ctx.stdout(`not found in ${basename(file)}: ${missing.join(", ")}`);
205
+ }
185
206
  for (const e of errors)
186
207
  ctx.stdout(`not stored: ${e.name} (${IMPORT_REASONS[e.reason] ?? e.reason})`);
187
208
  for (const line of renderDeclared(body.declared, repo))