@catalyst-cloud/cli 0.14.2 → 0.14.4

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.4
4
+
5
+ - Personal Linear and GitHub approval links stay out of logs and receipts. Setup validates the signed browser destination and its expiry before opening it.
6
+ - Personal connection checks and browser waits stop on interruption or a deadline. Setup rechecks the current person and workspace, and keeps existing approval when its status is available.
7
+
8
+ ## 0.14.3
9
+
10
+ - Setup can open the workspace's Linear approval from a personal login and wait for a fresh connection check before continuing. A saved credential alone does not count as connected.
11
+ - Workspace approval links stay out of logs and receipts. Interrupted setup resumes with a fresh check, and older clouds keep their supported read-only verification path.
12
+
3
13
  ## 0.14.2
4
14
 
5
15
  Choose an existing Linear team and its accessible repositories in the setup flow. Saved repository choices are checked again against current access and IDs before later setup.
@@ -0,0 +1,229 @@
1
+ import { loadConfig, normalizeBaseUrl, } from "./config.js";
2
+ import { pollConsent } from "./onboard-consent.js";
3
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
4
+ ? value
5
+ : null;
6
+ const waiting = (reason) => ({
7
+ state: "waiting",
8
+ reason,
9
+ });
10
+ const same = (a, b) => !!b?.user &&
11
+ !!a.user &&
12
+ a.account === b.account &&
13
+ a.user.id === b.user.id &&
14
+ normalizeBaseUrl(a.baseUrl) === normalizeBaseUrl(b.baseUrl);
15
+ /** Headers and body share an owned deadline; abandoned reads cannot rotate local credentials. */
16
+ async function read(ctx, journal, path, timeoutMs, external) {
17
+ const cfg = loadConfig(ctx.home);
18
+ if (!cfg?.user)
19
+ return { reason: "personal_login_required" };
20
+ if (((journal.account ?? journal.tenant) &&
21
+ (journal.account ?? journal.tenant) !== cfg.account) ||
22
+ (journal.membershipId && journal.membershipId !== cfg.user.id) ||
23
+ (journal.baseUrl &&
24
+ normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl)))
25
+ return { reason: "personal_identity_refused" };
26
+ const expiry = cfg.auth
27
+ ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime()
28
+ : 0;
29
+ const bearer = cfg.key ||
30
+ (cfg.auth && Number.isFinite(expiry) && expiry > 30_000
31
+ ? cfg.auth.accessToken
32
+ : undefined);
33
+ if (!bearer)
34
+ return { reason: "personal_login_refresh_required" };
35
+ const owned = new AbortController();
36
+ const timer = setTimeout(() => owned.abort(), timeoutMs);
37
+ const signal = external
38
+ ? AbortSignal.any([external, owned.signal])
39
+ : owned.signal;
40
+ let remove = () => { };
41
+ try {
42
+ return await new Promise((resolve) => {
43
+ const stopped = () => resolve({
44
+ reason: external?.aborted
45
+ ? "interrupted"
46
+ : "personal_status_unavailable",
47
+ });
48
+ if (signal.aborted) {
49
+ stopped();
50
+ return;
51
+ }
52
+ signal.addEventListener("abort", stopped, { once: true });
53
+ remove = () => signal.removeEventListener("abort", stopped);
54
+ Promise.resolve()
55
+ .then(async () => {
56
+ if (signal.aborted)
57
+ return { reason: "interrupted" };
58
+ const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}${path}`, {
59
+ method: "GET",
60
+ redirect: "error",
61
+ headers: {
62
+ authorization: `Bearer ${bearer}`,
63
+ accept: "application/json",
64
+ },
65
+ signal,
66
+ });
67
+ if (response.status !== 200)
68
+ return { status: response.status };
69
+ const body = await response.json();
70
+ if (!same(cfg, loadConfig(ctx.home)))
71
+ return { reason: "personal_identity_refused" };
72
+ return { status: 200, body };
73
+ })
74
+ .then((value) => (signal.aborted ? stopped() : resolve(value)), () => signal.aborted
75
+ ? stopped()
76
+ : resolve({ reason: "personal_status_unavailable" }));
77
+ });
78
+ }
79
+ finally {
80
+ clearTimeout(timer);
81
+ remove();
82
+ }
83
+ }
84
+ function failure(value, start = false) {
85
+ if ("reason" in value)
86
+ return ["personal_login_required", "personal_identity_refused"].includes(value.reason)
87
+ ? { state: "refused", reason: value.reason }
88
+ : waiting(value.reason);
89
+ if (value.status === 401 || value.status === 403)
90
+ return { state: "refused", reason: "personal_consent_refused" };
91
+ if (value.status === 503)
92
+ return waiting("personal_status_unavailable");
93
+ if (value.status === 404)
94
+ return waiting("cloud_capability_unavailable");
95
+ return value.status === 200
96
+ ? null
97
+ : {
98
+ state: "failed",
99
+ reason: start
100
+ ? "personal_consent_start_failed"
101
+ : "personal_status_failed",
102
+ };
103
+ }
104
+ function handoff(body, provider, cfg, now) {
105
+ const row = object(body);
106
+ if (!row ||
107
+ typeof row.authorizationUrl !== "string" ||
108
+ row.authorizationUrl.length > 8192 ||
109
+ /[\u0000-\u0020\u007f]/.test(row.authorizationUrl) ||
110
+ typeof row.expiresAt !== "number" ||
111
+ !Number.isFinite(row.expiresAt) ||
112
+ row.expiresAt <= now ||
113
+ row.expiresAt > now + 630_000)
114
+ return null;
115
+ try {
116
+ const url = new URL(row.authorizationUrl);
117
+ const keys = [...url.searchParams.keys()];
118
+ const token = url.searchParams.get("handoff");
119
+ if (url.protocol !== "https:" ||
120
+ url.origin !== new URL(normalizeBaseUrl(cfg.baseUrl)).origin ||
121
+ url.username ||
122
+ url.password ||
123
+ url.pathname !== `/connect/${provider}/personal/start` ||
124
+ url.hash ||
125
+ keys.length !== 1 ||
126
+ keys[0] !== "handoff" ||
127
+ !token ||
128
+ token.length > 4096 ||
129
+ /[\s\u0000-\u001f\u007f]/.test(token))
130
+ return null;
131
+ return url.toString();
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ /** The supported personal status reports server-resolved grant availability. A warm grant is
138
+ * not a new provider viewer probe, repository authorization, or workspace installation verdict. */
139
+ export function personalConsentAdapter(options) {
140
+ const timeout = options.requestTimeoutMs ?? 30_000;
141
+ const consentTimeout = options.consentTimeoutMs ?? 600_000;
142
+ if (!["linear", "github"].includes(options.provider) ||
143
+ !Number.isSafeInteger(timeout) ||
144
+ timeout < 1 ||
145
+ timeout > 30_000 ||
146
+ !Number.isSafeInteger(consentTimeout) ||
147
+ consentTimeout < 1 ||
148
+ consentTimeout > 600_000)
149
+ throw new Error("personal_timeout_invalid");
150
+ const statusPath = `/api/v1/me/connections/${options.provider}/personal`;
151
+ const inspect = async (ctx, journal, signal) => {
152
+ const response = await read(ctx, journal, statusPath, timeout, signal);
153
+ const failed = failure(response);
154
+ if (failed)
155
+ return failed;
156
+ const body = object("body" in response ? response.body : undefined);
157
+ if (!body ||
158
+ typeof body.connected !== "boolean" ||
159
+ (body.reason !== undefined &&
160
+ (body.connected || body.reason !== "lapsed")))
161
+ return { state: "failed", reason: "personal_status_shape" };
162
+ return body.connected
163
+ ? {
164
+ state: "done",
165
+ evidence: {
166
+ provider: options.provider,
167
+ checkedAt: ctx.now().getTime(),
168
+ },
169
+ }
170
+ : { state: "pending" };
171
+ };
172
+ return {
173
+ check: inspect,
174
+ act: async (ctx, journal, signal) => {
175
+ if (signal?.aborted)
176
+ return waiting("interrupted");
177
+ const cfg = loadConfig(ctx.home);
178
+ if (!cfg?.user)
179
+ return { state: "refused", reason: "personal_login_required" };
180
+ const before = await inspect(ctx, journal, signal);
181
+ if (before.state !== "pending")
182
+ return before;
183
+ const start = await read(ctx, journal, `/connect/${options.provider}/personal/start`, timeout, signal);
184
+ if ("status" in start &&
185
+ start.status === 409 &&
186
+ options.provider === "linear")
187
+ return waiting("linear_workspace_required");
188
+ const failed = failure(start, true);
189
+ if (failed)
190
+ return failed;
191
+ const url = handoff("body" in start ? start.body : undefined, options.provider, cfg, ctx.now().getTime());
192
+ if (!url)
193
+ return { state: "refused", reason: "personal_consent_handoff" };
194
+ if (signal?.aborted)
195
+ return waiting("interrupted");
196
+ if (!same(cfg, loadConfig(ctx.home)))
197
+ return { state: "refused", reason: "personal_identity_refused" };
198
+ const name = options.provider === "linear" ? "Linear" : "GitHub";
199
+ ctx.stderr(`Approve your personal ${name} connection in the browser.`);
200
+ // Signed continuation credentials belong only to the browser, never transcript or journal.
201
+ try {
202
+ options.openBrowser(url);
203
+ }
204
+ catch {
205
+ return waiting("personal_browser_unavailable");
206
+ }
207
+ let latest = waiting("personal_status_unavailable");
208
+ const run = () => pollConsent({
209
+ timeoutMs: consentTimeout,
210
+ signal,
211
+ sleep: options.sleep,
212
+ readStatus: async (pollSignal) => {
213
+ latest = await inspect(ctx, journal, pollSignal);
214
+ if (latest.state === "done")
215
+ return { outcome: "connected" };
216
+ if (latest.state === "refused" || latest.state === "failed")
217
+ return { outcome: latest.state, reason: latest.reason };
218
+ return {
219
+ outcome: latest.state === "pending" ? "absent" : "unavailable",
220
+ };
221
+ },
222
+ });
223
+ const result = options.wait
224
+ ? await options.wait(`Waiting for your ${name} approval`, run)
225
+ : await run();
226
+ return result.state === "done" ? latest : result;
227
+ },
228
+ };
229
+ }
@@ -2,16 +2,20 @@ import { existsSync, realpathSync } from "node:fs";
2
2
  import { userInfo } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { openBrowser } from "./browser.js";
5
- import { defaultSkillsDirFor, loadConfig, normalizeBaseUrl, readManifest } from "./config.js";
5
+ import { defaultSkillsDirFor, loadConfig, normalizeBaseUrl, readManifest, } from "./config.js";
6
6
  import { CliError } from "./errors.js";
7
7
  import { cmdLegacy, findLegacy } from "./legacy.js";
8
8
  import { bearerFor } from "./oauth.js";
9
- import { pollConsent } from "./onboard-consent.js";
9
+ import { personalConsentAdapter } from "./onboard-personal.js";
10
10
  import { boundedOnboardSignin } from "./onboard-signin.js";
11
11
  import { existingLinearAdapters } from "./onboard-existing.js";
12
+ import { linearWorkspaceAdapter } from "./onboard-workspace.js";
12
13
  import { existingRepositoryAdapter } from "./onboard-repositories.js";
13
14
  import { fetchMe } from "./transport.js";
14
- const waiting = (reason) => ({ state: "waiting", reason });
15
+ const waiting = (reason) => ({
16
+ state: "waiting",
17
+ reason,
18
+ });
15
19
  function sameHome(a, b) {
16
20
  try {
17
21
  return realpathSync(a) === realpathSync(b);
@@ -27,126 +31,152 @@ export function createOnboardRuntime(args, ctx, hooks) {
27
31
  if (!cfg)
28
32
  return null;
29
33
  const requested = args.baseUrl ?? stepCtx.env.CATALYST_CLOUD_BASE_URL;
30
- if (requested && normalizeBaseUrl(requested) !== normalizeBaseUrl(cfg.baseUrl))
34
+ if (requested &&
35
+ normalizeBaseUrl(requested) !== normalizeBaseUrl(cfg.baseUrl))
31
36
  throw new CliError("the saved login uses another cloud; resume against that cloud", "onboard-base-url-mismatch", 12);
32
37
  if (!cfg.user)
33
38
  throw new CliError("sign in as yourself to set up Catalyst", "onboard-person-required", 12);
34
- if (journal && (((journal.account ?? journal.tenant) && (journal.account ?? journal.tenant) !== cfg.account) ||
35
- (journal.membershipId && journal.membershipId !== cfg.user.id) ||
36
- (journal.baseUrl && normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))))
39
+ if (journal &&
40
+ (((journal.account ?? journal.tenant) &&
41
+ (journal.account ?? journal.tenant) !== cfg.account) ||
42
+ (journal.membershipId && journal.membershipId !== cfg.user.id) ||
43
+ (journal.baseUrl &&
44
+ normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))))
37
45
  throw new CliError("the saved setup belongs to another workspace or member", "onboard-identity-mismatch", 12);
38
46
  const me = await fetchMe(cfg.baseUrl, await bearerFor(stepCtx, cfg), stepCtx.fetch);
39
47
  if (!me.user || me.account !== cfg.account || me.user.id !== cfg.user.id)
40
48
  throw new CliError("the saved login and live member do not match", "onboard-identity-mismatch", 12);
41
49
  // /me names a person within the account, not an independently exposed membership-row ID.
42
- return { account: me.account, membershipId: me.user.id, baseUrl: normalizeBaseUrl(cfg.baseUrl), role: me.user.role };
50
+ return {
51
+ account: me.account,
52
+ membershipId: me.user.id,
53
+ baseUrl: normalizeBaseUrl(cfg.baseUrl),
54
+ role: me.user.role,
55
+ };
43
56
  };
44
57
  const legacyCheck = async (stepCtx) => {
45
- const found = findLegacy(stepCtx.home, hooks.legacy?.platform ?? process.platform).filter(item => !item.data).length;
58
+ const found = findLegacy(stepCtx.home, hooks.legacy?.platform ?? process.platform).filter((item) => !item.data).length;
46
59
  if (!sameHome(stepCtx.home, (hooks.realHome ?? (() => userInfo().homedir))()))
47
- return { state: "skipped", reason: "fake_home_report_only", evidence: { found, remaining: found } };
48
- return { state: found ? "pending" : "done", evidence: { found, remaining: found } };
60
+ return {
61
+ state: "skipped",
62
+ reason: "fake_home_report_only",
63
+ evidence: { found, remaining: found },
64
+ };
65
+ return {
66
+ state: found ? "pending" : "done",
67
+ evidence: { found, remaining: found },
68
+ };
49
69
  };
50
- const unsupported = { check: async () => waiting("cloud_capability_unavailable"), act: async () => waiting("cloud_capability_unavailable") };
51
- const personalStatus = async (provider, stepCtx, signal) => {
52
- const cfg = loadConfig(stepCtx.home);
53
- if (!cfg || !cfg.user)
54
- return { outcome: "refused", reason: "personal_login_required" };
55
- const response = await stepCtx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}/api/v1/me/connections/${provider}/personal`, {
56
- headers: { authorization: `Bearer ${await bearerFor(stepCtx, cfg)}`, accept: "application/json" },
57
- signal: signal ?? AbortSignal.timeout(30_000),
58
- });
59
- if (response.status === 401 || response.status === 403)
60
- return { outcome: "refused", reason: "personal_consent_refused" };
61
- if (response.status === 503)
62
- return { outcome: "unavailable" };
63
- if (!response.ok)
64
- return { outcome: "failed", reason: "personal_status_failed" };
65
- const body = await response.json();
66
- if (!body || typeof body !== "object" || !("connected" in body))
67
- return { outcome: "failed", reason: "personal_status_shape" };
68
- if (body.connected === true)
69
- return { outcome: "connected" };
70
- if (body.connected !== false)
71
- return { outcome: "failed", reason: "personal_status_shape" };
72
- return { outcome: "reason" in body && body.reason === "lapsed" ? "lapsed" : "absent" };
70
+ const unsupported = {
71
+ check: async () => waiting("cloud_capability_unavailable"),
72
+ act: async () => waiting("cloud_capability_unavailable"),
73
73
  };
74
- const personalAdapter = (provider) => ({
75
- check: async (stepCtx) => {
76
- const result = await personalStatus(provider, stepCtx);
77
- if (result.outcome === "connected")
78
- return { state: "done" };
79
- if (result.outcome === "refused" || result.outcome === "failed")
80
- return { state: result.outcome, reason: result.reason };
81
- return result.outcome === "unavailable" ? waiting("personal_status_unavailable") : { state: "pending" };
82
- }, act: async (stepCtx, _journal, signal) => {
83
- const cfg = loadConfig(stepCtx.home);
84
- if (!cfg || !cfg.user)
85
- return { state: "refused", reason: "personal_login_required" };
86
- const response = await stepCtx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}/connect/${provider}/personal/start`, {
87
- headers: { authorization: `Bearer ${await bearerFor(stepCtx, cfg)}`, accept: "application/json" }, signal: AbortSignal.timeout(30_000),
88
- });
89
- if (response.status === 409)
90
- return waiting(provider === "linear" ? "linear_workspace_required" : "github_workspace_required");
91
- if (response.status === 401 || response.status === 403)
92
- return { state: "refused", reason: "personal_consent_refused" };
93
- if (!response.ok)
94
- return { state: "failed", reason: "personal_consent_start_failed" };
95
- const body = await response.json();
96
- if (typeof body.authorizationUrl !== "string" || typeof body.expiresAt !== "number")
97
- return { state: "failed", reason: "personal_consent_shape" };
98
- let url;
99
- try {
100
- url = new URL(body.authorizationUrl);
101
- }
102
- catch {
103
- return { state: "failed", reason: "personal_consent_shape" };
104
- }
105
- if (url.protocol !== "https:" || url.origin !== new URL(cfg.baseUrl).origin || url.username || url.password)
106
- return { state: "refused", reason: "personal_consent_origin" };
107
- stepCtx.stderr(`Approve your personal ${provider === "linear" ? "Linear" : "GitHub"} connection in the browser.`);
108
- stepCtx.stderr(url.toString());
109
- (hooks.openBrowser ?? openBrowser)(url.toString());
110
- const poll = () => pollConsent({ readStatus: pollSignal => personalStatus(provider, stepCtx, pollSignal), sleep: hooks.sleep, signal });
111
- return hooks.ui ? hooks.ui.wait(`Waiting for your ${provider === "linear" ? "Linear" : "GitHub"} approval`, poll) : poll();
112
- }
74
+ const personalAdapter = (provider) => personalConsentAdapter({
75
+ provider,
76
+ openBrowser: hooks.openBrowser ?? openBrowser,
77
+ sleep: hooks.sleep,
78
+ wait: hooks.ui
79
+ ? (message, run) => hooks.ui.wait(message, run)
80
+ : undefined,
113
81
  });
82
+ const linear = existingLinearAdapters(args, hooks.ui?.chooseTeam
83
+ ? (teams) => hooks.ui.chooseTeam(teams.map((team) => ({ ...team })))
84
+ : undefined);
114
85
  return {
115
- identity: journal => identity(ctx, journal), bindSignals: true, ui: hooks.ui,
86
+ identity: (journal) => identity(ctx, journal),
87
+ bindSignals: true,
88
+ ui: hooks.ui,
116
89
  adapters: {
117
- machine: { check: async () => process.platform === "darwin" || process.platform === "linux"
118
- ? { state: "done", evidence: { provider: process.platform } } : waiting("machine_platform_unsupported") },
119
- cli: { check: async () => ({ state: "done", evidence: { version: readManifest().version } }) },
120
- skills: { check: async (stepCtx) => {
90
+ machine: {
91
+ check: async () => process.platform === "darwin" || process.platform === "linux"
92
+ ? { state: "done", evidence: { provider: process.platform } }
93
+ : waiting("machine_platform_unsupported"),
94
+ },
95
+ cli: {
96
+ check: async () => ({
97
+ state: "done",
98
+ evidence: { version: readManifest().version },
99
+ }),
100
+ },
101
+ skills: {
102
+ check: async (stepCtx) => {
121
103
  const cfg = loadConfig(stepCtx.home);
122
- const dir = stepCtx.env.CATALYST_SKILLS_DIR ?? cfg?.skillsDir ?? defaultSkillsDirFor(stepCtx.home);
104
+ const dir = stepCtx.env.CATALYST_SKILLS_DIR ??
105
+ cfg?.skillsDir ??
106
+ defaultSkillsDirFor(stepCtx.home);
123
107
  const names = hooks.skillNames ?? [];
124
- const count = names.filter(name => existsSync(join(dir, name, "SKILL.md"))).length;
125
- return names.length > 0 && count === names.length ? { state: "done", evidence: { count, path: dir } } : waiting("skills_install_unverified");
126
- } },
127
- legacy: { check: legacyCheck, act: async (stepCtx) => {
108
+ const count = names.filter((name) => existsSync(join(dir, name, "SKILL.md"))).length;
109
+ return names.length > 0 && count === names.length
110
+ ? { state: "done", evidence: { count, path: dir } }
111
+ : waiting("skills_install_unverified");
112
+ },
113
+ },
114
+ legacy: {
115
+ check: legacyCheck,
116
+ act: async (stepCtx) => {
128
117
  const before = await legacyCheck(stepCtx);
129
118
  if (before.state !== "pending")
130
119
  return before;
131
120
  // Provider/process output is not trusted to be secret-free; keep it out of the receipt/log.
132
121
  const quiet = { ...stepCtx, stdout: () => { }, stderr: () => { } };
133
- const code = await cmdLegacy({ ...args, command: "legacy", subcommand: null, rest: [], flags: { remove: true, yes: true }, json: false }, quiet, hooks.legacy);
134
- return code === 0 ? legacyCheck(stepCtx) : { state: "failed", reason: "legacy_cleanup_failed" };
135
- } },
136
- signin: { check: async (stepCtx, journal) => {
122
+ const code = await cmdLegacy({
123
+ ...args,
124
+ command: "legacy",
125
+ subcommand: null,
126
+ rest: [],
127
+ flags: { remove: true, yes: true },
128
+ json: false,
129
+ }, quiet, hooks.legacy);
130
+ return code === 0
131
+ ? legacyCheck(stepCtx)
132
+ : { state: "failed", reason: "legacy_cleanup_failed" };
133
+ },
134
+ },
135
+ signin: {
136
+ check: async (stepCtx, journal) => {
137
137
  const who = await identity(stepCtx, journal);
138
- return who ? { state: "done", evidence: { account: who.account, membershipId: who.membershipId, role: who.role } } : { state: "pending" };
139
- }, act: async (stepCtx, _journal, signal) => {
138
+ return who
139
+ ? {
140
+ state: "done",
141
+ evidence: {
142
+ account: who.account,
143
+ membershipId: who.membershipId,
144
+ role: who.role,
145
+ },
146
+ }
147
+ : { state: "pending" };
148
+ },
149
+ act: async (stepCtx, _journal, signal) => {
140
150
  return boundedOnboardSignin({ ...stepCtx, stdout: stepCtx.stderr }, hooks.login, signal, hooks.signinTimeoutMs);
141
- } },
142
- ...existingLinearAdapters(args, hooks.ui?.chooseTeam ? teams => hooks.ui.chooseTeam(teams.map(team => ({ ...team }))) : undefined),
151
+ },
152
+ },
153
+ ...linear,
154
+ "linear.workspace": linearWorkspaceAdapter({
155
+ fallback: linear["linear.workspace"],
156
+ openBrowser: hooks.openBrowser ?? openBrowser,
157
+ wait: hooks.ui
158
+ ? (message, work) => hooks.ui.wait(message, work)
159
+ : undefined,
160
+ sleep: hooks.sleep,
161
+ }),
143
162
  "github.install": unsupported,
144
- "github.repos": existingRepositoryAdapter(args, hooks.ui?.chooseRepositories ? repositories => hooks.ui.chooseRepositories(repositories.map(row => ({ ...row }))) : undefined),
163
+ "github.repos": existingRepositoryAdapter(args, hooks.ui?.chooseRepositories
164
+ ? (repositories) => hooks.ui.chooseRepositories(repositories.map((row) => ({ ...row })))
165
+ : undefined),
145
166
  "linear.personal": personalAdapter("linear"),
146
167
  "github.personal": personalAdapter("github"),
147
- daemon: { check: async (_stepCtx, journal) => (journal.localSync ?? args.flags["local-sync"] === true) ? waiting("local_sync_capability_unavailable")
148
- : { state: "skipped", reason: "local_sync_not_selected", evidence: { provider: "cloud" } } },
149
- housekeeping: { check: async () => waiting("housekeeping_service_unverified") },
168
+ daemon: {
169
+ check: async (_stepCtx, journal) => (journal.localSync ?? args.flags["local-sync"] === true)
170
+ ? waiting("local_sync_capability_unavailable")
171
+ : {
172
+ state: "skipped",
173
+ reason: "local_sync_not_selected",
174
+ evidence: { provider: "cloud" },
175
+ },
176
+ },
177
+ housekeeping: {
178
+ check: async () => waiting("housekeeping_service_unverified"),
179
+ },
150
180
  ready: { check: hooks.ready },
151
181
  },
152
182
  };
@@ -1,13 +1,17 @@
1
- import { createOnboardProgress } from "./onboard-progress.js";
2
- import { ONBOARD_STEPS, ONBOARD_TITLES } from "./onboard.js";
1
+ import { createOnboardProgress, } from "./onboard-progress.js";
2
+ import { ONBOARD_STEPS, ONBOARD_TITLES, } from "./onboard.js";
3
3
  export function shouldUseOnboardUi(args, stdoutTty) {
4
- return stdoutTty && !args.json && args.flags.yes !== true && args.flags["dry-run"] !== true;
4
+ return (stdoutTty &&
5
+ !args.json &&
6
+ args.flags.yes !== true &&
7
+ args.flags["dry-run"] !== true);
5
8
  }
6
9
  export function createClackOnboardUi(prompts, streams, deps = {}) {
7
10
  const abort = new AbortController();
8
11
  const options = { ...streams, signal: abort.signal };
9
12
  // Clack 1.8.1's spinner calls process.exit(0) on raw Ctrl-C, bypassing receipt/lock cleanup.
10
- const spin = deps.progress ?? createOnboardProgress(streams.output, abort.signal, () => abort.abort());
13
+ const spin = deps.progress ??
14
+ createOnboardProgress(streams.output, abort.signal, () => abort.abort());
11
15
  const signals = deps.signals ?? process;
12
16
  const interrupt = () => abort.abort();
13
17
  for (const event of ["SIGINT", "SIGTERM", "SIGHUP"])
@@ -15,15 +19,23 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
15
19
  let active = false;
16
20
  let introduced = false;
17
21
  let group;
18
- const stop = () => { if (active) {
19
- spin.stop();
20
- active = false;
21
- } };
22
- const message = (text) => { stop(); prompts.log.message(text, { output: streams.output }); };
23
- const start = (text) => { stop(); if (!abort.signal.aborted) {
24
- spin.start(text);
25
- active = true;
26
- } };
22
+ const stop = () => {
23
+ if (active) {
24
+ spin.stop();
25
+ active = false;
26
+ }
27
+ };
28
+ const message = (text) => {
29
+ stop();
30
+ prompts.log.message(text, { output: streams.output });
31
+ };
32
+ const start = (text) => {
33
+ stop();
34
+ if (!abort.signal.aborted) {
35
+ spin.start(text);
36
+ active = true;
37
+ }
38
+ };
27
39
  return {
28
40
  signal: abort.signal,
29
41
  plan(journal) {
@@ -32,52 +44,89 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
32
44
  prompts.intro("Catalyst setup", { output: streams.output });
33
45
  introduced = true;
34
46
  }
35
- const steps = new Map(journal.steps.map(step => [step.id, step]));
36
- message(ONBOARD_STEPS.map(id => `· ${ONBOARD_TITLES[id]}${steps.get(id)?.state === "done" ? " (recheck)" : ""}`).join("\n"));
47
+ const steps = new Map(journal.steps.map((step) => [step.id, step]));
48
+ message(ONBOARD_STEPS.map((id) => `· ${ONBOARD_TITLES[id]}${steps.get(id)?.state === "done" ? " (recheck)" : ""}`).join("\n"));
37
49
  message("Use cloud reads by default. Local sync is optional for SQL, offline work or sustained reads.");
38
50
  },
39
51
  async confirmPlan(localSync) {
40
52
  stop();
41
- const answer = await prompts.select({ ...options, message: "Continue with this plan?", initialValue: localSync ? "local" : "cloud", options: [
42
- { value: "cloud", label: "Continue using cloud reads", hint: "default" },
53
+ const answer = await prompts.select({
54
+ ...options,
55
+ message: "Continue with this plan?",
56
+ initialValue: localSync ? "local" : "cloud",
57
+ options: [
58
+ {
59
+ value: "cloud",
60
+ label: "Continue using cloud reads",
61
+ hint: "default",
62
+ },
43
63
  { value: "local", label: "Continue and set up local sync" },
44
64
  { value: "stop", label: "Stop without changes" },
45
- ] });
65
+ ],
66
+ });
46
67
  if (prompts.isCancel(answer)) {
47
68
  abort.abort();
48
69
  return { proceed: false, localSync };
49
70
  }
50
- return { proceed: answer === "cloud" || answer === "local", localSync: answer === "local" };
71
+ return {
72
+ proceed: answer === "cloud" || answer === "local",
73
+ localSync: answer === "local",
74
+ };
51
75
  },
52
76
  async chooseTeam(teams) {
53
77
  stop();
54
78
  if (!teams.length || abort.signal.aborted)
55
79
  return null;
56
- const answer = await prompts.select({ ...options, message: "Which Linear team should this project use?", initialValue: teams[0].id,
57
- options: teams.map(team => ({ value: team.id, label: `${team.name || team.key || team.id}${team.key && team.name ? ` (${team.key})` : ""}` })) });
80
+ const answer = await prompts.select({
81
+ ...options,
82
+ message: "Which Linear team should this project use?",
83
+ initialValue: teams[0].id,
84
+ options: teams.map((team) => ({
85
+ value: team.id,
86
+ label: `${team.name || team.key || team.id}${team.key && team.name ? ` (${team.key})` : ""}`,
87
+ })),
88
+ });
58
89
  if (prompts.isCancel(answer)) {
59
90
  abort.abort();
60
91
  return null;
61
92
  }
62
93
  return typeof answer === "string" ? answer : null;
63
94
  },
64
- ...(prompts.multiselect ? { async chooseRepositories(repositories) {
65
- stop();
66
- if (!repositories.length || abort.signal.aborted)
67
- return null;
68
- const answer = await prompts.multiselect({ ...options, message: "Which repositories should use this Linear team?", required: true,
69
- options: repositories.map(row => ({ value: `${row.owner}/${row.name}`, label: `${row.owner}/${row.name}` })) });
70
- if (prompts.isCancel(answer)) {
71
- abort.abort();
72
- return null;
73
- }
74
- return Array.isArray(answer) ? answer : null;
75
- } } : {}),
95
+ ...(prompts.multiselect
96
+ ? {
97
+ async chooseRepositories(repositories) {
98
+ stop();
99
+ if (!repositories.length || abort.signal.aborted)
100
+ return null;
101
+ const answer = await prompts.multiselect({
102
+ ...options,
103
+ message: "Which repositories should use this Linear team?",
104
+ required: true,
105
+ options: repositories.map((row) => ({
106
+ value: `${row.owner}/${row.name}`,
107
+ label: `${row.owner}/${row.name}`,
108
+ })),
109
+ });
110
+ if (prompts.isCancel(answer)) {
111
+ abort.abort();
112
+ return null;
113
+ }
114
+ return Array.isArray(answer) ? answer : null;
115
+ },
116
+ }
117
+ : {}),
76
118
  stepStart(id) {
77
- const next = ["machine", "cli", "skills", "legacy"].includes(id) ? "This computer"
78
- : id === "signin" ? "Catalyst sign-in" : id.startsWith("linear.") || id.startsWith("github.") ? "Connections"
79
- : ["projects", "accounts", "settings", "values"].includes(id) ? "Project setup"
80
- : ["capacity", "daemon", "housekeeping"].includes(id) ? "Runner and services" : "Work and readiness";
119
+ const next = ["machine", "cli", "skills", "legacy"].includes(id)
120
+ ? "This computer"
121
+ : id === "signin"
122
+ ? "Catalyst sign-in"
123
+ : id.startsWith("linear.") || id.startsWith("github.")
124
+ ? "Connections"
125
+ : ["projects", "accounts", "settings", "values"].includes(id)
126
+ ? "Project setup"
127
+ : ["capacity", "daemon", "housekeeping"].includes(id)
128
+ ? "Runner and services"
129
+ : "Work and readiness";
81
130
  if (next !== group) {
82
131
  message(next);
83
132
  group = next;
@@ -87,6 +136,11 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
87
136
  stepEnd(step) {
88
137
  stop();
89
138
  const reasons = {
139
+ personal_consent_handoff: "The browser approval link could not be verified. Run catalyst onboard to try again.",
140
+ personal_identity_refused: "Your current login no longer matches this setup. Resume with the original workspace and person.",
141
+ personal_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
142
+ personal_status_unavailable: "Your personal connection could not be checked. Run catalyst onboard to try again.",
143
+ personal_browser_unavailable: "The browser could not open. Run catalyst onboard to try again.",
90
144
  cloud_capability_unavailable: "Your cloud does not support this setup step yet.",
91
145
  step_not_available_in_this_release: "This setup step is not available yet.",
92
146
  prerequisite_not_ready: "Waiting for an earlier setup step.",
@@ -115,27 +169,39 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
115
169
  repository_selection_unverified: "Your selected repositories could not be verified. Run catalyst onboard --repo <owner/name> to choose again.",
116
170
  };
117
171
  const text = `${ONBOARD_TITLES[step.id]}${step.reason ? `: ${reasons[step.reason] ?? step.reason.replaceAll("_", " ")}` : ""}`;
118
- const kind = step.state === "done" ? "info" : step.state === "failed" ? "error" : step.state === "waiting" || step.state === "pending" ? "warn" : "message";
119
- prompts.log[kind](`${step.state === "done" ? "✓ " : ""}${text}`, { output: streams.output });
172
+ const kind = step.state === "done"
173
+ ? "info"
174
+ : step.state === "failed"
175
+ ? "error"
176
+ : step.state === "waiting" || step.state === "pending"
177
+ ? "warn"
178
+ : "message";
179
+ prompts.log[kind](`${step.state === "done" ? "✓ " : ""}${text}`, {
180
+ output: streams.output,
181
+ });
120
182
  },
121
183
  message,
122
184
  finish(journal, only) {
123
185
  stop();
124
186
  let text = "Setup still needs checks. Run catalyst onboard to resume.";
125
187
  if (abort.signal.aborted)
126
- text = "Setup paused. Your progress is saved.\nresume: catalyst onboard";
188
+ text =
189
+ "Setup paused. Your progress is saved.\nresume: catalyst onboard";
127
190
  else if (journal.complete && journal.exit === 0 && !only)
128
191
  text = "Onboarding complete.";
129
192
  else if (only && journal.exit === 0)
130
193
  text = `${ONBOARD_TITLES[only]} finished. Onboarding still has other steps.\nresume: catalyst onboard`;
131
194
  prompts.outro(text, { output: streams.output });
132
195
  },
133
- async wait(text, run) { start(text); try {
134
- return await run();
135
- }
136
- finally {
137
- stop();
138
- } },
196
+ async wait(text, run) {
197
+ start(text);
198
+ try {
199
+ return await run();
200
+ }
201
+ finally {
202
+ stop();
203
+ }
204
+ },
139
205
  dispose() {
140
206
  try {
141
207
  stop();
@@ -0,0 +1,185 @@
1
+ import { loadConfig, normalizeBaseUrl } from "./config.js";
2
+ import { pollConsent } from "./onboard-consent.js";
3
+ const STATUS = "/api/v1/me/connections/linear/workspace";
4
+ const HANDOFF = "/connect/linear/workspace/handoff";
5
+ const idPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
6
+ const states = new Set(["connected", "missing-scope", "expired-or-revoked", "unreachable", "not-connected"]);
7
+ const warmth = new Set(["warm", "absent", "expired", "renewal-due", "no-expiry", "unavailable"]);
8
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
9
+ const waiting = (reason) => ({ state: "waiting", reason });
10
+ const nullableText = (v) => v === null || typeof v === "string";
11
+ const nullableTime = (v) => v === null || typeof v === "number" && Number.isFinite(v) && v >= 0;
12
+ /** A bounded read never rotates credentials or writes a token after the receipt lock is released. */
13
+ async function read(ctx, journal, path, timeoutMs, external) {
14
+ const cfg = loadConfig(ctx.home);
15
+ if (!cfg?.user)
16
+ return { reason: "personal_login_required" };
17
+ if ((journal.account ?? journal.tenant) && (journal.account ?? journal.tenant) !== cfg.account ||
18
+ journal.membershipId && journal.membershipId !== cfg.user.id ||
19
+ journal.baseUrl && normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
20
+ return { reason: "workspace_identity_refused" };
21
+ const expiry = cfg.auth ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() : 0;
22
+ const bearer = cfg.key || (cfg.auth && Number.isFinite(expiry) && expiry > 30_000 ? cfg.auth.accessToken : undefined);
23
+ if (!bearer)
24
+ return { reason: "workspace_login_refresh_required" };
25
+ const deadline = new AbortController();
26
+ const timer = setTimeout(() => deadline.abort(), timeoutMs);
27
+ const signal = external ? AbortSignal.any([external, deadline.signal]) : deadline.signal;
28
+ let remove = () => { };
29
+ try {
30
+ return await new Promise(resolve => {
31
+ const stopped = () => resolve({ reason: external?.aborted ? "interrupted" : "workspace_status_unavailable" });
32
+ if (signal.aborted) {
33
+ stopped();
34
+ return;
35
+ }
36
+ signal.addEventListener("abort", stopped, { once: true });
37
+ remove = () => signal.removeEventListener("abort", stopped);
38
+ Promise.resolve().then(async () => {
39
+ if (signal.aborted)
40
+ return { reason: "interrupted" };
41
+ const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}${path}`, {
42
+ method: "GET", redirect: "error", headers: { authorization: `Bearer ${bearer}`, accept: "application/json" }, signal,
43
+ });
44
+ // Refusal bodies and provider diagnostics are never parsed, returned or printed.
45
+ if (response.status !== 200)
46
+ return { status: response.status };
47
+ return { status: 200, body: await response.json() };
48
+ }).then(value => signal.aborted ? stopped() : resolve(value), () => signal.aborted ? stopped() : resolve({ reason: "workspace_status_unavailable" }));
49
+ });
50
+ }
51
+ finally {
52
+ clearTimeout(timer);
53
+ remove();
54
+ }
55
+ }
56
+ function readFailure(value) {
57
+ if ("reason" in value)
58
+ return value.reason === "workspace_identity_refused" || value.reason === "personal_login_required"
59
+ ? { state: "refused", reason: value.reason } : waiting(value.reason);
60
+ if (value.status === 401 || value.status === 403)
61
+ return { state: "refused", reason: "workspace_consent_refused" };
62
+ return value.status === 200 ? null : waiting("workspace_status_unavailable");
63
+ }
64
+ /** Live liveness plus stored grant scopes is a connection verdict, not live workspace identity. */
65
+ function observation(body, now) {
66
+ const row = object(body);
67
+ const workspace = object(row?.workspace);
68
+ const credential = object(row?.credential);
69
+ const verification = object(row?.verification);
70
+ if (!row || typeof row.connected !== "boolean" || !workspace || typeof workspace.bound !== "boolean" ||
71
+ !nullableText(workspace.workspaceId) || !nullableText(workspace.workspaceSlug) || !nullableText(workspace.workspaceName) ||
72
+ !credential || ![true, false, null].includes(credential.stored) ||
73
+ !nullableTime(credential.updatedAt) || !nullableTime(credential.expiresAt) || (typeof credential.warmth !== "string" || !warmth.has(credential.warmth)) || !verification)
74
+ return waiting("workspace_status_shape");
75
+ if (verification.source === "none" && verification.state === "not-run" && verification.checkedAt === null && row.connected === false)
76
+ return waiting("workspace_status_unavailable");
77
+ if (verification.source !== "live-probe" || (typeof verification.state !== "string" || !states.has(verification.state)) ||
78
+ typeof verification.checkedAt !== "number" || !Number.isFinite(verification.checkedAt) ||
79
+ verification.checkedAt < now - 120_000 || verification.checkedAt > now + 30_000 ||
80
+ row.connected !== (verification.state === "connected"))
81
+ return waiting("workspace_status_shape");
82
+ if (verification.state === "unreachable")
83
+ return waiting("workspace_status_unavailable");
84
+ if (!row.connected)
85
+ return { state: "pending" };
86
+ // A working credential alone cannot prove this account has a bound Linear workspace.
87
+ if (!workspace.bound || typeof workspace.workspaceId !== "string" || !idPattern.test(workspace.workspaceId))
88
+ return waiting("linear_workspace_unverified");
89
+ return { state: "done", evidence: { provider: "linear", workspace: workspace.workspaceId, checkedAt: verification.checkedAt } };
90
+ }
91
+ function safeHandoff(body, ctx) {
92
+ const row = object(body);
93
+ const cfg = loadConfig(ctx.home);
94
+ if (!cfg || !row || typeof row.authorizationUrl !== "string" || row.authorizationUrl.length > 8192 || /[\u0000-\u0020\u007f]/.test(row.authorizationUrl) ||
95
+ typeof row.expiresAt !== "number" || !Number.isFinite(row.expiresAt))
96
+ return null;
97
+ const remaining = row.expiresAt - ctx.now().getTime();
98
+ // Thirty seconds accommodates clock skew; an expired link is never launched.
99
+ if (remaining <= 0 || remaining > 630_000)
100
+ return null;
101
+ try {
102
+ const url = new URL(row.authorizationUrl);
103
+ const keys = [...url.searchParams.keys()];
104
+ const handoff = url.searchParams.get("handoff");
105
+ if (url.protocol !== "https:" || url.origin !== new URL(normalizeBaseUrl(cfg.baseUrl)).origin || url.username || url.password ||
106
+ url.pathname !== HANDOFF || url.hash || keys.length !== 1 || keys[0] !== "handoff" ||
107
+ !handoff || handoff.length > 4096 || /[\s\u0000-\u001f\u007f]/.test(handoff))
108
+ return null;
109
+ return url.toString();
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
115
+ /** Uses the new personal bearer twins; old clouds retain their supported read-only readiness path. */
116
+ export function linearWorkspaceAdapter(options) {
117
+ const timeoutMs = options.requestTimeoutMs ?? 30_000;
118
+ const consentTimeoutMs = options.consentTimeoutMs ?? 600_000;
119
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000 ||
120
+ !Number.isSafeInteger(consentTimeoutMs) || consentTimeoutMs < 1 || consentTimeoutMs > 600_000)
121
+ throw new Error("workspace_timeout_invalid");
122
+ const inspect = async (ctx, journal, signal) => {
123
+ const result = await read(ctx, journal, STATUS, timeoutMs, signal);
124
+ if ("status" in result && result.status === 404)
125
+ return { result: await options.fallback.check(ctx, journal, signal), unsupported: true };
126
+ const failure = readFailure(result);
127
+ return { result: failure ?? observation("body" in result ? result.body : undefined, ctx.now().getTime()) };
128
+ };
129
+ return {
130
+ check: async (ctx, journal, signal) => (await inspect(ctx, journal, signal)).result,
131
+ act: async (ctx, journal, signal) => {
132
+ const cfg = loadConfig(ctx.home);
133
+ if (!cfg?.user || !["owner", "admin"].includes(cfg.user.role))
134
+ return { state: "refused", reason: "workspace_admin_required" };
135
+ if (signal?.aborted)
136
+ return waiting("interrupted");
137
+ const before = await inspect(ctx, journal, signal);
138
+ if (before.result.state !== "pending")
139
+ return before.result;
140
+ if (before.unsupported)
141
+ return waiting("cloud_capability_unavailable");
142
+ const start = await read(ctx, journal, `${STATUS}/start`, timeoutMs, signal);
143
+ if ("status" in start && start.status === 404)
144
+ return waiting("cloud_capability_unavailable");
145
+ const failure = readFailure(start);
146
+ if (failure)
147
+ return failure;
148
+ const url = safeHandoff("body" in start ? start.body : undefined, ctx);
149
+ if (!url)
150
+ return { state: "refused", reason: "workspace_consent_handoff" };
151
+ if (signal?.aborted)
152
+ return waiting("interrupted");
153
+ const current = loadConfig(ctx.home);
154
+ if (!current?.user || current.account !== cfg.account || current.user.id !== cfg.user.id ||
155
+ normalizeBaseUrl(current.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
156
+ return { state: "refused", reason: "workspace_identity_refused" };
157
+ if (!["owner", "admin"].includes(current.user.role))
158
+ return { state: "refused", reason: "workspace_admin_required" };
159
+ ctx.stderr("Approve the organization's Linear connection in your browser. Your personal approval follows.");
160
+ // The signed URL is transient credential material: only the browser receives it.
161
+ try {
162
+ options.openBrowser(url);
163
+ }
164
+ catch {
165
+ return waiting("workspace_browser_unavailable");
166
+ }
167
+ let latest = waiting("workspace_status_unavailable");
168
+ const run = () => pollConsent({ timeoutMs: consentTimeoutMs, signal, sleep: options.sleep,
169
+ readStatus: async (pollSignal) => {
170
+ const value = await inspect(ctx, journal, pollSignal);
171
+ latest = value.result;
172
+ if (value.unsupported)
173
+ return { outcome: "failed", reason: "cloud_capability_unavailable" };
174
+ if (latest.state === "done")
175
+ return { outcome: "connected" };
176
+ if (latest.state === "refused" || latest.state === "failed")
177
+ return { outcome: latest.state, reason: latest.reason };
178
+ return { outcome: latest.state === "pending" ? "absent" : "unavailable" };
179
+ },
180
+ });
181
+ const result = options.wait ? await options.wait("Waiting for workspace approval", run) : await run();
182
+ return result.state === "done" ? latest : result;
183
+ },
184
+ };
185
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.14.2",
3
+ "version": "0.14.4",
4
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",
@@ -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.14.2 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.4 — 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.14.2 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.4 — 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.14.2 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.4 — written in this repository for customer accounts -->
8
8
 
9
9
  # Onboard me
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Own one Catalyst Cloud project end to end until it closes. Use when the person says "run this project for me", "own this until it ships", "keep this moving", or hands you a project id or a set of tickets to drive. Subscribes to the account's event stream for the scope through the catalyst CLI, reacts to each change in the same turn, makes tickets ready and moves them to dispatch, parks what should stop, chases stalls, escalates inward, and keeps one status summary current. Writes to Linear as the app actor; never polls.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.2 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.4 — written in this repository for customer accounts -->
8
8
 
9
9
  # Run this project
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Get a stuck Catalyst Cloud ticket moving again. Use when the person asks "why is this parked and can you release it?", "unpark this", "get things flowing again", "the outage is over, retry what failed", or hands you a ticket that is not moving. Reads why nothing runs and what holds the ticket through the catalyst CLI, decides whether the recorded cause is fixed, previews the release, releases every governor holding the ticket the right way (or one failure class across a team), and raises an ask only for what a person has to do. Never releases a cause it cannot show changed without saying so.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.2 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.4 — written in this repository for customer accounts -->
8
8
 
9
9
  # Unstick
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  The human's decision inbox on Catalyst Cloud, and the one way an agent raises a decision on their behalf. Use when the person asks "what needs me?", "what am I blocking?", or when active work is gated on a choice only they can make. Lists open asks ranked by how much open work each one holds, files an ask through the cloud's ask route with the account's own template, and records the answer so the held work releases. Never answers as the human, never duplicates an open ask.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.2 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.4 — written in this repository for customer accounts -->
8
8
 
9
9
  # What needs me
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  What environment variable names does this repository need, and where does each one come from? Scans the repository offline (no login, no network) and lists the names in three groups (build/test, deploy-only, bindings), each with where it was found, what uses it, and where a local value would come from. Never reads a value or prints one. Also checks the environment variable table in `.catalyst/catalyst.toml` offline. Use when someone asks "what does catalyst.env.json mean", "what env vars does this repo need", "why does the container need this", or before reviewing or writing the declaration.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.2 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.4 — written in this repository for customer accounts -->
8
8
 
9
9
  # What this repository needs
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Use for "what's happening?", "where are we?", "why is that stuck?", "what closed?", "what's next?", "how does this work?", "why did it do that?", "how does it prioritise?" and "what does this setting do, where is it set?" in Catalyst Cloud, or asks for something to be done rather than known. Reads the contract via catalyst CLI. Read-only: route work/decisions, never answer as the human.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.2 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.14.4 — written in this repository for customer accounts -->
8
8
 
9
9
  # What's happening
10
10