@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.
- package/CHANGELOG.md +8 -0
- package/README.md +2 -2
- package/dist/args.js +291 -69
- package/dist/capabilities.js +2 -0
- package/dist/cli.js +109 -17
- package/dist/config.js +3 -1
- package/dist/env/draft.js +134 -0
- package/dist/env/legacy.js +54 -0
- package/dist/env/parse-env.js +50 -0
- package/dist/env/settings-toml.js +66 -0
- package/dist/env.js +126 -26
- package/dist/legacy.js +16 -17
- package/dist/onboard-consent.js +65 -0
- package/dist/onboard.js +836 -0
- package/dist/project.js +75 -13
- package/dist/ready.js +11 -1
- package/dist/secret.js +24 -3
- package/dist/var.js +118 -0
- package/package.json +3 -2
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- package/skills/catalyst-onboard/references/declaring-a-repository.md +19 -12
- package/skills/catalyst-onboard/references/reading-ready.md +1 -1
- package/skills/catalyst-onboard/references/the-one-path.md +4 -4
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +2 -2
- package/skills/catalyst-onboard/scripts/lib/project-coverage.mjs +53 -0
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +885 -196
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +4 -4
- package/skills/what-this-repo-needs/scripts/check.mjs +12 -13
- package/skills/whats-happening/SKILL.md +19 -35
- package/skills/whats-happening/references/coding-accounts.md +6 -19
- package/skills/whats-happening/references/routing-work.md +4 -15
- package/skills/whats-happening/references/settings-and-where-they-live.md +15 -18
- package/skills/whats-happening/references/stages-and-mapping.md +12 -18
- package/skills/whats-happening/references/status-reply.md +11 -29
- package/skills/whats-happening/references/the-ladder.md +15 -25
- package/skills/whats-happening/references/what-runs-next.md +7 -26
- package/skills/whats-happening/references/when-a-phase-fails.md +16 -22
- package/skills/whats-happening/references/why-is-it-stuck.md +38 -48
- 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
|
-
|
|
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) =>
|
|
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" &&
|
|
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) => {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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 }, [
|
|
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), {
|
|
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
|
|
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({
|
|
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.
|
|
4
|
-
"description": "Customer skill bundle for Catalyst Cloud, named from the executive's seat
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
1
|
+
# Drafting one repository's settings
|
|
2
2
|
|
|
3
|
-
The
|
|
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
|
-
##
|
|
5
|
+
## Inspect before drafting
|
|
6
6
|
|
|
7
|
-
1.
|
|
8
|
-
2.
|
|
9
|
-
3.
|
|
10
|
-
4.
|
|
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
|
-
##
|
|
13
|
+
## Ask before writing or opening the PR
|
|
13
14
|
|
|
14
|
-
|
|
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 =
|
|
31
|
+
required = false
|
|
25
32
|
|
|
26
33
|
[[environment.variables]]
|
|
27
34
|
name = "STRIPE_API_KEY"
|
|
28
|
-
required =
|
|
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
|
-
`
|
|
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
|
|
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
|
|
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/
|
|
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
|
-
|
|
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/
|
|
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
|
+
}
|