@catalyst-cloud/cli 0.9.4 → 0.9.5

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 (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +35 -31
  3. package/dist/args.js +13 -1
  4. package/dist/ask.js +2 -2
  5. package/dist/capabilities.js +70 -0
  6. package/dist/cli.js +37 -29
  7. package/dist/config.js +1 -1
  8. package/dist/connections.js +5 -5
  9. package/dist/environment.js +4 -4
  10. package/dist/events.js +1 -1
  11. package/dist/execution.js +3 -2
  12. package/dist/identity.js +3 -3
  13. package/dist/oauth.js +3 -3
  14. package/dist/query.js +27 -1
  15. package/dist/ready.js +3 -3
  16. package/dist/release.js +1 -1
  17. package/dist/replica.js +9 -7
  18. package/dist/runtime-store.js +2 -2
  19. package/dist/runtime.js +1 -1
  20. package/dist/secret.js +1 -1
  21. package/dist/skills.js +1 -1
  22. package/dist/team.js +329 -0
  23. package/package.json +1 -1
  24. package/skills/catalyst-github/SKILL.md +1 -1
  25. package/skills/catalyst-linear/SKILL.md +1 -1
  26. package/skills/catalyst-onboard/SKILL.md +7 -7
  27. package/skills/catalyst-onboard/references/choosing-a-coding-account.md +2 -2
  28. package/skills/catalyst-onboard/references/declaring-a-repository.md +1 -1
  29. package/skills/catalyst-onboard/references/local-sync.md +3 -3
  30. package/skills/catalyst-onboard/references/skill-sources.md +1 -1
  31. package/skills/catalyst-onboard/references/the-one-path.md +12 -10
  32. package/skills/catalyst-onboard/references/what-a-phase-needs.md +5 -5
  33. package/skills/catalyst-onboard/references/what-the-browser-owns.md +6 -9
  34. package/skills/catalyst-onboard/references/who-fixes-what.md +35 -16
  35. package/skills/catalyst-onboard/scripts/local-sync.mjs +6 -6
  36. package/skills/catalyst-onboard/scripts/where-am-i.mjs +133 -54
  37. package/skills/catalyst-setup/SKILL.md +1 -1
  38. package/skills/connect-me/SKILL.md +1 -1
  39. package/skills/how-catalyst-works/SKILL.md +2 -2
  40. package/skills/how-catalyst-works/references/what-runs-next.md +11 -0
  41. package/skills/run-this-project/SKILL.md +1 -1
  42. package/skills/unstick/SKILL.md +1 -1
  43. package/skills/what-needs-me/SKILL.md +1 -1
  44. package/skills/whats-happening/SKILL.md +1 -1
  45. package/skills/whats-happening/references/reading-the-board.md +1 -1
  46. package/skills/whats-happening/references/why-is-it-stuck.md +1 -0
package/dist/identity.js CHANGED
@@ -14,7 +14,7 @@ function render(result, choices) {
14
14
  return `${summary}\nNo choices were offered. An automatic match cannot be replaced; an unmatched roster may need a retry.`;
15
15
  if (result.options.length === 0)
16
16
  return `${summary}\nThe workspace roster has no eligible people.`;
17
- return [summary, ...result.options.map((o) => `${o.id}\t${o.displayName ?? o.name ?? "(unnamed)"}`), "Select your own identity explicitly: catalyst-skills identity linear set <linearUserId>"].join("\n");
17
+ return [summary, ...result.options.map((o) => `${o.id}\t${o.displayName ?? o.name ?? "(unnamed)"}`), "Select your own identity explicitly: catalyst identity linear set <linearUserId>"].join("\n");
18
18
  }
19
19
  export async function cmdIdentity(args, ctx, deps = {}) {
20
20
  const parts = positionals(args);
@@ -25,11 +25,11 @@ export async function cmdIdentity(args, ctx, deps = {}) {
25
25
  }
26
26
  const cfg = requireConfig(ctx);
27
27
  if (!cfg.user)
28
- throw new CliError("this machine uses a tenant account key; run catalyst-skills login as yourself", "member-required");
28
+ throw new CliError("this machine uses a tenant account key; run catalyst login as yourself", "member-required");
29
29
  const createClient = deps.createClient ?? (async (options) => (await loadHttpSdk()).createTenantClient(options));
30
30
  const client = await createClient({ key: await bearerFor(ctx, cfg), baseUrl: cfg.baseUrl, fetch: ctx.fetch });
31
31
  if (!client.linearIdentity)
32
- throw new CliError("the installed SDK lacks identity recovery; upgrade catalyst-skills after the SDK release", "sdk-upgrade-required");
32
+ throw new CliError("the installed SDK lacks identity recovery; upgrade the catalyst CLI after the SDK release", "sdk-upgrade-required");
33
33
  if (action === "set") {
34
34
  // The arity check above requires a selection; never choose the first roster entry for a person.
35
35
  if (selected === undefined)
package/dist/oauth.js CHANGED
@@ -152,7 +152,7 @@ async function pollDeviceCode(ctx, discovery, auth, sleep) {
152
152
  continue;
153
153
  }
154
154
  if (err === "access_denied")
155
- throw new CliError("login was denied — run: catalyst-skills login to try again", "login-denied");
155
+ throw new CliError("login was denied — run: catalyst login to try again", "login-denied");
156
156
  if (err === "expired_token")
157
157
  return "expired";
158
158
  // A transient HTTP failure (request timeout, rate limit, server error) is retried, honouring
@@ -220,7 +220,7 @@ export async function bearerFor(ctx, cfg, deps = {}) {
220
220
  if (typeof cfg.key === "string" && cfg.key !== "")
221
221
  return cfg.key;
222
222
  if (!cfg.auth)
223
- throw new CliError("this machine is not connected — run: catalyst-skills login", "not-configured");
223
+ throw new CliError("this machine is not connected — run: catalyst login", "not-configured");
224
224
  const msToExpiry = Date.parse(cfg.auth.expiresAt) - ctx.now().getTime();
225
225
  if (Number.isFinite(msToExpiry) && msToExpiry > REFRESH_SKEW_MS)
226
226
  return cfg.auth.accessToken;
@@ -256,7 +256,7 @@ async function refreshAndPersist(ctx, cfg, deps) {
256
256
  const err = await oauthError(res, discovery.tokenUrl);
257
257
  if (err === "invalid_grant") {
258
258
  // revocation or > inactivity window — the ONLY session-expired case; NOT routine expiry.
259
- throw new CliError("your login expired or was revoked — run: catalyst-skills login", "session-expired");
259
+ throw new CliError("your login expired or was revoked — run: catalyst login", "session-expired");
260
260
  }
261
261
  const transient = res.status === 429 || res.status >= 500;
262
262
  if (!transient)
package/dist/query.js CHANGED
@@ -156,7 +156,7 @@ async function fromApi(ctx, cfg, sub, rest, f, args) {
156
156
  if (!q)
157
157
  throw new UsageError("search needs terms: query search <terms>");
158
158
  const res = await api.getJson("/api/v1/search", { query: { q, limit: f.limit } });
159
- return rowsOf(res.body);
159
+ return searchRows(res.body);
160
160
  }
161
161
  case "changes": {
162
162
  const since = flagString(args, "since");
@@ -224,6 +224,21 @@ export function applyFilters(rows, f) {
224
224
  return true;
225
225
  });
226
226
  }
227
+ /** The hub answers search in four groups (`issues`, `pulls`, `projects`, `initiatives`, the read
228
+ * model's SearchView). `rowsOf` returns only the first array it finds, so it dropped every match that
229
+ * was not a ticket. Every group comes back here, flattened, each row tagged with its `kind`. */
230
+ const SEARCH_GROUPS = [["issues", "issue"], ["pulls", "pull"], ["projects", "project"], ["initiatives", "initiative"]];
231
+ export function searchRows(body) {
232
+ if (Array.isArray(body))
233
+ return body;
234
+ if (!body || typeof body !== "object")
235
+ return [];
236
+ const b = body;
237
+ if (SEARCH_GROUPS.some(([group]) => Array.isArray(b[group]))) {
238
+ return SEARCH_GROUPS.flatMap(([group, kind]) => Array.isArray(b[group]) ? b[group].map((row) => ({ kind, ...row })) : []);
239
+ }
240
+ return rowsOf(body);
241
+ }
227
242
  function printResult(ctx, args, sub, result) {
228
243
  if (args.json || !Array.isArray(result)) {
229
244
  ctx.stdout(JSON.stringify(result, null, args.json ? 0 : 2));
@@ -242,6 +257,17 @@ export function summaryLine(sub, r) {
242
257
  return `${r.id} ${r.state ?? "?"} ${r.name ?? ""}`;
243
258
  case "cycles":
244
259
  return `cycle ${r.number ?? r.id} ${r.name ?? ""} ${r.starts_at ?? ""} → ${r.ends_at ?? ""}`;
260
+ case "search":
261
+ switch (r.kind) {
262
+ case "pull":
263
+ return `pull #${r.number ?? "?"} ${r.title ?? ""}${r.repo_id ? ` (${r.repo_id})` : ""}`;
264
+ case "project":
265
+ return `project ${r.name ?? r.id}`;
266
+ case "initiative":
267
+ return `initiative ${r.name ?? r.id}`;
268
+ default:
269
+ return `issue ${r.identifier ?? r.id} ${r.title ?? ""}`;
270
+ }
245
271
  default:
246
272
  return JSON.stringify(r);
247
273
  }
package/dist/ready.js CHANGED
@@ -184,7 +184,7 @@ export async function readyReport(ctx, deps) {
184
184
  ? `config: joined ${cfg.name} as ${cfg.user.label} (${cfg.user.role})`
185
185
  : `config: joined ${cfg.name} (${cfg.slug}) as ${cfg.principal}`,
186
186
  }
187
- : { id: "config", ok: false, line: "config: not connected", fix: "npx @catalyst-cloud/catalyst-skills login (keyless; or pass --key / set CATALYST_CLOUD_TOKEN)", who: "you (approve the login in your browser)" });
187
+ : { id: "config", ok: false, line: "config: not connected", fix: "npx -p @catalyst-cloud/cli catalyst login (keyless; or pass --key / set CATALYST_CLOUD_TOKEN)", who: "you (approve the login in your browser)" });
188
188
  }
189
189
  catch (err) {
190
190
  checks.push({ id: "config", ok: false, line: `config: ${err instanceof CliError ? err.message : String(err)}`, fix: "re-run login to rewrite it", who: "you" });
@@ -192,7 +192,7 @@ export async function readyReport(ctx, deps) {
192
192
  const cache = readContractCache(ctx.home);
193
193
  const range = readManifest().tenantContractRange;
194
194
  if (!cache) {
195
- checks.push({ id: "contract", ok: false, line: "contract: not cached", fix: "catalyst-skills contract --refresh", who: "you" });
195
+ checks.push({ id: "contract", ok: false, line: "contract: not cached", fix: "catalyst contract --refresh", who: "you" });
196
196
  }
197
197
  else if (contractVersionInRange(cache.contractVersion, range) !== true) {
198
198
  checks.push({ id: "contract", ok: false, line: `contract: version ${cache.contractVersion} is outside this bundle's range ${range}`, fix: upgradeCommand(), who: "you" });
@@ -290,7 +290,7 @@ export async function readyReport(ctx, deps) {
290
290
  checks.push({ id: "skills", ok: true, line: `skills: all ${deps.skillNames.length} present in ${skillsDir}` });
291
291
  }
292
292
  else {
293
- checks.push({ id: "skills", ok: false, line: `skills: ${present.length} of ${deps.skillNames.length} in ${skillsDir}, missing ${missing.join(", ")}`, fix: "catalyst-skills install", who: "you" });
293
+ checks.push({ id: "skills", ok: false, line: `skills: ${present.length} of ${deps.skillNames.length} in ${skillsDir}, missing ${missing.join(", ")}`, fix: "catalyst install", who: "you" });
294
294
  }
295
295
  try {
296
296
  await (deps.loadSdk ?? loadSdk)();
package/dist/release.js CHANGED
@@ -37,7 +37,7 @@ export function renderTicketRelease(ticket, r) {
37
37
  const lines = [];
38
38
  const dry = r.dryRun === true;
39
39
  if (r.outcome === "nothing-held") {
40
- lines.push(`${ticket}: nothing holds this ticket — run \`catalyst-skills explain ${ticket}\` for why it is not running`);
40
+ lines.push(`${ticket}: nothing holds this ticket — run \`catalyst explain ${ticket}\` for why it is not running`);
41
41
  }
42
42
  else if (r.outcome === "refused") {
43
43
  lines.push(dry ? `${ticket} (dry run): would be refused — nothing would be released:` : `${ticket}: refused — nothing was released:`);
package/dist/replica.js CHANGED
@@ -196,7 +196,7 @@ function baseStatusLine(s) {
196
196
  case "not-configured":
197
197
  return `replica: not configured (${s.reasons.join("; ")})`;
198
198
  case "absent":
199
- return `replica: absent at ${s.dbPath} — start it with: catalyst-skills replica start --detach`;
199
+ return `replica: absent at ${s.dbPath} — start it with: catalyst replica start --detach`;
200
200
  case "fresh":
201
201
  return `replica: fresh at ${s.dbPath} (cursor ${s.cursor}, heartbeat ${s.heartbeatAgeMs}ms ago${s.lag !== undefined ? `, ${s.lag} behind head ${s.head}` : ""})`;
202
202
  case "stale":
@@ -240,7 +240,9 @@ export async function engineFor(sdk, dbPath, ctx, deps = {}) {
240
240
  }
241
241
  catch (err) {
242
242
  const why = err instanceof Error ? err.message.split("\n")[0] : String(err);
243
- ctx.stderr(`[catalyst-skills] better-sqlite3 unavailable (${why}); using node:sqlite`);
243
+ // node:sqlite is the supported path (CTC-4272): say nothing when it works, except under CATALYST_DEBUG.
244
+ if (ctx.env?.CATALYST_DEBUG === "1")
245
+ ctx.stderr(`[catalyst] better-sqlite3 unavailable (${why}); using node:sqlite`);
244
246
  return deps.readonly ? sdk.nodeSqliteReadonlyEngine(dbPath) : sdk.nodeSqliteEngine(dbPath);
245
247
  }
246
248
  }
@@ -252,7 +254,7 @@ export const SNAPSHOT_MAX_FAILURES = 5;
252
254
  * count on; staying live is that signal. The failing warm loop never reaches it — its resync demand
253
255
  * fails and tears the attempt down first (CTC-2499). */
254
256
  export const SNAPSHOT_LIVE_STABLE_MS = 60_000;
255
- export const REPLICA_RESTART_COMMAND = "catalyst-skills replica start --detach";
257
+ export const REPLICA_RESTART_COMMAND = "catalyst replica start --detach";
256
258
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
257
259
  function defaultWaitForStop() {
258
260
  return new Promise((resolve) => {
@@ -342,7 +344,7 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
342
344
  const argv = (deps.argv ?? process.argv.slice(1)).filter((a) => a !== "--detach");
343
345
  const { pid } = (deps.detach ?? detachSelf)(argv, ctx.env);
344
346
  writeFileSync(pidfilePath(dbPath), `${pid}\n`);
345
- ctx.stdout(`replica writer started in the background (pid ${pid}, db ${dbPath}); check with: catalyst-skills replica status`);
347
+ ctx.stdout(`replica writer started in the background (pid ${pid}, db ${dbPath}); check with: catalyst replica status`);
346
348
  return 0;
347
349
  }
348
350
  const sdk = await loadSdk();
@@ -503,7 +505,7 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
503
505
  const lastError = attemptError ?? "the snapshot pull failed";
504
506
  const nowMs = ctx.now().getTime();
505
507
  const stopped = failures >= maxFailures
506
- ? { at: nowMs, reason: `${failures} consecutive snapshot failures`, restartWith: "catalyst-skills replica start --detach" }
508
+ ? { at: nowMs, reason: `${failures} consecutive snapshot failures`, restartWith: "catalyst replica start --detach" }
507
509
  : null;
508
510
  writeWriterState(dbPath, { pid: process.pid, consecutiveFailures: failures, lastError, lastFailureAt: nowMs, stopped }, nowMs);
509
511
  if (stopped) {
@@ -562,7 +564,7 @@ export function assertSingleSelect(sql) {
562
564
  async function cmdSql(args, ctx, dbPath, sql, deps) {
563
565
  const query = assertSingleSelect(sql);
564
566
  if (!existsSync(dbPath))
565
- throw new CliError(`no replica at ${dbPath} — start it with: catalyst-skills replica start --detach`, "replica-absent", 3);
567
+ throw new CliError(`no replica at ${dbPath} — start it with: catalyst replica start --detach`, "replica-absent", 3);
566
568
  const sdk = await loadSdk();
567
569
  const engine = await engineFor(sdk, dbPath, ctx, { ...deps.engineDeps, readonly: true });
568
570
  const replica = await sdk.CatalystReplica.openReadOnly({ dbPath, engine, log: () => { } });
@@ -577,7 +579,7 @@ async function cmdSql(args, ctx, dbPath, sql, deps) {
577
579
  }
578
580
  async function cmdSchema(args, ctx, dbPath, table) {
579
581
  if (!existsSync(dbPath))
580
- throw new CliError(`no replica at ${dbPath} — the schema is what the file holds; start it with: catalyst-skills replica start --detach`, "replica-absent", 3);
582
+ throw new CliError(`no replica at ${dbPath} — the schema is what the file holds; start it with: catalyst replica start --detach`, "replica-absent", 3);
581
583
  const db = new (loadSqlite().DatabaseSync)(dbPath, { readOnly: true });
582
584
  try {
583
585
  const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all().map((r) => r.name);
@@ -21,11 +21,11 @@ const PLATFORM_ARCH = {
21
21
  * needs `.zip` extraction, a separate mechanism this ticket does not build (D7). */
22
22
  export function tarballFor(version, platform, arch) {
23
23
  if (platform === "win32") {
24
- throw new CliError("catalyst-skills runtime install does not support Windows yet — supported: darwin (arm64, x64) and linux (x64, arm64). Use whatever Node 22.15+ or bun 1.4+ you already have on Windows.", "runtime-unsupported-platform");
24
+ throw new CliError("catalyst runtime install does not support Windows yet — supported: darwin (arm64, x64) and linux (x64, arm64). Use whatever Node 22.15+ or bun 1.4+ you already have on Windows.", "runtime-unsupported-platform");
25
25
  }
26
26
  const arches = PLATFORM_ARCH[platform];
27
27
  if (!arches || !arches.includes(arch)) {
28
- throw new CliError(`catalyst-skills runtime install does not support ${platform}/${arch} — supported: darwin (arm64, x64) and linux (x64, arm64)`, "runtime-unsupported-platform");
28
+ throw new CliError(`catalyst runtime install does not support ${platform}/${arch} — supported: darwin (arm64, x64) and linux (x64, arm64)`, "runtime-unsupported-platform");
29
29
  }
30
30
  const file = `node-v${version}-${platform}-${arch}.tar.gz`;
31
31
  return { file, url: `https://nodejs.org/dist/v${version}/${file}`, shasums: `https://nodejs.org/dist/v${version}/SHASUMS256.txt` };
package/dist/runtime.js CHANGED
@@ -13,7 +13,7 @@
13
13
  export const BUN_MIN = "1.4.0";
14
14
  /** The ONE copy-pasteable command. Installs a pinned Node under the CLI's own cache and pins it;
15
15
  * it never touches the machine's default Node and needs no admin rights. See src/runtime-store.ts. */
16
- export const FIX_COMMAND = "npx -y @catalyst-cloud/catalyst-skills runtime install";
16
+ export const FIX_COMMAND = "npx -y -p @catalyst-cloud/cli catalyst runtime install";
17
17
  /** Detect the runtime this process is running on. `process.versions.bun` is present only under bun. */
18
18
  export function detectRuntime(versions = process.versions) {
19
19
  if (versions.bun)
package/dist/secret.js CHANGED
@@ -70,7 +70,7 @@ function requireRepo(args, verb) {
70
70
  /** The cloud's refusal, in its own words, plus what to do for the two we can explain. */
71
71
  function refusalLine(status, body, repo) {
72
72
  if (status === 401) {
73
- return "refused (401): the cloud did not accept your credential here. If `catalyst-skills me` works, this cloud predates secret writes from the CLI and needs a newer release; otherwise log in again.";
73
+ return "refused (401): the cloud did not accept your credential here. If `catalyst me` works, this cloud predates secret writes from the CLI and needs a newer release; otherwise log in again.";
74
74
  }
75
75
  if (status === 404)
76
76
  return `refused (404): ${repo} is not a repository of this tenant (register it in the app first)`;
package/dist/skills.js CHANGED
@@ -137,5 +137,5 @@ export function updateNoticeLine(previous, current, entry) {
137
137
  // minor — so it never crosses 0.2.x → 0.3.0. Pin @latest via `npm install`, THEN re-run login: the
138
138
  // install alone does not rewrite customer.json.cliPath, so the skill helpers would keep spawning the
139
139
  // stale recorded bundle and repeat this notice forever; the new global bin records its own path.
140
- return `[catalyst-skills] updated ${previous} → ${current}: ${summary} · update with: ${upgradeCommand()}`;
140
+ return `[catalyst] updated ${previous} → ${current}: ${summary} · update with: ${upgradeCommand()}`;
141
141
  }
package/dist/team.js ADDED
@@ -0,0 +1,329 @@
1
+ // Team setup uses the person's saved login and the SDK's typed personal-admin methods.
2
+ // A fresh SDK client per call refreshes a device-login token across a multi-chunk migration.
3
+ import { createHash } from "node:crypto";
4
+ import { flagBool, flagList, flagString, positionals } from "./args.js";
5
+ import { requireConfig } from "./config.js";
6
+ import { loadContract } from "./contract.js";
7
+ import { UsageError } from "./errors.js";
8
+ import { bearerFor } from "./oauth.js";
9
+ import { loadHttpSdk } from "./sdk.js";
10
+ const SLOTS = ["dispatch", "intake", "research", "plan", "implement", "remediate", "verify", "review", "pr", "done", "canceled"];
11
+ function isSlot(value) { return SLOTS.includes(value); }
12
+ const NAMES = {
13
+ dispatch: ["todo", "to do", "ready"], intake: ["intake", "triage"], research: ["research"],
14
+ plan: ["plan", "planning"], implement: ["implement", "in progress", "doing"],
15
+ remediate: ["remediate", "fix"], verify: ["verify", "validate", "testing"], review: ["review"],
16
+ pr: ["pr", "in review", "pull request"], done: ["done", "completed"], canceled: ["canceled", "cancelled"],
17
+ };
18
+ const MAX_CHUNKS = 100;
19
+ const MAX_RUN_MS = 5 * 60_000;
20
+ function routes(doc) {
21
+ const read = doc.routes.find((r) => r.method === "GET" && r.path.endsWith("/team-workflow"))?.path;
22
+ if (!read)
23
+ throw new UsageError("team setup needs a newer Catalyst Cloud (team-workflow route absent)");
24
+ const desired = { teams: read.replace(/\/team-workflow$/, "/teams"), read, check: `${read}/check`, save: `${read}/save`, adopt: `${read}/adopt`, undo: `${read}/adopt-undo`, migrate: `${read}/migrate` };
25
+ for (const [name, path] of Object.entries(desired)) {
26
+ const method = name === "teams" || name === "read" ? "GET" : "POST";
27
+ if (!doc.routes.some((r) => r.method === method && r.path === path))
28
+ throw new UsageError(`team setup needs a newer Catalyst Cloud (${method} ${path} absent)`);
29
+ }
30
+ return desired;
31
+ }
32
+ function key(args) {
33
+ const [sub, team, ...extra] = positionals(args);
34
+ if (extra.length > 0)
35
+ throw new UsageError(`team ${sub ?? ""} takes one team key`);
36
+ if (!team && !(sub === "list" || (sub === "check" && flagBool(args, "all"))))
37
+ throw new UsageError(`team ${sub ?? ""} needs a team key`);
38
+ if (team && flagBool(args, "all"))
39
+ throw new UsageError("team check takes a key or --all, not both");
40
+ return team ?? "";
41
+ }
42
+ function emit(ctx, args, value, lines) {
43
+ if (args.json)
44
+ ctx.stdout(JSON.stringify(value));
45
+ else
46
+ for (const line of lines)
47
+ ctx.stdout(line);
48
+ }
49
+ function refusal(ctx, args, reply) {
50
+ const { body, status } = reply;
51
+ emit(ctx, args, { status, ...body }, [`refused (${status}): ${String(body.error ?? "http")}${body.reason || body.message ? ` — ${String(body.reason ?? body.message)}` : ""}`]);
52
+ return 1;
53
+ }
54
+ function readinessLines(value) {
55
+ if (value === null || typeof value !== "object")
56
+ return ["the change landed, but readiness could not be checked; run team check"];
57
+ const r = value;
58
+ return [`readiness: ${String(r.status ?? "unknown")}; checked at ${String(r.checkedAt ?? "unknown")}`,
59
+ ...(Array.isArray(r.checks) ? r.checks : []).filter((c) => c.state === "fail").map((c) => ` ${String(c.id)}: ${String(c.reason ?? "failed")} (${String(c.owner ?? c.whoCanFix ?? "team admin")})`)];
60
+ }
61
+ function fromSdk(result) {
62
+ if (result.outcome === "ok") {
63
+ const { outcome: _outcome, status, ...body } = result;
64
+ return { status, body: body };
65
+ }
66
+ return {
67
+ status: result.outcome === "shape" ? 502 : "status" in result ? result.status : 503,
68
+ body: { error: "error" in result ? result.error ?? result.outcome : result.outcome, reason: result.reason, ...(result.outcome === "shape" ? { upstreamStatus: result.status } : {}) },
69
+ };
70
+ }
71
+ function isRefusal(reply) { return typeof reply.body.error === "string" || !((reply.status >= 200 && reply.status < 300) || reply.status === 304); }
72
+ function assignments(values, label) {
73
+ return values.map((value) => {
74
+ const split = value.indexOf("=");
75
+ if (split <= 0 || split === value.length - 1)
76
+ throw new UsageError(`--${label} needs left=right (got ${value})`);
77
+ return [value.slice(0, split), value.slice(split + 1)];
78
+ });
79
+ }
80
+ function mapping(view, explicit) {
81
+ if (view.stageSource === "none" || !Array.isArray(view.stages))
82
+ throw new UsageError("live Linear stages are unreadable; retry team map after the connection is healthy");
83
+ const stages = view.stages;
84
+ const selected = new Map();
85
+ const unresolved = [];
86
+ if (explicit.length > 0) {
87
+ for (const [slot, stateName] of explicit) {
88
+ if (!isSlot(slot))
89
+ throw new UsageError(`unknown workflow role: ${slot}`);
90
+ if (selected.has(slot))
91
+ throw new UsageError(`duplicate workflow role: ${slot}`);
92
+ const found = stages.filter((s) => s.name.toLocaleLowerCase() === stateName.toLocaleLowerCase() || s.id === stateName);
93
+ if (found.length !== 1)
94
+ throw new UsageError(`${stateName} matches ${found.length} live Linear stages; choose an unambiguous name or id`);
95
+ selected.set(slot, found[0].id);
96
+ }
97
+ }
98
+ else {
99
+ for (const slot of SLOTS) {
100
+ const names = NAMES[slot] ?? [];
101
+ const match = names.flatMap((name) => stages.filter((stage) => stage.name.toLocaleLowerCase() === name));
102
+ const unique = [...new Map(match.map((stage) => [stage.id, stage])).values()];
103
+ if (unique.length === 1)
104
+ selected.set(slot, unique[0].id);
105
+ else
106
+ unresolved.push(slot);
107
+ }
108
+ }
109
+ const previous = new Map((view.rows ?? []).map((r) => [r.slot, r.linearStateId]));
110
+ const diff = SLOTS.map((slot) => {
111
+ const before = previous.get(slot) ?? null;
112
+ const after = selected.get(slot) ?? null;
113
+ return { slot, before, after, change: before === after ? "unchanged" : before === null ? "added" : after === null ? "removed" : "changed" };
114
+ });
115
+ return { rows: SLOTS.filter((slot) => selected.has(slot)).map((slot) => ({ slot, linearStateId: selected.get(slot), source: "chosen" })), diff, unresolved };
116
+ }
117
+ function hashPlan(value) { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
118
+ function planLines(title, rows, hash) {
119
+ return [title, ...rows.map((row) => ` ${JSON.stringify(row)}`), `Plan hash: ${hash}`, `Review this plan with the person, then rerun with --yes --plan-hash ${hash} to apply exactly it.`];
120
+ }
121
+ export async function cmdTeam(args, ctx, deps = {}) {
122
+ const [sub] = positionals(args);
123
+ if (!sub || !["list", "check", "map", "adopt", "migrate", "checklist"].includes(sub))
124
+ throw new UsageError("team needs list | check | map | adopt | migrate | checklist");
125
+ const team = key(args);
126
+ if (sub !== "check" && flagBool(args, "all"))
127
+ throw new UsageError("--all belongs to team check");
128
+ if (sub !== "adopt" && flagBool(args, "undo"))
129
+ throw new UsageError("--undo belongs to team adopt");
130
+ if (sub !== "migrate" && (flagBool(args, "retire") || flagList(args, "choice").length))
131
+ throw new UsageError("--retire and --choice belong to team migrate");
132
+ if (sub !== "map" && flagList(args, "stage").length)
133
+ throw new UsageError("--stage belongs to team map");
134
+ if (!["map", "adopt", "migrate"].includes(sub) && flagString(args, "plan-hash") !== undefined)
135
+ throw new UsageError("--plan-hash belongs to team map, adopt, or migrate");
136
+ if ((sub === "check" || sub === "checklist") && flagBool(args, "yes"))
137
+ throw new UsageError(`team ${sub} takes no --yes`);
138
+ const cfg = requireConfig(ctx);
139
+ let { doc } = await loadContract(ctx, cfg);
140
+ try {
141
+ routes(doc);
142
+ }
143
+ catch (error) {
144
+ if (!(error instanceof UsageError) || !error.message.includes("team-workflow"))
145
+ throw error;
146
+ // A fresh cached contract can predate the team routes. Revalidate once before telling the
147
+ // person the cloud is too old; the conditional GET is cheap when the server has not changed.
148
+ doc = (await loadContract(ctx, cfg, { refresh: true })).doc;
149
+ routes(doc);
150
+ }
151
+ // API hashes detect stale server state. CLI approval additionally binds the reviewed scope,
152
+ // operation and signed-in tenant so consent cannot transfer to a different command or account.
153
+ const approvalHash = (operation, plan) => hashPlan({ baseUrl: cfg.baseUrl, account: cfg.account, actor: cfg.user?.id ?? null, team, operation, plan });
154
+ const createClient = deps.createClient ?? (async (options) => (await loadHttpSdk()).createTenantClient(options));
155
+ const client = async () => (await createClient({ key: await bearerFor(ctx, cfg), baseUrl: cfg.baseUrl, fetch: ctx.fetch })).teamWorkflow;
156
+ if (sub === "list") {
157
+ const reply = fromSdk(await (await client()).teams());
158
+ if (isRefusal(reply))
159
+ return refusal(ctx, args, reply);
160
+ if (!Array.isArray(reply.body.teams))
161
+ return refusal(ctx, args, { status: 502, body: { error: "bad-response", reason: "team list was missing" } });
162
+ const teams = reply.body.teams;
163
+ emit(ctx, args, { teams, liveTeamRead: reply.body.liveTeamRead }, teams.map((t) => `${String(t.teamKey ?? t.teamId ?? t.key ?? "unknown")}: ${String(t.name ?? t.teamName ?? "")}`));
164
+ return 0;
165
+ }
166
+ if (sub === "check") {
167
+ const teamList = flagBool(args, "all") ? fromSdk(await (await client()).teams()) : null;
168
+ if (teamList && isRefusal(teamList))
169
+ return refusal(ctx, args, teamList);
170
+ const teamKeys = teamList ? (Array.isArray(teamList.body.teams) ? teamList.body.teams : []).map((t) => String(t.teamKey || t.teamId || t.key || "")).filter(Boolean) : [team];
171
+ if (teamList && !Array.isArray(teamList.body.teams))
172
+ return refusal(ctx, args, { status: 502, body: { error: "bad-response", reason: "team list was missing" } });
173
+ if (teamList && teamKeys.length === 0)
174
+ return refusal(ctx, args, { status: 404, body: { error: "no-teams", reason: "No Linear teams are visible to this account; connect the tenant's Linear workspace and retry." } });
175
+ const results = [];
176
+ let failed = false;
177
+ for (const teamKey of teamKeys) {
178
+ const reply = fromSdk(await (await client()).check(teamKey));
179
+ if (isRefusal(reply)) {
180
+ results.push({ team: teamKey, status: reply.status, ...reply.body });
181
+ failed = true;
182
+ break;
183
+ }
184
+ results.push({ team: teamKey, ...reply.body });
185
+ if (reply.body.readiness?.status !== "ready")
186
+ failed = true;
187
+ }
188
+ emit(ctx, args, { results }, results.flatMap((result) => [`${String(result.team)}:`, ...(result.error ? [` refused: ${String(result.error)} — ${String(result.reason ?? result.message ?? "")}`] : readinessLines(result.readiness))]));
189
+ return failed ? 1 : 0;
190
+ }
191
+ if (sub === "checklist") {
192
+ const reply = fromSdk(await (await client()).get(team));
193
+ if (isRefusal(reply))
194
+ return refusal(ctx, args, reply);
195
+ const checklist = reply.body.checklist;
196
+ if (!Array.isArray(checklist) || !checklist.every((line) => typeof line === "string"))
197
+ return refusal(ctx, args, { status: 503, body: { error: "stages-unreadable", reason: "Could not read live Linear stages; retry team checklist." } });
198
+ emit(ctx, args, { team, checklist }, checklist);
199
+ return 0;
200
+ }
201
+ if (sub === "map") {
202
+ const reply = fromSdk(await (await client()).get(team));
203
+ if (isRefusal(reply))
204
+ return refusal(ctx, args, reply);
205
+ if (typeof reply.body.mappingHash !== "string")
206
+ return refusal(ctx, args, { status: 502, body: { error: "bad-response", reason: "team workflow read omitted the mapping hash; update Catalyst Cloud before saving" } });
207
+ let proposal;
208
+ try {
209
+ proposal = mapping(reply.body, assignments(flagList(args, "stage"), "stage"));
210
+ }
211
+ catch (error) {
212
+ return refusal(ctx, args, { status: 400, body: { error: "mapping-unresolved", reason: error instanceof Error ? error.message : String(error) } });
213
+ }
214
+ const config = reply.body.config && typeof reply.body.config === "object" ? reply.body.config : undefined;
215
+ const mode = config?.mode ?? "mapped-existing";
216
+ const gitAutomation = config?.gitAutomation ?? "off";
217
+ const planHash = approvalHash("map", { team, rows: proposal.rows, diff: proposal.diff, unresolved: proposal.unresolved, mappingHash: reply.body.mappingHash, mode, gitAutomation });
218
+ const preview = { team, rows: proposal.rows, diff: proposal.diff, unresolved: proposal.unresolved, mappingHash: reply.body.mappingHash, mode, gitAutomation, planHash, decision: "not-confirmed" };
219
+ if (!flagBool(args, "yes")) {
220
+ emit(ctx, args, preview, planLines(`Mapping plan for ${team}:`, proposal.diff, planHash));
221
+ return 3;
222
+ }
223
+ if (flagString(args, "plan-hash") !== planHash)
224
+ return refusal(ctx, args, { status: 409, body: { error: "plan-hash-mismatch", reason: `Preview again and review the updated plan hash ${planHash}.` } });
225
+ if (!args.json)
226
+ for (const line of planLines(`Mapping plan for ${team}:`, proposal.diff, planHash).slice(0, -1))
227
+ ctx.stdout(line);
228
+ const saved = fromSdk(await (await client()).save({ team, mode, gitAutomation, rows: proposal.rows, expectedMappingHash: reply.body.mappingHash }));
229
+ if (isRefusal(saved))
230
+ return refusal(ctx, args, saved);
231
+ emit(ctx, args, { ...preview, decision: "applied", result: saved.body }, readinessLines(saved.body.readiness));
232
+ return 0;
233
+ }
234
+ if (sub === "adopt") {
235
+ const undo = flagBool(args, "undo");
236
+ const previewReply = fromSdk(undo ? await (await client()).undoPreview(team) : await (await client()).adoptPreview(team));
237
+ if (isRefusal(previewReply))
238
+ return refusal(ctx, args, previewReply);
239
+ const hashName = undo ? "undoHash" : "planHash";
240
+ const hash = previewReply.body[hashName];
241
+ if (typeof hash !== "string" || !Array.isArray(previewReply.body[undo ? "candidates" : "stages"]))
242
+ return refusal(ctx, args, { status: 502, body: { error: "bad-response", reason: `adopt preview omitted ${hashName} or scope` } });
243
+ const planHash = approvalHash(undo ? "adopt-undo" : "adopt", {
244
+ serverHash: hash, teamId: previewReply.body.teamId,
245
+ stages: previewReply.body.stages, labels: previewReply.body.labels,
246
+ candidates: previewReply.body.candidates, unfilledLoadBearing: previewReply.body.unfilledLoadBearing,
247
+ });
248
+ const plan = { team, decision: "not-confirmed", preview: previewReply.body, planHash };
249
+ const scope = [
250
+ ...previewReply.body[undo ? "candidates" : "stages"],
251
+ ...(undo ? [] : Array.isArray(previewReply.body.labels) ? previewReply.body.labels : []),
252
+ ];
253
+ if (!flagBool(args, "yes")) {
254
+ emit(ctx, args, plan, planLines(`${undo ? "Undo" : "Adopt"} plan for ${team}:`, scope, planHash));
255
+ return 3;
256
+ }
257
+ if (flagString(args, "plan-hash") !== planHash)
258
+ return refusal(ctx, args, { status: 409, body: { error: "plan-hash-mismatch", reason: `Preview again and review the updated plan hash ${planHash}.` } });
259
+ if (!args.json)
260
+ for (const line of planLines(`${undo ? "Undo" : "Adopt"} plan for ${team}:`, scope, planHash).slice(0, -1))
261
+ ctx.stdout(line);
262
+ const applied = fromSdk(undo ? await (await client()).undoApply(team, hash) : await (await client()).adoptApply(team, hash));
263
+ if (isRefusal(applied))
264
+ return refusal(ctx, args, applied);
265
+ const outcomes = (undo ? [applied.body.archived, applied.body.kept, applied.body.failed] : [applied.body.stages, applied.body.labels, applied.body.labelsNotCreated, applied.body.provenanceGaps, applied.body.labelProvenanceGaps]).flatMap((v) => Array.isArray(v) ? v : []);
266
+ emit(ctx, args, { team, decision: "applied", preview: previewReply.body, result: applied.body }, [...outcomes.map((o) => ` ${JSON.stringify(o)}`), ...readinessLines(applied.body.readiness)]);
267
+ return 0;
268
+ }
269
+ const choices = assignments(flagList(args, "choice"), "choice").map(([sourceStateId, destinationStateId]) => ({ sourceStateId, destinationStateId }));
270
+ const previewReply = fromSdk(await (await client()).migratePreview(team, choices));
271
+ if (isRefusal(previewReply))
272
+ return refusal(ctx, args, previewReply);
273
+ const preview = previewReply.body.preview;
274
+ if (!preview || typeof preview.migrationHash !== "string" || !Array.isArray(preview.sources))
275
+ return refusal(ctx, args, { status: 502, body: { error: "bad-response", reason: "migration preview omitted hash or source rows" } });
276
+ if (preview.actionsAvailable === false)
277
+ return refusal(ctx, args, { status: 409, body: { error: "migration-approval-unavailable", reason: "Catalyst cannot verify the exact tickets in this migration preview, so moves and retirement are paused. Use Linear's bulk editor to move tickets, or retry when approved migration snapshots are available." } });
278
+ const step = flagBool(args, "retire") ? "retire" : "migrate";
279
+ const planHash = approvalHash(step, { preview, choices });
280
+ const plan = { team, step, decision: "not-confirmed", preview, planHash };
281
+ const blocked = preview.overLimit || preview.sources.some((source) => source.outcome === "needs-a-choice");
282
+ if (!flagBool(args, "yes")) {
283
+ emit(ctx, args, plan, planLines(`Migration ${plan.step} plan for ${team}:`, preview.sources, planHash));
284
+ return 3;
285
+ }
286
+ if (flagString(args, "plan-hash") !== planHash)
287
+ return refusal(ctx, args, { status: 409, body: { error: "plan-hash-mismatch", reason: `Preview again and review the updated plan hash ${planHash}.` } });
288
+ if (blocked)
289
+ return refusal(ctx, args, { status: 400, body: { error: "migration-plan-blocked", reason: "Resolve the preview's blocked sources or issue limit before applying." } });
290
+ if (!args.json)
291
+ for (const line of planLines(`Migration ${plan.step} plan for ${team}:`, preview.sources, planHash).slice(0, -1))
292
+ ctx.stdout(line);
293
+ const results = [];
294
+ const deadline = Date.now() + MAX_RUN_MS;
295
+ let hash = preview.migrationHash;
296
+ for (let n = 0; n < (step === "retire" ? 1 : MAX_CHUNKS); n++) {
297
+ if (Date.now() >= deadline)
298
+ return refusal(ctx, args, { status: 503, body: { error: "migration-time-budget", reason: "The bounded run ended; preview again before continuing." } });
299
+ const result = fromSdk(step === "retire" ? await (await client()).migrateRetire(team, hash, choices) : await (await client()).migrateChunk(team, hash, choices));
300
+ if (isRefusal(result))
301
+ return refusal(ctx, args, result);
302
+ results.push(result.body);
303
+ const partial = Array.isArray(result.body.sources) && result.body.sources.some((source) => source.outcome === "partially-moved");
304
+ if (!args.json) {
305
+ ctx.stdout(`${step} chunk ${n + 1}: moved ${String(result.body.moved ?? 0)}, remaining ${String(result.body.remaining ?? 0)}`);
306
+ for (const source of Array.isArray(result.body.sources) ? result.body.sources : [])
307
+ ctx.stdout(` ${JSON.stringify(source)}`);
308
+ for (const [name, value] of Object.entries(result.body))
309
+ if (name === "failed" || name === "blockers" || name === "logGaps")
310
+ ctx.stdout(` ${name}: ${JSON.stringify(value)}`);
311
+ }
312
+ if (partial) {
313
+ emit(ctx, args, { ...plan, decision: "partial", results }, [`Migration partially completed; at least one source reported a refused or incomplete move.`, ...readinessLines(result.body.readiness)]);
314
+ return 1;
315
+ }
316
+ if (step === "retire" && Array.isArray(result.body.failed) && result.body.failed.length > 0) {
317
+ emit(ctx, args, { ...plan, decision: "partial", results }, [`Source retirement partially completed: ${JSON.stringify(result.body.failed)}`]);
318
+ return 1;
319
+ }
320
+ if (step === "retire" || result.body.remaining === 0) {
321
+ emit(ctx, args, { ...plan, decision: "applied", results }, [...readinessLines(result.body.readiness), ...(step === "migrate" ? ["Tickets moved. Retiring source stages requires a separate team migrate --retire confirmation."] : [])]);
322
+ return 0;
323
+ }
324
+ if (typeof result.body.migrationHash !== "string")
325
+ return refusal(ctx, args, { status: 502, body: { error: "bad-response", reason: "migration chunk omitted the next hash" } });
326
+ hash = result.body.migrationHash;
327
+ }
328
+ return refusal(ctx, args, { status: 503, body: { error: "migration-chunk-limit", reason: "The bounded run ended; preview again before continuing." } });
329
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
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 tenant with one command. The skills directory is the roster; this line never counts it.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.4 — written in this repository for customer tenants -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.5 — written in this repository for customer tenants -->
8
8
 
9
9
  # Catalyst's GitHub
10
10
 
@@ -5,7 +5,7 @@ description: >-
5
5
  allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
6
6
  disable-model-invocation: true
7
7
  ---
8
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.4 — written in this repository for customer tenants -->
8
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.5 — written in this repository for customer tenants -->
9
9
 
10
10
  # Catalyst Linear
11
11