@catalyst-cloud/cli 0.13.1 → 0.14.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 (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +2 -2
  3. package/dist/args.js +291 -69
  4. package/dist/capabilities.js +2 -0
  5. package/dist/cli.js +109 -17
  6. package/dist/config.js +3 -1
  7. package/dist/env/draft.js +134 -0
  8. package/dist/env/legacy.js +54 -0
  9. package/dist/env/parse-env.js +50 -0
  10. package/dist/env/settings-toml.js +66 -0
  11. package/dist/env.js +126 -26
  12. package/dist/legacy.js +16 -17
  13. package/dist/onboard-consent.js +65 -0
  14. package/dist/onboard.js +836 -0
  15. package/dist/project.js +75 -13
  16. package/dist/ready.js +11 -1
  17. package/dist/secret.js +24 -3
  18. package/dist/var.js +118 -0
  19. package/package.json +3 -2
  20. package/skills/catalyst-github/SKILL.md +1 -1
  21. package/skills/catalyst-linear/SKILL.md +1 -1
  22. package/skills/catalyst-onboard/SKILL.md +1 -1
  23. package/skills/catalyst-onboard/references/declaring-a-repository.md +19 -12
  24. package/skills/catalyst-onboard/references/reading-ready.md +1 -1
  25. package/skills/catalyst-onboard/references/the-one-path.md +4 -4
  26. package/skills/catalyst-onboard/references/what-the-browser-owns.md +2 -2
  27. package/skills/catalyst-onboard/scripts/lib/project-coverage.mjs +53 -0
  28. package/skills/catalyst-onboard/scripts/where-am-i.mjs +885 -196
  29. package/skills/run-this-project/SKILL.md +1 -1
  30. package/skills/unstick/SKILL.md +1 -1
  31. package/skills/what-needs-me/SKILL.md +1 -1
  32. package/skills/what-this-repo-needs/SKILL.md +4 -4
  33. package/skills/what-this-repo-needs/scripts/check.mjs +12 -13
  34. package/skills/whats-happening/SKILL.md +19 -35
  35. package/skills/whats-happening/references/coding-accounts.md +6 -19
  36. package/skills/whats-happening/references/routing-work.md +4 -15
  37. package/skills/whats-happening/references/settings-and-where-they-live.md +15 -18
  38. package/skills/whats-happening/references/stages-and-mapping.md +12 -18
  39. package/skills/whats-happening/references/status-reply.md +11 -29
  40. package/skills/whats-happening/references/the-ladder.md +15 -25
  41. package/skills/whats-happening/references/what-runs-next.md +7 -26
  42. package/skills/whats-happening/references/when-a-phase-fails.md +16 -22
  43. package/skills/whats-happening/references/why-is-it-stuck.md +38 -48
  44. package/dist/env/declaration-rules.js +0 -163
package/dist/project.js CHANGED
@@ -18,7 +18,13 @@ export const WIP_LIMIT_MAX = 9999;
18
18
  * two strings, so `capabilities` can judge them. */
19
19
  export const WIP_LIMIT_ROUTE = "/api/v1/agent/team-wip-limit";
20
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" };
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
+ };
22
28
  const sourceText = (source) => SOURCE_TEXT[String(source)] ?? String(source ?? "unknown");
23
29
  /** The project `--team` names, or the only mapped project when the flag is absent. Never a guess between two. */
24
30
  export function resolveTeam(doc, wanted) {
@@ -54,7 +60,13 @@ export function ticketIds(value) {
54
60
  if (!Array.isArray(value))
55
61
  return [];
56
62
  return value
57
- .map((t) => (typeof t === "string" ? t : t && typeof t === "object" ? String(t.identifier ?? t.id ?? "") : ""))
63
+ .map((t) => typeof t === "string"
64
+ ? t
65
+ : t && typeof t === "object"
66
+ ? String(t.identifier ??
67
+ t.id ??
68
+ "")
69
+ : "")
58
70
  .filter((t) => t !== "");
59
71
  }
60
72
  /** One line naming what holds the WIP, capped so a big team does not scroll; null when the cloud sent none. */
@@ -68,11 +80,46 @@ function inProgressLine(view) {
68
80
  function line(key, view) {
69
81
  const limit = view.limit;
70
82
  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" : "";
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
+ : "";
72
88
  return `${key}: ${typeof inProgress === "number" ? inProgress : "?"} in progress, limit ${typeof limit === "number" ? limit : "?"} (${sourceText(view.source)})${at}`;
73
89
  }
74
90
  export async function cmdProject(args, ctx) {
75
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
+ }
76
123
  if (sub !== "wip-limit")
77
124
  throw new UsageError("project needs wip-limit <get|set <n>|default>");
78
125
  if (action !== "get" && action !== "set")
@@ -88,15 +135,19 @@ export async function cmdProject(args, ctx) {
88
135
  const key = team.key ?? team.id;
89
136
  const client = apiClient(cfg, ctx);
90
137
  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); };
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
+ };
96
145
  const refuse = (status, reply) => {
97
146
  // The cloud's error bodies carry `error` (the code to match on) and a human `message`.
98
147
  const body = reply ?? {};
99
- const detail = body.message || body.reason ? ` (${String(body.message ?? body.reason)})` : "";
148
+ const detail = body.message || body.reason
149
+ ? ` (${String(body.message ?? body.reason)})`
150
+ : "";
100
151
  const why = status === 403
101
152
  ? `setting the limit needs a workspace owner or admin; any member can read it with catalyst project wip-limit get${detail}`
102
153
  : status === 404 && body.error === "team-unknown"
@@ -104,16 +155,23 @@ export async function cmdProject(args, ctx) {
104
155
  : status === 404
105
156
  ? "this Catalyst Cloud does not serve the wip-limit route yet (needs a newer cloud)"
106
157
  : `${String(body.error ?? body.code ?? "bad request")}${detail}`;
107
- emit({ status, team: { id: team.id, key }, ...body }, [`refused (${status}): ${why}`]);
158
+ emit({ status, team: { id: team.id, key }, ...body }, [
159
+ `refused (${status}): ${why}`,
160
+ ]);
108
161
  return 1;
109
162
  };
110
- const before = await client.getJson(wipLimitPath(team.id), { accept, parseAccepted: true });
163
+ const before = await client.getJson(wipLimitPath(team.id), {
164
+ accept,
165
+ parseAccepted: true,
166
+ });
111
167
  if (before.status !== 200)
112
168
  return refuse(before.status, before.body);
113
169
  if (action === "get") {
114
170
  emit({ team: { id: team.id, key }, ...before.body }, [
115
171
  line(key, before.body),
116
- ...(inProgressLine(before.body) === null ? [] : [inProgressLine(before.body)]),
172
+ ...(inProgressLine(before.body) === null
173
+ ? []
174
+ : [inProgressLine(before.body)]),
117
175
  `change it: catalyst project wip-limit set <n> --team ${key} (owner or admin; set default returns to the workspace value)`,
118
176
  ]);
119
177
  return 0;
@@ -121,7 +179,11 @@ export async function cmdProject(args, ctx) {
121
179
  const after = await client.postJson(WIP_LIMIT_ROUTE, { team: team.id, limit }, { accept });
122
180
  if (after.status !== 200)
123
181
  return refuse(after.status, after.body);
124
- emit({ team: { id: team.id, key }, ...after.body, previous: { limit: before.body.limit, source: before.body.source } }, [
182
+ emit({
183
+ team: { id: team.id, key },
184
+ ...after.body,
185
+ previous: { limit: before.body.limit, source: before.body.source },
186
+ }, [
125
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" : ""}`,
126
188
  line(key, after.body),
127
189
  ]);
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/secret.js CHANGED
@@ -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))
package/dist/var.js ADDED
@@ -0,0 +1,118 @@
1
+ // var.ts — plain repository/workspace environment values. Values are sent only to the private
2
+ // /me/env-vars routes and are never included in CLI output.
3
+ import { readFileSync } from "node:fs";
4
+ import { basename } from "node:path";
5
+ import { flagList, flagString, positionals } from "./args.js";
6
+ import { requireConfig } from "./config.js";
7
+ import { readContractCache } from "./contract.js";
8
+ import { UsageError } from "./errors.js";
9
+ import { parseEnvAssignments } from "./env/parse-env.js";
10
+ import { promptSecret, stdinIsTty } from "./prompt.js";
11
+ import { apiClient } from "./transport.js";
12
+ export const ENV_VARS_PATH = "/me/env-vars";
13
+ const NAME_RE = /^[A-Z][A-Z0-9_]*$/;
14
+ const REPO_RE = /^[^/\s]+\/[^/\s]+$/;
15
+ const ACCEPT = [400, 401, 403, 404, 409, 422];
16
+ function scope(args) {
17
+ const repo = flagString(args, "repo");
18
+ if (repo !== undefined && !REPO_RE.test(repo))
19
+ throw new UsageError(`--repo must be owner/name (got "${repo}")`);
20
+ return repo === undefined ? { scope: "tenant" } : { scope: "repo", repo };
21
+ }
22
+ function selectedNames(args) {
23
+ const raw = flagList(args, "names");
24
+ if (raw.length === 0)
25
+ return undefined;
26
+ const names = [...new Set(raw.flatMap((part) => part.split(",").map((name) => name.trim())).filter(Boolean))].sort();
27
+ if (names.length === 0)
28
+ throw new UsageError("--names needs one or more variable names");
29
+ const bad = names.find((name) => !NAME_RE.test(name));
30
+ if (bad !== undefined)
31
+ throw new UsageError(`--names includes an invalid env-style name: ${bad}`);
32
+ return names;
33
+ }
34
+ function scopeBody(target, ctx) {
35
+ if (target.scope === "tenant")
36
+ return { scope: "tenant" };
37
+ const [owner, name] = target.repo.split("/");
38
+ const repositories = readContractCache(ctx.home)?.doc.merge.repositories ?? [];
39
+ const repo = repositories.find((entry) => entry.owner.toLocaleLowerCase() === owner.toLocaleLowerCase() && entry.name.toLocaleLowerCase() === name.toLocaleLowerCase());
40
+ if (!repo)
41
+ throw new UsageError(`--repo ${target.repo} is not in the cached workspace contract; refresh catalyst contract and try again`);
42
+ return { scope: "repo", repoId: repo.repoId };
43
+ }
44
+ async function stdinText() {
45
+ const chunks = [];
46
+ for await (const chunk of process.stdin)
47
+ chunks.push(Buffer.from(chunk));
48
+ return Buffer.concat(chunks).toString("utf8");
49
+ }
50
+ export async function cmdVar(args, ctx, deps = {}) {
51
+ const [sub, ...rest] = positionals(args);
52
+ if (sub !== "set" && sub !== "import")
53
+ throw new UsageError(`unknown var subcommand "${sub ?? ""}": set | import`);
54
+ if (rest.length !== 1)
55
+ throw new UsageError(sub === "set" ? "var set takes one NAME" : "var import takes one file (a .env file)");
56
+ const target = scope(args);
57
+ const writeScope = scopeBody(target, ctx);
58
+ const cfg = requireConfig(ctx);
59
+ const client = deps.client ?? apiClient(cfg, ctx);
60
+ if (sub === "set") {
61
+ const name = rest[0];
62
+ if (!NAME_RE.test(name))
63
+ throw new UsageError(`"${name}" is not an env-style name (A-Z, 0-9 and _, starting with a letter)`);
64
+ const raw = (deps.isTty ?? stdinIsTty)()
65
+ ? await (deps.prompt ?? promptSecret)(`Value for ${name} (input hidden): `)
66
+ : await (deps.readStdin ?? stdinText)();
67
+ const value = raw.endsWith("\r\n") ? raw.slice(0, -2) : raw.endsWith("\n") ? raw.slice(0, -1) : raw;
68
+ if (value.length === 0)
69
+ throw new UsageError("var set needs a non-empty value");
70
+ const reply = await client.postJson(ENV_VARS_PATH, { ...writeScope, name, value }, { accept: ACCEPT });
71
+ if (reply.status !== 200 && reply.status !== 201) {
72
+ ctx.stderr(`var set refused (${reply.status}): ${reply.body.message ?? reply.body.error ?? "no reason given"}`);
73
+ return 1;
74
+ }
75
+ if (args.json)
76
+ ctx.stdout(JSON.stringify({ name, scope: target.scope, repo: target.repo ?? null, created: reply.body.created ?? null }));
77
+ else
78
+ ctx.stdout(`stored ${name} (${target.scope}${target.repo ? `: ${target.repo}` : ""}${reply.body.created ? "; new" : "; updated"})`);
79
+ return 0;
80
+ }
81
+ let text;
82
+ try {
83
+ text = readFileSync(rest[0], "utf8");
84
+ }
85
+ catch (err) {
86
+ throw new UsageError(`could not read ${rest[0]}: ${err instanceof Error ? err.message : String(err)}`);
87
+ }
88
+ const names = selectedNames(args);
89
+ const entries = parseEnvAssignments(text, names === undefined ? undefined : new Set(names));
90
+ const found = new Set(entries.map(({ name }) => name));
91
+ const created = [];
92
+ const updated = [];
93
+ const errors = [];
94
+ for (const entry of entries) {
95
+ const reply = await client.postJson(ENV_VARS_PATH, { ...writeScope, ...entry }, { accept: ACCEPT });
96
+ if (reply.status !== 200 && reply.status !== 201) {
97
+ errors.push({ name: entry.name, reason: reply.body.message ?? reply.body.error ?? `HTTP ${reply.status}` });
98
+ }
99
+ else if (reply.body.created)
100
+ created.push(entry.name);
101
+ else
102
+ updated.push(entry.name);
103
+ }
104
+ const absent = names?.filter((name) => !found.has(name)) ?? [];
105
+ if (args.json)
106
+ ctx.stdout(JSON.stringify({ scope: target.scope, repo: target.repo ?? null, created, updated, errors, ...(names === undefined ? {} : { requested: names, notFoundInFile: absent }) }));
107
+ else {
108
+ const count = created.length + updated.length;
109
+ ctx.stdout(count === 0 ? `stored nothing (${target.scope}${target.repo ? `: ${target.repo}` : ""})` : `stored ${count} variables: ${[...created, ...updated].join(", ")}`);
110
+ if (updated.length > 0)
111
+ ctx.stdout(` updated: ${updated.join(", ")}`);
112
+ if (absent.length > 0)
113
+ ctx.stdout(`not found in ${basename(rest[0])}: ${absent.join(", ")}`);
114
+ for (const error of errors)
115
+ ctx.stdout(`not stored: ${error.name} (${error.reason})`);
116
+ }
117
+ return errors.length > 0 ? 1 : 0;
118
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.13.1",
4
- "description": "Customer skill bundle for Catalyst Cloud, named from the executive's seat — get set up, see what is happening, see what needs you, run a project, unstick what is stuck — plus the catalyst CLI that reads through the Catalyst Cloud SDK, writes through the agent proxy, and connects your machine to your cloud account with one command. The skills directory is the roster; this line never counts it.",
3
+ "version": "0.14.1",
4
+ "description": "Customer skill bundle for Catalyst Cloud, named from the executive's seat \u2014 get set up, see what is happening, see what needs you, run a project, unstick what is stuck \u2014 plus the catalyst CLI that reads through the Catalyst Cloud SDK, writes through the agent proxy, and connects your machine to your cloud account with one command. The skills directory is the roster; this line never counts it.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "publishConfig": {
@@ -50,6 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@catalyst-cloud/sdk": "^0.13.1",
53
+ "smol-toml": "^1.7.1",
53
54
  "yaml": "^2.9.0"
54
55
  },
55
56
  "devDependencies": {
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.1 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.1 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst's GitHub
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's view of Linear on the customer's own cloud account. Reads a ticket with its comments, relations, labels, linked pull requests and agent sessions inline, from the local replica when it is fresh and the origin-fresh API otherwise, always naming the source; searches tickets, PRs, projects and initiatives; writes comments, card moves, labels and new tickets through the account's agent proxy as the app actor, with every route, stage id, label id and marker read from the account's contract. Knows what a ticket accumulates as Catalyst works it (phase-outcome comments, the document per phase, the agent session, the labels, the single blocks relation, the bookkeeping marker, the eyes acknowledgement). Use when a person says "show me the ticket", "what did Catalyst write on it", "comment on it", "move it", "label it" or "file a ticket". Not for raising a decision for a human (what-needs-me) and not for pull requests (catalyst-github).
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.1 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.1 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst Linear
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Set up Catalyst Cloud and say whether setup is working. Walks a person from nothing to their first ticket running, one step at a time: the coding account, the project, the integrations and connected accounts, each repository's settings file, then one real ticket moving. Also logs this machine in, reads the readiness verdict with who can fix each failure, and checks the optional local replica. Use when someone says "set me up", "onboard me", "I just signed up", "get me started", "what do I do first", "log me in", "which account is this machine on", "am I set up", "what is missing", "why does nothing happen" or "is the replica running", when a login expired, or when a skill script exits 2 saying this machine is not connected. Does every step a key can do through the catalyst CLI, hands over the exact page for the steps only a browser can do, and never claims a step it did not watch succeed.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.13.1 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.1 — written in this repository for customer accounts -->
8
8
 
9
9
  # Onboard me
10
10
 
@@ -1,17 +1,24 @@
1
- # Declaring what one repository's containers need
1
+ # Drafting one repository's settings
2
2
 
3
- The cloud containers build and test the repository and need the names of the variables and secrets it reads. You write the names; the person enters the values in the app. Never read or print a value. Say which files you will read and what you will write, and wait for a yes. An existing file gets verified against the inventory, not rewritten.
3
+ The assistant prepares `.catalyst/catalyst.toml` from the repository. Never ask the person to write TOML or copy environment names by hand. Never read or print values from `.env`, CI secrets, or other local secret stores.
4
4
 
5
- ## The inventory, in order
5
+ ## Inspect before drafting
6
6
 
7
- 1. Start from what builds and tests the repository: the README's setup section, the scripts in `package.json` or its equivalent, and CI's build and test steps. A name none of them reads does not belong in the file.
8
- 2. Tie each name to the file it came from: a `.env.example` (never `.env`, which holds live values), a CI secret in a workflow's `env:` block, or a platform binding. A name you cannot tie to a file is a guess; say so instead of listing it.
9
- 3. Leave out platform bindings and deploy-only secrets, since containers build and test but never deploy, unless the person says the build needs one.
10
- 4. Write the names into `.catalyst/catalyst.toml`, the only file the cloud reads, and open a pull request. Each merged change to it is proposed as a new revision, which an owner or admin approves at Settings → Repositories → the repository → Environment → Setup declaration → Approve this revision; the person enters values on that page's Environment variables and Secrets tabs.
7
+ 1. Check `.catalyst/catalyst.toml` and `catalyst.env.json` in the selected repository. Classify it as missing, legacy-only, valid TOML, or invalid TOML. `catalyst env check --json` checks the TOML environment table; when only the legacy file exists, it reports that the JSON declaration is no longer read.
8
+ 2. For a legacy file, run `catalyst env migrate [path-to-catalyst.env.json]`. It prints a TOML environment table containing names only; every name is optional, and names identified as secrets are marked `secret = true`. Do not copy JSON values or provenance into the new file.
9
+ 3. For missing or invalid declarations, inspect the repository's package manager files, README setup instructions, `package.json` scripts or equivalent, build and test workflows, `.env.example`, and source references. Use `catalyst env inventory [repo] --json` as supporting evidence. Do not inspect `.env` values or run repository scripts just to infer settings.
10
+ 4. Draft `#:schema https://staging.catalystcloud.dev/schemas/catalyst.schema.json`, `[project]` with the mapped project's `linear_team`, the environment variables evidenced by build/test/setup, and setup/verify steps based on the repository's real package manager and build/test commands. Exclude deploy-only variables and platform bindings unless build or tests use them. Mark known secrets with `secret = true`. Set `required = false` on every variable; never make a phase depend on a newly declared value by default.
11
+ 5. Validate the complete draft against the live schema at the URL in its `#:schema` line, then run `catalyst env check <path>`. The environment check is an additional check; it does not replace full schema validation. If a live-schema validator is unavailable or either check fails, fix the draft or report the exact blocker without claiming it is valid.
11
12
 
12
- ## The file's shape
13
+ ## Ask before writing or opening the PR
13
14
 
14
- `[project]` with `linear_team` is required; every other section is optional.
15
+ Show a short preview with the project/team, setup and verify commands, variable names, which are marked secret, and confirmation that every variable is optional. Do not include values. Ask the person to approve this exact draft. Before approval, do not write or replace `.catalyst/catalyst.toml`, remove `catalyst.env.json`, or open a settings PR.
16
+
17
+ After approval, write the validated file. When converting a legacy-only repository, remove `catalyst.env.json` in the same change. Open the settings PR, then offer to watch it merge. Once merged, give the owner or admin a direct link to approve the new declaration revision at Settings → Your projects → the project → Repositories → the repository → Environment → Setup declaration → Approve this revision. The direct route is `/settings/projects/$projectId/repositories/$repoId/environment/declaration`; use the project and repository ids from Catalyst instead of guessing them.
18
+
19
+ ## File shape
20
+
21
+ `[project]` with `linear_team` is required; other sections are optional. Environment names belong under `[[environment.variables]]`:
15
22
 
16
23
  ```toml
17
24
  #:schema https://staging.catalystcloud.dev/schemas/catalyst.schema.json
@@ -21,11 +28,11 @@ linear_team = "ENG"
21
28
 
22
29
  [[environment.variables]]
23
30
  name = "DATABASE_URL"
24
- required = true
31
+ required = false
25
32
 
26
33
  [[environment.variables]]
27
34
  name = "STRIPE_API_KEY"
28
- required = true
35
+ required = false
29
36
  secret = true
30
37
 
31
38
  [[environment.setup]]
@@ -33,4 +40,4 @@ name = "install"
33
40
  run = ["npm", "ci"]
34
41
  ```
35
42
 
36
- `linear_team` is the Linear team's key. `run` is an argument list, not a shell string, and `name` is kebab-case; `verify` steps share the shape and carry the test commands. The schema lists the optional tables (`toolchains`, `system_packages`, `services`). After the merge, `catalyst ready` reports `environment_declared` with the next step.
43
+ `run` is an argument list, not a shell string. `verify` steps use the same shape and carry the repository's test commands. Secret and variable values belong in Catalyst Cloud, never in this file.
@@ -46,7 +46,7 @@ A team is `ready`, `degraded`, `blocked` or `unchecked` (no pass yet, a note). S
46
46
  | `hosts_current` | no connected host runs an older mapping | `references/what-a-phase-needs.md`; `no_host_connected` is waiting | whoever runs that host |
47
47
  | `environment_declared` | the default repository's `.catalyst/catalyst.toml` is ingested, valid and approved | the reason names the step: register a default repository, commit or fix the file, or approve it (`references/declaring-a-repository.md`) | owner or admin, except committing the file, which is whoever can push |
48
48
  | `tools_resolvable` | every MCP server and CLI the declaration names resolves | `tool_reference_unresolved`: add the secret or fix the declaration; `toolchain_cli_missing` is not self-service | owner or admin, in the declaration or Settings → Environment |
49
- | `reviewer_required` | the repository can merge under its policy | a strict policy with no reviewer: configure one at Settings → Repositories → Code reviews, or relax the policy; only the merge waits | owner or admin, in settings |
49
+ | `reviewer_required` | the repository can merge under its policy | a strict policy with no reviewer: configure one at Settings → Your projects → the project → Repositories → the repository → Code reviews, or relax the policy; only the merge waits | owner or admin, in settings |
50
50
  | `reviewer_configured` | a code reviewer is configured at all | configure one there; a fail never blocks or degrades | owner or admin, in settings; nobody is required when it fails |
51
51
 
52
52
  ## The machine checks the CLI adds
@@ -12,7 +12,7 @@ Only when the script says the machine is not connected: ask "Shall I start the l
12
12
 
13
13
  ## 1b. Leftovers of the old local runtime
14
14
 
15
- Only when the script notes them (from the CLI's fixed list; current jobs are never on it); left in place they can start old jobs or shadow current commands. Ask once, recommending it: "Shall I remove the old runtime's leftovers now? It keeps your data folders." On a yes, run `catalyst legacy --remove --yes` and read back its `removed:` and `re-checked:` lines; offer `--data` separately. On a no, the note stays.
15
+ Only when the script notes them (from the CLI's fixed list; current jobs are never on it); left in place they can start old jobs or shadow current commands. Ask once, recommending it: "Shall I remove the old runtime's leftovers now? It keeps your data folders." On a yes, run `catalyst legacy --remove --yes` and read back its `removed:` and `re-checked:` lines; keep all shared data folders. Never offer data deletion. On a no, the note stays.
16
16
 
17
17
  ## 2. A coding account
18
18
 
@@ -28,7 +28,7 @@ A card moved into an unmapped project does nothing, the most common reason a new
28
28
 
29
29
  `catalyst team list` shows the teams without checking readiness; the person picks one key. `catalyst team map <KEY>` or `catalyst team adopt <KEY>` prints a preview to approve before `--yes --plan-hash <hash>` (exit 3 applied nothing). Run `catalyst team check <KEY>` only for the selected team. `team migrate <KEY>`, its `--retire`, and `team adopt <KEY> --undo` each preview and need their own approval. `catalyst capabilities` says which verbs this cloud serves.
30
30
 
31
- A `blocked` project is not set up. Linear's own Git automation rules change only in Linear (Settings → Teams → the team → Workflow → Git automation → No action); hand that over, then re-check.
31
+ A `blocked` project is not set up. Change Linear's own Git automation rules in Linear for now (Settings → Teams → the team → Workflow → Workflows & automations → Pull request and commit automations → No action); hand that over, then re-check.
32
32
 
33
33
  ## 5. Install the GitHub App
34
34
 
@@ -36,7 +36,7 @@ Without it Catalyst cannot touch code. On the same page, the owner or admin inst
36
36
 
37
37
  ## 6. Register the repository
38
38
 
39
- On `<their cloud>/settings/repositories`, add the repository **and attach it to the project from step 4** in the same form. A repository registered without a project is listed and never receives work.
39
+ On `<their cloud>/settings/projects`, add the repository **and attach it to the project from step 4** in the same form. A repository registered without a project is listed and never receives work.
40
40
 
41
41
  ## 7. Connect your own accounts
42
42
 
@@ -44,7 +44,7 @@ The person's own logins, so work done for them is attributed to them and their a
44
44
 
45
45
  ## 8. The repository's settings file
46
46
 
47
- Ask "Shall I draft it from your repository's own build files?", then follow `references/declaring-a-repository.md`. Mention the approval after the merge as context, not a second task. A workspace-wide declaration goes through `catalyst environment` (read, propose, `--approve`).
47
+ Inspect and draft the repository settings file yourself from its build and test files; follow `references/declaring-a-repository.md`. Show the summary and get approval before writing or opening the PR. Mention the declaration approval after merge as context, not a second task. A workspace-wide declaration goes through `catalyst environment` (read, propose, `--approve`).
48
48
 
49
49
  Once you know the checkout path, rerun `node scripts/where-am-i.mjs --next --repo <path>` (a read) and follow `references/repository-agent-setup.md` for the offers it notes.
50
50
 
@@ -8,7 +8,7 @@ Each is an authorization a person grants in their own session, so no command wil
8
8
  | -- | -- |
9
9
  | approving the login | the URL and short code `catalyst login` printed |
10
10
  | enrolling a coding account, or replacing its credential | the AI accounts page the script printed |
11
- | connecting the Linear integration, installing the GitHub App | `<their cloud>/settings/connections` |
11
+ | connecting the Linear integration, installing the GitHub App | `<their cloud>/settings/connections` (Settings → Integrations) |
12
12
  | creating `<your GitHub org>/thoughts` and letting the App reach it | GitHub, then the App's installation page for that org: All repositories, or add `thoughts` (step 5 of `references/the-one-path.md`) |
13
13
  | connecting personal Linear, then personal GitHub | the URL `catalyst connections personal <linear\|github> start` printed |
14
14
 
@@ -18,7 +18,7 @@ Their routes take a browser session, not a key. Call them gaps, route the person
18
18
 
19
19
  | step | where |
20
20
  | -- | -- |
21
- | registering a repository and attaching it to a project | `<their cloud>/settings/repositories` |
21
+ | registering a repository and attaching it to a project | `<their cloud>/settings/projects` (Settings → Your projects) |
22
22
  | approving one repository's declaration, and entering its values | that repository's Environment page (`references/declaring-a-repository.md`) |
23
23
 
24
24
  `catalyst environment` handles a workspace-wide declaration. ⛔ Only the CLI makes requests; if you find yourself building a URL for one of these, stop.
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ // Shared coverage formatter used by where-am-i; --help documents its input contract.
3
+ if (process.argv.includes("--help")) {
4
+ process.stdout.write(
5
+ "Usage: imported by where-am-i; compares project rows with saved team stage mappings.\n",
6
+ );
7
+ process.exit(0);
8
+ }
9
+
10
+ /** Compare the full Catalyst project inventory with teams that actually have saved stage rows. */
11
+ export function projectCoverage(projects, teams) {
12
+ const mappedTeamIds = new Set(
13
+ teams
14
+ .filter(
15
+ (team) =>
16
+ team &&
17
+ (team.id ?? team.teamId) &&
18
+ team.stages &&
19
+ Object.keys(team.stages).length > 0,
20
+ )
21
+ .map((team) => team.id ?? team.teamId),
22
+ );
23
+ const mapped = [];
24
+ const unmapped = [];
25
+ for (const project of projects) {
26
+ (mappedTeamIds.has(project.linearTeamId) ? mapped : unmapped).push(project);
27
+ }
28
+ return { mapped, unmapped, total: projects.length };
29
+ }
30
+
31
+ export function projectCoverageLines(projects, teams) {
32
+ const coverage = projectCoverage(projects, teams);
33
+ const teamById = new Map(teams.map((team) => [team.id ?? team.teamId, team]));
34
+ const lines = [
35
+ `${coverage.mapped.length} of your ${coverage.total} projects have their Linear team mapped.`,
36
+ ];
37
+ for (const project of coverage.mapped) {
38
+ const team = teamById.get(project.linearTeamId);
39
+ const readiness = team?.readiness ?? {};
40
+ const bad = Array.isArray(readiness.checks)
41
+ ? readiness.checks.filter((check) => check.state !== "pass")
42
+ : [];
43
+ lines.push(
44
+ `${project.name} (${project.linearTeamKey ?? team?.teamKey ?? project.linearTeamId}): ${readiness.status ?? "unchecked"}${bad.length ? ` — ${bad.map((check) => `${check.id} ${check.state}`).join(", ")}` : ""}`,
45
+ );
46
+ }
47
+ if (coverage.unmapped.length > 0) {
48
+ lines.push(
49
+ `These ${coverage.unmapped.length} do not: ${coverage.unmapped.map((project) => project.name).join(", ")}`,
50
+ );
51
+ }
52
+ return { ...coverage, lines };
53
+ }