@catalyst-cloud/catalyst-skills 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +28 -15
- package/dist/args.js +19 -1
- package/dist/ask.js +1 -1
- package/dist/browser.js +20 -0
- package/dist/cli.js +56 -22
- package/dist/config.js +48 -6
- package/dist/contract.js +1 -1
- package/dist/events.js +106 -0
- package/dist/execution.js +55 -9
- package/dist/oauth.js +300 -0
- package/dist/query.js +1 -1
- package/dist/ready.js +1 -1
- package/dist/release.js +142 -0
- package/dist/replica.js +10 -3
- package/dist/{http.js → transport.js} +21 -10
- package/dist/watch/consumer.js +2 -2
- package/dist/watch.js +4 -2
- package/dist/write.js +1 -1
- package/package.json +5 -4
- package/skills/catalyst-github/scripts/lib/cli.mjs +3 -2
- package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-linear/scripts/lib/cli.mjs +4 -3
- package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-setup/SKILL.md +1 -1
- package/skills/catalyst-setup/references/what-each-check-means.md +5 -1
- package/skills/catalyst-setup/scripts/lib/cli.mjs +3 -2
- package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-setup/scripts/replica-status.mjs +1 -1
- package/skills/connect-me/SKILL.md +16 -8
- package/skills/connect-me/scripts/lib/cli.mjs +3 -2
- package/skills/connect-me/scripts/lib/credential.mjs +29 -0
- package/skills/how-catalyst-works/references/stages-and-mapping.md +1 -1
- package/skills/how-catalyst-works/references/what-runs-next.md +1 -1
- package/skills/how-catalyst-works/references/when-a-phase-fails.md +4 -4
- package/skills/how-catalyst-works/scripts/lib/cli.mjs +4 -3
- package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/run-this-project/references/making-work-ready.md +1 -1
- package/skills/run-this-project/references/stalls-and-escalation.md +2 -2
- package/skills/run-this-project/scripts/lib/cli.mjs +3 -2
- package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
- package/skills/unstick/SKILL.md +41 -0
- package/skills/unstick/agents/openai.yaml +6 -0
- package/skills/unstick/agents/portability.yaml +5 -0
- package/skills/unstick/references/playbook.md +51 -0
- package/skills/unstick/scripts/lib/cli.mjs +135 -0
- package/skills/unstick/scripts/lib/credential.mjs +29 -0
- package/skills/unstick/scripts/unstick.mjs +57 -0
- package/skills/what-needs-me/scripts/lib/cli.mjs +3 -2
- package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
- package/skills/whats-happening/SKILL.md +1 -1
- package/skills/whats-happening/references/why-is-it-stuck.md +9 -4
- package/skills/whats-happening/scripts/lib/cli.mjs +3 -2
- package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
package/dist/release.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// release.ts — `catalyst-skills release`: a person's release of a parked or held ticket, or of every
|
|
2
|
+
// ticket on one team parked under one failure class, through the contract's `ticket-release` and
|
|
3
|
+
// `ticket-release-class` routes. The cloud reads every governor holding the ticket and either releases
|
|
4
|
+
// all of them or releases nothing and names, per governor, the human action that does fix it; this verb
|
|
5
|
+
// prints that answer and exits 1 on a refusal so a script can branch on it.
|
|
6
|
+
//
|
|
7
|
+
// ⛔ NOTHING IS RELEASED WITHOUT A REASON. `--because` (what changed) is required on a real release and
|
|
8
|
+
// is recorded in the tenant's release audit; only `--dry-run` may omit it. `--retry-unchanged` is the
|
|
9
|
+
// explicit statement that something the mirror cannot see changed — the cloud refuses an unchanged
|
|
10
|
+
// cause without it.
|
|
11
|
+
import { flagBool, flagString, positionals } from "./args.js";
|
|
12
|
+
import { requireConfig } from "./config.js";
|
|
13
|
+
import { loadContract } from "./contract.js";
|
|
14
|
+
import { CliError, UsageError } from "./errors.js";
|
|
15
|
+
import { needsNewerCloud } from "./execution.js";
|
|
16
|
+
import { apiClient } from "./transport.js";
|
|
17
|
+
/** One release written as a person reads it: "unparked implement", "cleared the validate hold". */
|
|
18
|
+
export function describeAction(a) {
|
|
19
|
+
switch (a.op) {
|
|
20
|
+
case "unpark":
|
|
21
|
+
return `unparked ${a.phase ?? "a phase"}`;
|
|
22
|
+
case "clear_validate_hold":
|
|
23
|
+
return "cleared the validate hold";
|
|
24
|
+
case "clear_no_change_hold":
|
|
25
|
+
return "cleared the no-change hold";
|
|
26
|
+
case "grant_round_threshold_cycle":
|
|
27
|
+
return "bought one more repair cycle";
|
|
28
|
+
default:
|
|
29
|
+
return `${a.op}${a.phase ? ` at ${a.phase}` : ""}`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const refusalLine = (r) => ` ${r.code}: ${r.humanAction}`;
|
|
33
|
+
export function renderTicketRelease(ticket, r) {
|
|
34
|
+
if (r.error === "team_changed") {
|
|
35
|
+
return [`${ticket} moved from team ${r.expectedTeamKey ?? "(none)"} to ${r.teamKey ?? "(none)"} while the release was being checked — run it again`];
|
|
36
|
+
}
|
|
37
|
+
const lines = [];
|
|
38
|
+
const dry = r.dryRun === true;
|
|
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`);
|
|
41
|
+
}
|
|
42
|
+
else if (r.outcome === "refused") {
|
|
43
|
+
lines.push(dry ? `${ticket} (dry run): would be refused — nothing would be released:` : `${ticket}: refused — nothing was released:`);
|
|
44
|
+
for (const x of r.refused ?? [])
|
|
45
|
+
lines.push(refusalLine(x));
|
|
46
|
+
const blocked = r.releasable ?? [];
|
|
47
|
+
if (blocked.length > 0)
|
|
48
|
+
lines.push(` (it would otherwise have ${blocked.map(describeAction).join(", ")})`);
|
|
49
|
+
}
|
|
50
|
+
else if (dry) {
|
|
51
|
+
lines.push(`${ticket} (dry run): would release — ${(r.releasable ?? []).map(describeAction).join(", ")}`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
lines.push(`${ticket}: released — ${(r.released ?? []).map(describeAction).join(", ")}`);
|
|
55
|
+
}
|
|
56
|
+
for (const w of r.warnings ?? [])
|
|
57
|
+
lines.push(`warning: ${w}`);
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
60
|
+
export function renderClassRelease(r) {
|
|
61
|
+
const released = r.released ?? [];
|
|
62
|
+
const refused = r.refused ?? [];
|
|
63
|
+
const nothing = r.nothingHeld ?? [];
|
|
64
|
+
const head = `${r.team ?? "?"} ${r.class ?? "?"}${r.dryRun ? " (dry run)" : ""}: released ${released.length}, refused ${refused.length}, nothing held ${nothing.length}${r.truncated ? " (more remain — run it again)" : ""}`;
|
|
65
|
+
const lines = [head];
|
|
66
|
+
for (const t of released) {
|
|
67
|
+
const actions = r.dryRun ? (t.releasable ?? []) : (t.released ?? []);
|
|
68
|
+
lines.push(` ${r.dryRun ? "would release" : "released"} ${t.ticket ?? "?"}: ${actions.map(describeAction).join(", ")}`);
|
|
69
|
+
}
|
|
70
|
+
for (const t of refused) {
|
|
71
|
+
for (const x of t.refused ?? [])
|
|
72
|
+
lines.push(` refused ${t.ticket ?? "?"} — ${x.code}: ${x.humanAction}`);
|
|
73
|
+
}
|
|
74
|
+
if (nothing.length > 0)
|
|
75
|
+
lines.push(` nothing held: ${nothing.join(", ")}`);
|
|
76
|
+
return lines;
|
|
77
|
+
}
|
|
78
|
+
/** The contract's route by name, or the "your cloud is older" error when this tenant does not serve it. */
|
|
79
|
+
function releaseRoute(doc, name, cfg) {
|
|
80
|
+
const route = doc.routes.find((r) => r.method === "POST" && r.path.split("/").filter(Boolean).at(-1) === name);
|
|
81
|
+
if (!route)
|
|
82
|
+
throw needsNewerCloud(`release (the tenant contract serves no "${name}" route)`, cfg);
|
|
83
|
+
return route.path;
|
|
84
|
+
}
|
|
85
|
+
export async function cmdRelease(args, ctx) {
|
|
86
|
+
const [ticket, ...extra] = positionals(args);
|
|
87
|
+
const failureClass = flagString(args, "class");
|
|
88
|
+
const team = flagString(args, "team");
|
|
89
|
+
const because = flagString(args, "because");
|
|
90
|
+
const dryRun = flagBool(args, "dry-run");
|
|
91
|
+
const retryUnchanged = flagBool(args, "retry-unchanged");
|
|
92
|
+
if (extra.length > 0)
|
|
93
|
+
throw new UsageError("release takes one ticket");
|
|
94
|
+
if (failureClass !== undefined && ticket !== undefined) {
|
|
95
|
+
throw new UsageError("release takes a ticket or --class with --team, not both");
|
|
96
|
+
}
|
|
97
|
+
if (failureClass === undefined && ticket === undefined) {
|
|
98
|
+
throw new UsageError("release needs a ticket, or --class <failure-class> --team <K>");
|
|
99
|
+
}
|
|
100
|
+
if (failureClass !== undefined && team === undefined)
|
|
101
|
+
throw new UsageError("release --class needs --team <K>");
|
|
102
|
+
if (because === undefined && !dryRun) {
|
|
103
|
+
throw new UsageError("release needs --because <what changed> (recorded in the release audit), or --dry-run to preview");
|
|
104
|
+
}
|
|
105
|
+
const limitRaw = flagString(args, "limit");
|
|
106
|
+
const limit = limitRaw === undefined ? undefined : Number(limitRaw);
|
|
107
|
+
if (limit !== undefined && !Number.isInteger(limit))
|
|
108
|
+
throw new UsageError("--limit must be an integer");
|
|
109
|
+
const cfg = requireConfig(ctx);
|
|
110
|
+
const { doc } = await loadContract(ctx, cfg);
|
|
111
|
+
const api = apiClient(cfg, ctx);
|
|
112
|
+
const common = { ...(because !== undefined ? { because } : {}), retryUnchanged, dryRun };
|
|
113
|
+
if (failureClass !== undefined) {
|
|
114
|
+
const path = releaseRoute(doc, "ticket-release-class", cfg);
|
|
115
|
+
const res = await api.postJson(path, {
|
|
116
|
+
team,
|
|
117
|
+
class: failureClass,
|
|
118
|
+
...common,
|
|
119
|
+
...(limit !== undefined ? { limit } : {}),
|
|
120
|
+
});
|
|
121
|
+
if (args.json)
|
|
122
|
+
ctx.stdout(JSON.stringify(res.body));
|
|
123
|
+
else
|
|
124
|
+
for (const line of renderClassRelease(res.body))
|
|
125
|
+
ctx.stdout(line);
|
|
126
|
+
// A real class release that refused any ticket exits 1, the same contract as a single refusal.
|
|
127
|
+
return res.body.dryRun !== true && (res.body.refused ?? []).length > 0 ? 1 : 0;
|
|
128
|
+
}
|
|
129
|
+
const id = ticket;
|
|
130
|
+
const path = releaseRoute(doc, "ticket-release", cfg);
|
|
131
|
+
const res = await api.postJson(path, { ticket: id, ...common }, { accept: [404, 409] });
|
|
132
|
+
if (res.status === 404) {
|
|
133
|
+
throw new CliError(`ticket ${id} is not in this tenant's mirror — check the identifier`, "ticket-unknown");
|
|
134
|
+
}
|
|
135
|
+
if (args.json)
|
|
136
|
+
ctx.stdout(JSON.stringify(res.body));
|
|
137
|
+
else
|
|
138
|
+
for (const line of renderTicketRelease(id, res.body))
|
|
139
|
+
ctx.stdout(line);
|
|
140
|
+
const refused = res.status === 409 || (res.body.outcome === "refused" && res.body.dryRun !== true);
|
|
141
|
+
return refused ? 1 : 0;
|
|
142
|
+
}
|
package/dist/replica.js
CHANGED
|
@@ -10,8 +10,10 @@ import { flagBool, flagInt, flagString, positionals } from "./args.js";
|
|
|
10
10
|
import { apiBase, loadConfig, replicaDbPath } from "./config.js";
|
|
11
11
|
import { detachSelf } from "./detach.js";
|
|
12
12
|
import { CliError, UsageError } from "./errors.js";
|
|
13
|
-
import { apiClient } from "./
|
|
13
|
+
import { apiClient } from "./transport.js";
|
|
14
|
+
import { authStrategyFor } from "./oauth.js";
|
|
14
15
|
import { loadSdk } from "./sdk.js";
|
|
16
|
+
import { createEventSync } from "./events.js";
|
|
15
17
|
export const DEFAULT_STALE_MS = 15_000;
|
|
16
18
|
export function pidfilePath(dbPath) {
|
|
17
19
|
return `${dbPath}.pid`;
|
|
@@ -204,7 +206,7 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
|
|
|
204
206
|
baseUrl: apiBase(cfg),
|
|
205
207
|
account: cfg.account,
|
|
206
208
|
accountSource: "declared",
|
|
207
|
-
auth:
|
|
209
|
+
auth: authStrategyFor(ctx, cfg),
|
|
208
210
|
dbPath,
|
|
209
211
|
engine,
|
|
210
212
|
fetchImpl: ctx.fetch,
|
|
@@ -213,7 +215,11 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
|
|
|
213
215
|
onStatus: (s) => ctx.stderr(`[replica] status=${s}`),
|
|
214
216
|
});
|
|
215
217
|
await replica.start();
|
|
216
|
-
|
|
218
|
+
const eventSync = await createEventSync(ctx, { loadSdk: deps.loadEventsSdk });
|
|
219
|
+
void eventSync.start().catch((error) => {
|
|
220
|
+
ctx.stderr(`[events] sync failed: ${error instanceof Error ? error.message : String(error)}; replica remains live`);
|
|
221
|
+
});
|
|
222
|
+
ctx.stdout(`replica live at ${dbPath} (cursor ${replica.cursor ?? "none"}); event cache active — Ctrl-C to stop`);
|
|
217
223
|
const wait = deps.waitForStop ??
|
|
218
224
|
(() => new Promise((resolve) => {
|
|
219
225
|
const done = () => resolve();
|
|
@@ -221,6 +227,7 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
|
|
|
221
227
|
process.once("SIGTERM", done);
|
|
222
228
|
}));
|
|
223
229
|
await wait();
|
|
230
|
+
await eventSync.stop();
|
|
224
231
|
await replica.close();
|
|
225
232
|
try {
|
|
226
233
|
if (readPidfile(dbPath) === process.pid)
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { normalizeBaseUrl } from "./config.js";
|
|
2
2
|
import { CliError, MeError } from "./errors.js";
|
|
3
|
+
import { bearerFor } from "./oauth.js";
|
|
3
4
|
export const REQUEST_TIMEOUT_MS = 15_000;
|
|
4
5
|
export class ApiClient {
|
|
5
|
-
|
|
6
|
+
baseUrl;
|
|
7
|
+
credential;
|
|
6
8
|
fetchImpl;
|
|
7
|
-
constructor(
|
|
8
|
-
|
|
9
|
+
constructor(baseUrl,
|
|
10
|
+
/** Resolves the current bearer FRESH per request — a personal key verbatim, or the OAuth access
|
|
11
|
+
* token refreshed within 60s of expiry (CTC-2112). Never captured once. */
|
|
12
|
+
credential, fetchImpl) {
|
|
13
|
+
this.baseUrl = baseUrl;
|
|
14
|
+
this.credential = credential;
|
|
9
15
|
this.fetchImpl = fetchImpl;
|
|
10
16
|
}
|
|
11
17
|
/** `path` is absolute under the origin (e.g. `/api/v1/issues`). */
|
|
12
18
|
url(path, query) {
|
|
13
|
-
const u = new URL(`${normalizeBaseUrl(this.
|
|
19
|
+
const u = new URL(`${normalizeBaseUrl(this.baseUrl)}${path}`);
|
|
14
20
|
for (const [k, v] of Object.entries(query ?? {}))
|
|
15
21
|
if (v !== undefined)
|
|
16
22
|
u.searchParams.set(k, String(v));
|
|
@@ -18,7 +24,7 @@ export class ApiClient {
|
|
|
18
24
|
}
|
|
19
25
|
async getJson(path, opts = {}) {
|
|
20
26
|
const headers = {
|
|
21
|
-
authorization: `Bearer ${this.
|
|
27
|
+
authorization: `Bearer ${await this.credential()}`,
|
|
22
28
|
accept: "application/json",
|
|
23
29
|
};
|
|
24
30
|
if (opts.etag)
|
|
@@ -44,7 +50,7 @@ export class ApiClient {
|
|
|
44
50
|
const url = this.url(path, opts.query);
|
|
45
51
|
const res = await this.send(url, {
|
|
46
52
|
method: "GET",
|
|
47
|
-
headers: { authorization: `Bearer ${this.
|
|
53
|
+
headers: { authorization: `Bearer ${await this.credential()}`, accept: "application/x-ndjson, application/json" },
|
|
48
54
|
});
|
|
49
55
|
if (opts.accept?.includes(res.status))
|
|
50
56
|
return { status: res.status, body: [], headers: res.headers };
|
|
@@ -64,18 +70,21 @@ export class ApiClient {
|
|
|
64
70
|
}
|
|
65
71
|
return { status: res.status, body: rows, headers: res.headers };
|
|
66
72
|
}
|
|
67
|
-
|
|
73
|
+
/** `opts.accept` lists non-2xx statuses returned to the caller with their JSON body instead of thrown
|
|
74
|
+
* — the release route answers a refusal as 409 with the refusals the caller has to print. */
|
|
75
|
+
async postJson(path, body, opts = {}) {
|
|
68
76
|
const url = this.url(path);
|
|
69
77
|
const res = await this.send(url, {
|
|
70
78
|
method: "POST",
|
|
71
79
|
headers: {
|
|
72
|
-
authorization: `Bearer ${this.
|
|
80
|
+
authorization: `Bearer ${await this.credential()}`,
|
|
73
81
|
accept: "application/json",
|
|
74
82
|
"content-type": "application/json",
|
|
75
83
|
},
|
|
76
84
|
body: JSON.stringify(body),
|
|
77
85
|
});
|
|
78
|
-
|
|
86
|
+
if (!opts.accept?.includes(res.status))
|
|
87
|
+
await this.refuseIfNotOk(res, `POST ${path}`);
|
|
79
88
|
return { status: res.status, body: await this.parseBody(res, `POST ${path}`), headers: res.headers };
|
|
80
89
|
}
|
|
81
90
|
async send(url, init) {
|
|
@@ -121,8 +130,10 @@ export class ApiClient {
|
|
|
121
130
|
throw new CliError(`${what} failed (${res.status})${reason ? `: ${reason}` : ""}`, "http", 2, res.status);
|
|
122
131
|
}
|
|
123
132
|
}
|
|
133
|
+
/** Build the authenticated client for a config: the bearer resolves through {@link bearerFor}, so a
|
|
134
|
+
* key config sends the key and an OAuth config sends a freshly-refreshed access token per request. */
|
|
124
135
|
export function apiClient(cfg, ctx) {
|
|
125
|
-
return new ApiClient(cfg, ctx.fetch);
|
|
136
|
+
return new ApiClient(cfg.baseUrl, () => bearerFor(ctx, cfg), ctx.fetch);
|
|
126
137
|
}
|
|
127
138
|
/** GET /api/v1/me — how a key learns its own tenant. Unchanged contract from 0.1. */
|
|
128
139
|
export async function fetchMe(baseUrl, key, fetchImpl, timeoutMs = REQUEST_TIMEOUT_MS) {
|
package/dist/watch/consumer.js
CHANGED
|
@@ -11,7 +11,7 @@ export function printFrame(frame) {
|
|
|
11
11
|
}
|
|
12
12
|
async function fetchHeadCursor(opts, signal) {
|
|
13
13
|
const url = `${opts.baseUrl.replace(/\/+$/, "")}/snapshot?head=1`;
|
|
14
|
-
const res = await (opts.fetchImpl ?? fetch)(url, { headers: { authorization: `Bearer ${opts.
|
|
14
|
+
const res = await (opts.fetchImpl ?? fetch)(url, { headers: { authorization: `Bearer ${await opts.getToken()}` }, signal });
|
|
15
15
|
if (!res.ok) {
|
|
16
16
|
const body = (await res.text()).slice(0, 200);
|
|
17
17
|
throw new Error(`head-only snapshot fetch failed: ${res.status} ${res.statusText} at ${url} — ${body}`);
|
|
@@ -111,7 +111,7 @@ export function createLiveEventsClient(sdk, opts) {
|
|
|
111
111
|
const client = new sdk.LiveSyncClient({
|
|
112
112
|
baseUrl: opts.baseUrl,
|
|
113
113
|
accountId: opts.accountId,
|
|
114
|
-
auth:
|
|
114
|
+
auth: opts.auth,
|
|
115
115
|
reseed: reseedHead,
|
|
116
116
|
getCursor: () => state?.cursor ?? null,
|
|
117
117
|
onChange: (frame) => {
|
package/dist/watch.js
CHANGED
|
@@ -7,7 +7,8 @@ import { flagList, flagString } from "./args.js";
|
|
|
7
7
|
import { apiBase, requireConfig, watchCursorPathFor } from "./config.js";
|
|
8
8
|
import { loadContract } from "./contract.js";
|
|
9
9
|
import { CliError, UsageError } from "./errors.js";
|
|
10
|
-
import { apiClient } from "./
|
|
10
|
+
import { apiClient } from "./transport.js";
|
|
11
|
+
import { authStrategyFor, bearerFor } from "./oauth.js";
|
|
11
12
|
import { loadSdk } from "./sdk.js";
|
|
12
13
|
import { createLiveEventsClient } from "./watch/consumer.js";
|
|
13
14
|
import { CursorFileError } from "./watch/cursor-file.js";
|
|
@@ -135,7 +136,8 @@ export async function runWatch(ctx, cfg, opts, deps = {}) {
|
|
|
135
136
|
return createLiveEventsClient(sdk, {
|
|
136
137
|
baseUrl: apiBase(cfg),
|
|
137
138
|
accountId: cfg.account,
|
|
138
|
-
|
|
139
|
+
auth: authStrategyFor(ctx, cfg),
|
|
140
|
+
getToken: () => bearerFor(ctx, cfg),
|
|
139
141
|
cursorFile: opts.cursorFile ?? watchCursorPathFor(ctx.home),
|
|
140
142
|
fromHead: opts.fromHead,
|
|
141
143
|
fetchImpl: ctx.fetch,
|
package/dist/write.js
CHANGED
|
@@ -6,7 +6,7 @@ import { flagBool, flagInt, flagList, flagString, positionals } from "./args.js"
|
|
|
6
6
|
import { requireConfig } from "./config.js";
|
|
7
7
|
import { bookkeepingPrefix, labelId, loadContract, routePath, stageIdForSlot, teamByKey, teamForTicket } from "./contract.js";
|
|
8
8
|
import { CliError, UsageError } from "./errors.js";
|
|
9
|
-
import { apiClient } from "./
|
|
9
|
+
import { apiClient } from "./transport.js";
|
|
10
10
|
/** A ticket identifier → its Linear id (and team/project) through GET /api/v1/issues/:identifier. */
|
|
11
11
|
export async function resolveIssue(api, ticket) {
|
|
12
12
|
const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(ticket)}`, { accept: [404] });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catalyst-cloud/catalyst-skills",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Customer skill bundle for Catalyst Cloud:
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Customer skill bundle for Catalyst Cloud: nine skills named from the executive's seat (whats-happening, what-needs-me, run-this-project, unstick, catalyst-setup, catalyst-linear, catalyst-github, how-catalyst-works, connect-me) plus the catalyst-skills CLI that reads through the Catalyst Cloud SDK, writes through the agent proxy, and connects your machine to your tenant with one command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"publishConfig": {
|
|
@@ -38,10 +38,11 @@
|
|
|
38
38
|
"prepare": "tsc -p tsconfig.build.json",
|
|
39
39
|
"test": "vitest run",
|
|
40
40
|
"test:coverage": "vitest run --coverage",
|
|
41
|
-
"version:sync": "node scripts/sync-plugin-version.mjs"
|
|
41
|
+
"version:sync": "node scripts/sync-plugin-version.mjs",
|
|
42
|
+
"skill-lib:sync": "node scripts/sync-skill-lib.mjs"
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
44
|
-
"@catalyst-cloud/sdk": "^0.
|
|
45
|
+
"@catalyst-cloud/sdk": "^0.10.0"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
47
48
|
"@types/node": "^26.3.0",
|
|
@@ -8,9 +8,10 @@ import { spawnSync } from "node:child_process";
|
|
|
8
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
|
|
11
12
|
|
|
12
13
|
export const PACKAGE = "@catalyst-cloud/catalyst-skills";
|
|
13
|
-
export const CONNECT_HINT = `this machine is not connected to a tenant yet — run:
|
|
14
|
+
export const CONNECT_HINT = `this machine is not connected to a tenant yet — run: ${CONNECT_COMMAND}`;
|
|
14
15
|
|
|
15
16
|
/** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME before HOME. */
|
|
16
17
|
export function configPath() {
|
|
@@ -32,7 +33,7 @@ export function requireCustomerConfig() {
|
|
|
32
33
|
console.error(`${path} could not be read (${err instanceof Error ? err.message : String(err)}) — ${CONNECT_HINT}`);
|
|
33
34
|
process.exit(2);
|
|
34
35
|
}
|
|
35
|
-
if (!cfg || typeof cfg
|
|
36
|
+
if (!hasCredential(cfg) || typeof cfg.baseUrl !== "string") {
|
|
36
37
|
console.error(`${path} is missing required fields — ${CONNECT_HINT}`);
|
|
37
38
|
process.exit(2);
|
|
38
39
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/credential.mjs — is this machine connected? The ONE place a skill script decides it, vendored
|
|
3
|
+
// byte-identical into every skill's scripts/lib/ from skill-lib/credential.mjs at the package root
|
|
4
|
+
// (`npm run skill-lib:sync`; a test fails on any drift). Skills install one directory at a time, so
|
|
5
|
+
// each carries its own copy. This file is a library — run a sibling script with --help for usage.
|
|
6
|
+
//
|
|
7
|
+
// customer.json carries exactly one credential: a personal key (`key`), or the keyless login's
|
|
8
|
+
// session (`auth`, the recommended rail). A script never reads either for its value: it spawns the
|
|
9
|
+
// catalyst-skills CLI, which authenticates with whichever is present and refreshes a login's token
|
|
10
|
+
// itself. A new credential kind lands here, once.
|
|
11
|
+
|
|
12
|
+
/** The command that connects this machine, as every not-connected line names it. */
|
|
13
|
+
export const CONNECT_COMMAND =
|
|
14
|
+
"npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)";
|
|
15
|
+
|
|
16
|
+
/** True when `cfg` (parsed customer.json) holds a usable credential of either kind. Never throws. */
|
|
17
|
+
export function hasCredential(cfg) {
|
|
18
|
+
if (cfg === null || typeof cfg !== "object") return false;
|
|
19
|
+
const key = cfg["key"];
|
|
20
|
+
if (typeof key === "string" && key !== "") return true;
|
|
21
|
+
const login = cfg["auth"];
|
|
22
|
+
return (
|
|
23
|
+
login !== null &&
|
|
24
|
+
typeof login === "object" &&
|
|
25
|
+
login["kind"] === "oauth" &&
|
|
26
|
+
typeof login["refreshToken"] === "string" &&
|
|
27
|
+
login["refreshToken"] !== ""
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -6,9 +6,10 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
|
|
9
10
|
|
|
10
11
|
export const PACKAGE = "@catalyst-cloud/catalyst-skills";
|
|
11
|
-
export const CONNECT_HINT =
|
|
12
|
+
export const CONNECT_HINT = CONNECT_COMMAND;
|
|
12
13
|
|
|
13
14
|
/** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME (used by tests) over HOME. */
|
|
14
15
|
export function configPath() {
|
|
@@ -22,8 +23,8 @@ export function loadConfig() {
|
|
|
22
23
|
if (!existsSync(path)) return { ok: false, reason: `no config at ${path}` };
|
|
23
24
|
try {
|
|
24
25
|
const cfg = JSON.parse(readFileSync(path, "utf8"));
|
|
25
|
-
if (!cfg || typeof cfg.
|
|
26
|
-
return { ok: false, reason: `${path} is missing
|
|
26
|
+
if (!hasCredential(cfg) || typeof cfg.account !== "string") {
|
|
27
|
+
return { ok: false, reason: `${path} is missing a key or login, or the account` };
|
|
27
28
|
}
|
|
28
29
|
return { ok: true, cfg, path };
|
|
29
30
|
} catch (err) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/credential.mjs — is this machine connected? The ONE place a skill script decides it, vendored
|
|
3
|
+
// byte-identical into every skill's scripts/lib/ from skill-lib/credential.mjs at the package root
|
|
4
|
+
// (`npm run skill-lib:sync`; a test fails on any drift). Skills install one directory at a time, so
|
|
5
|
+
// each carries its own copy. This file is a library — run a sibling script with --help for usage.
|
|
6
|
+
//
|
|
7
|
+
// customer.json carries exactly one credential: a personal key (`key`), or the keyless login's
|
|
8
|
+
// session (`auth`, the recommended rail). A script never reads either for its value: it spawns the
|
|
9
|
+
// catalyst-skills CLI, which authenticates with whichever is present and refreshes a login's token
|
|
10
|
+
// itself. A new credential kind lands here, once.
|
|
11
|
+
|
|
12
|
+
/** The command that connects this machine, as every not-connected line names it. */
|
|
13
|
+
export const CONNECT_COMMAND =
|
|
14
|
+
"npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)";
|
|
15
|
+
|
|
16
|
+
/** True when `cfg` (parsed customer.json) holds a usable credential of either kind. Never throws. */
|
|
17
|
+
export function hasCredential(cfg) {
|
|
18
|
+
if (cfg === null || typeof cfg !== "object") return false;
|
|
19
|
+
const key = cfg["key"];
|
|
20
|
+
if (typeof key === "string" && key !== "") return true;
|
|
21
|
+
const login = cfg["auth"];
|
|
22
|
+
return (
|
|
23
|
+
login !== null &&
|
|
24
|
+
typeof login === "object" &&
|
|
25
|
+
login["kind"] === "oauth" &&
|
|
26
|
+
typeof login["refreshToken"] === "string" &&
|
|
27
|
+
login["refreshToken"] !== ""
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -26,7 +26,7 @@ You answer one question with one verdict and one list. The verdict is READY or N
|
|
|
26
26
|
- Report, never repair. No tenant-reachable repair verb exists for a key yet; the settings page is where a tenant owner or admin fixes a mapping, a connection or a label, and you say which one.
|
|
27
27
|
- End every answer with the verdict and the who-can-click-what list, in that order.
|
|
28
28
|
- A stale or absent replica is a note, never a failure: every skill reads the API meanwhile and says so. Do not tell the person to start it unless they want local SQL or cheaper repeated reads.
|
|
29
|
-
- Not connected (exit 2) means the connect step, not a retry: `
|
|
29
|
+
- Not connected (exit 2) means the connect step, not a retry: `npx @catalyst-cloud/catalyst-skills login`, which logs the person in keyless in their browser; with a personal key from Settings → API keys instead, prefix it with `CATALYST_CLOUD_TOKEN=<your personal key>`. Never guess a tenant; the login (or the key) is the only selector.
|
|
30
30
|
- Waiting is not failing. "No write observed", "no delivery observed" and "no host connected" clear themselves the first time the thing happens; say that instead of raising them.
|
|
31
31
|
- Unknown is not a pass. A check the engine could not run is reported as such, never rounded up.
|
|
32
32
|
- Never run a check in a loop. If the person wants to know when a waiting check clears, that is the project-running skill's watch.
|
|
@@ -37,7 +37,7 @@ Three checks are informational by design (labels, event delivery, host currency)
|
|
|
37
37
|
| id | proves | fix |
|
|
38
38
|
| -- | -- | -- |
|
|
39
39
|
| `node` | Node 22 or newer, which the SDK's built-in SQLite engine needs | install Node 22+ |
|
|
40
|
-
| `config` | this machine is connected: `customer.json` exists and loads | `
|
|
40
|
+
| `config` | this machine is connected: `customer.json` exists and loads | `npx @catalyst-cloud/catalyst-skills login` (keyless: the person approves in their browser); or, with a personal key minted at Settings → API keys, the same command prefixed with `CATALYST_CLOUD_TOKEN=<your personal key>` |
|
|
41
41
|
| `contract` | the tenant contract is cached and its major version is one this bundle accepts | `catalyst-skills contract --refresh`; a version outside the range means update the bundle. A 403 naming an older cloud means the cloud has not yet deployed personal-key access |
|
|
42
42
|
| `cliPath` | the CLI path recorded at login still exists, so skill scripts can spawn it | re-run login |
|
|
43
43
|
| `skills` | every skill of this bundle is present where the CLI installed them | `catalyst-skills install` |
|
|
@@ -60,3 +60,7 @@ Three checks are informational by design (labels, event delivery, host currency)
|
|
|
60
60
|
## Who can click what
|
|
61
61
|
|
|
62
62
|
The last block `check.mjs` prints groups every failure by the person it needs. Machine fixes name "you", the person at the keyboard. Tenant fixes name the owner or admin roles the contract lists (as Linear user ids, since that is how the cloud knows them), because the settings page that repairs a mapping, a connection or a label is theirs. A check the contract marks as not needing an answer names nobody: it is informational or self-clearing. This skill reports; it never repairs, because no key-reachable repair verb exists yet.
|
|
63
|
+
|
|
64
|
+
## Setting up one team at a time
|
|
65
|
+
|
|
66
|
+
A team starts receiving work only once its stages are saved, and that is done one team at a time: a tenant owner or admin opens Settings → Linear teams, picks the team and presses Map my stages (or Adopt the Catalyst workflow). No other team's stages or tickets change; only the labels Adopt creates are shared across the workspace. Before saving, the screen lists which of that team's tickets would start and which stay where they are. Once saved, the tickets in the team's dispatch stage start; tickets in other stages that Catalyst never worked on stay where they are. To pilot safely, pick a low-stakes team, move anything in its dispatch stage that should not start back to Backlog, and leave the other teams unmapped. `gitAutomation` in the contract plays no part: nothing reads it.
|
|
@@ -8,9 +8,10 @@ import { spawnSync } from "node:child_process";
|
|
|
8
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
|
|
11
12
|
|
|
12
13
|
export const PACKAGE = "@catalyst-cloud/catalyst-skills";
|
|
13
|
-
export const CONNECT_HINT = `this machine is not connected to a tenant yet — run:
|
|
14
|
+
export const CONNECT_HINT = `this machine is not connected to a tenant yet — run: ${CONNECT_COMMAND}`;
|
|
14
15
|
|
|
15
16
|
/** ~/.config/catalyst-cloud/customer.json, honouring CATALYST_SKILLS_HOME before HOME. */
|
|
16
17
|
export function configPath() {
|
|
@@ -32,7 +33,7 @@ export function requireCustomerConfig() {
|
|
|
32
33
|
console.error(`${path} could not be read (${err instanceof Error ? err.message : String(err)}) — ${CONNECT_HINT}`);
|
|
33
34
|
process.exit(2);
|
|
34
35
|
}
|
|
35
|
-
if (!cfg || typeof cfg
|
|
36
|
+
if (!hasCredential(cfg) || typeof cfg.baseUrl !== "string") {
|
|
36
37
|
console.error(`${path} is missing required fields — ${CONNECT_HINT}`);
|
|
37
38
|
process.exit(2);
|
|
38
39
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/credential.mjs — is this machine connected? The ONE place a skill script decides it, vendored
|
|
3
|
+
// byte-identical into every skill's scripts/lib/ from skill-lib/credential.mjs at the package root
|
|
4
|
+
// (`npm run skill-lib:sync`; a test fails on any drift). Skills install one directory at a time, so
|
|
5
|
+
// each carries its own copy. This file is a library — run a sibling script with --help for usage.
|
|
6
|
+
//
|
|
7
|
+
// customer.json carries exactly one credential: a personal key (`key`), or the keyless login's
|
|
8
|
+
// session (`auth`, the recommended rail). A script never reads either for its value: it spawns the
|
|
9
|
+
// catalyst-skills CLI, which authenticates with whichever is present and refreshes a login's token
|
|
10
|
+
// itself. A new credential kind lands here, once.
|
|
11
|
+
|
|
12
|
+
/** The command that connects this machine, as every not-connected line names it. */
|
|
13
|
+
export const CONNECT_COMMAND =
|
|
14
|
+
"npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)";
|
|
15
|
+
|
|
16
|
+
/** True when `cfg` (parsed customer.json) holds a usable credential of either kind. Never throws. */
|
|
17
|
+
export function hasCredential(cfg) {
|
|
18
|
+
if (cfg === null || typeof cfg !== "object") return false;
|
|
19
|
+
const key = cfg["key"];
|
|
20
|
+
if (typeof key === "string" && key !== "") return true;
|
|
21
|
+
const login = cfg["auth"];
|
|
22
|
+
return (
|
|
23
|
+
login !== null &&
|
|
24
|
+
typeof login === "object" &&
|
|
25
|
+
login["kind"] === "oauth" &&
|
|
26
|
+
typeof login["refreshToken"] === "string" &&
|
|
27
|
+
login["refreshToken"] !== ""
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -14,7 +14,7 @@ const HELP = `Usage: node scripts/replica-status.mjs [--probe] [--json]
|
|
|
14
14
|
Exit codes, passed through from catalyst-skills replica status:
|
|
15
15
|
0 fresh: a live writer, a young heartbeat, a cursor — skills read the replica
|
|
16
16
|
1 stale: the file exists but the writer is gone or behind — skills read the API and say so
|
|
17
|
-
2 not connected to a tenant — run: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login
|
|
17
|
+
2 not connected to a tenant — run: npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)
|
|
18
18
|
3 absent: no replica file — optional; start one with: catalyst-skills replica start --detach`;
|
|
19
19
|
|
|
20
20
|
const args = process.argv.slice(2);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: connect-me
|
|
3
3
|
description: >-
|
|
4
|
-
Connect this machine to the person's Catalyst Cloud tenant with their own personal key
|
|
4
|
+
Connect this machine to the person's Catalyst Cloud tenant as themselves — keyless (a browser device-code login) or with their own personal key — cache the tenant contract, and verify. Use when a person is getting started, when their config is missing or broken, when their login expired or they rotated their key, when a skill script exits 2 saying the machine is not connected, or when they ask which tenant this machine belongs to. The login names the tenant and the person, so nobody types an account id. Installing the skills is not this skill's job; the agent's own install command did that.
|
|
5
5
|
disable-model-invocation: true
|
|
6
6
|
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
7
7
|
---
|
|
@@ -9,28 +9,36 @@ allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:
|
|
|
9
9
|
|
|
10
10
|
# Connect me
|
|
11
11
|
|
|
12
|
-
Connecting is one command.
|
|
12
|
+
Connecting is one command. Keyless is the preferred rail — with no key, `login` opens a browser device-code flow and logs the person in as themselves:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
catalyst-skills login
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
It prints a short code and a URL; the person approves in a browser (or from a phone, on a machine with no browser) and this machine is connected as them. The short-lived session refreshes silently on every request, so they stay connected for months.
|
|
19
|
+
|
|
20
|
+
A **personal key** still works for a script or an unattended shell — the environment form keeps it out of shell history; `--key <personal-key>` is the third form:
|
|
13
21
|
|
|
14
22
|
```sh
|
|
15
23
|
CATALYST_CLOUD_TOKEN=<your-personal-key> catalyst-skills login
|
|
16
24
|
```
|
|
17
25
|
|
|
18
|
-
|
|
26
|
+
`npx @catalyst-cloud/catalyst-skills login` works when the package is not installed globally. A non-default cloud is pinned with `CATALYST_CLOUD_BASE_URL` or `--base-url`.
|
|
19
27
|
|
|
20
28
|
**This skill never installs skills.** You are already reading one, so the person's agent installed the set with its own command. Connecting is only about the credential and the contract.
|
|
21
29
|
|
|
22
|
-
**What it writes.** `~/.config/catalyst-cloud/customer.json` (mode 0600) holding the
|
|
30
|
+
**What it writes.** `~/.config/catalyst-cloud/customer.json` (mode 0600) holding the credential — a keyless login session (`auth`), or a personal key — the tenant it resolved, the person it resolved (`user`: id, label, role, Linear user id), and the absolute path of this CLI so skill scripts can spawn it; `~/.config/catalyst-cloud/contract.json`, the tenant contract with its ETag. A keyless session's token rotates on its own and the file is rewritten atomically each time.
|
|
23
31
|
|
|
24
32
|
**The replica is optional and one command away.** Nothing has to be running for reads, writes, asks or explanations; the API is origin-fresh. `catalyst-skills replica start --detach` starts the local replica for cheap repeated reads and ad hoc SQL; `login --start-replica` does the same at the end. Every skill checks `catalyst-skills replica status` first and falls back to the API when the replica is absent or stale, saying so.
|
|
25
33
|
|
|
26
34
|
## What you do
|
|
27
35
|
|
|
28
|
-
1.
|
|
36
|
+
1. Prefer keyless: run `catalyst-skills login` with no key and have the person approve the code in their browser (or from a phone). Nothing to mint, nothing to paste, nothing to keep out of the transcript. A **personal key** is the fallback for a script or unattended shell — they mint it at Settings → API keys (every active member can; no admin is needed) and hand it to the login command via `CATALYST_CLOUD_TOKEN`; it is shown once, so never put it in a file, a ticket, or this conversation beyond that one command. Either way, do not ask an admin for the tenant's **account key** (`ctc_acct_`, Settings → Account keys): that is a host credential — runners, daemons, the host replica — and a person connected with it has no name on anything their agent writes, and "what needs me" cannot mean them. If they connect with one anyway, login says so on stderr and still works.
|
|
29
37
|
2. Run the login command. Its output names the tenant (`Connected to <name> (<slug>)`), the person (`Connected as <label> (<role>)`), the config path, and the cached contract version. If the person line says their Linear identity is not matched yet, tell them: an admin matches it in Settings → Members, and until then "what needs me" shows everyone's asks.
|
|
30
38
|
3. Verify with `node scripts/verify-connection.mjs`: one line each for the tenant, the contract version, and the replica; exit 1 when the machine is not connected.
|
|
31
39
|
4. Run `catalyst-skills ready` and read the verdict to them.
|
|
32
40
|
5. Offer the replica: `catalyst-skills replica start --detach`, then `catalyst-skills replica status`. For keeping it alive across a reboot, load `references/keeping-the-replica-running.md`.
|
|
33
|
-
6. If login fails: a `401` means a stale, mistyped or revoked key — mint a new one at Settings → API keys, never a retry loop; a network error names the URL, check `--base-url`. A `403` on the contract naming an older cloud means the cloud has not yet deployed personal-key access: update the cloud, or connect with the account key until it has. If login succeeds but prints a line naming two contract versions, the tenant serves a contract outside this bundle's range: update the bundle before using the other skills.
|
|
41
|
+
6. If login fails: a keyless session that reports "your login expired or was revoked" needs one fresh `catalyst-skills login`, never a retry loop (it means the session was revoked or lapsed past the inactivity window — routine expiry refreshes silently and never surfaces). A `401` on the key rail means a stale, mistyped or revoked key — mint a new one at Settings → API keys, never a retry loop; a network error names the URL, check `--base-url`. A `403` on the contract naming an older cloud means the cloud has not yet deployed personal-key access: update the cloud, or connect with the account key until it has. If login succeeds but prints a line naming two contract versions, the tenant serves a contract outside this bundle's range: update the bundle before using the other skills.
|
|
34
42
|
|
|
35
43
|
## The verbs a session runs first
|
|
36
44
|
|
|
@@ -50,6 +58,6 @@ Every skill session opens with these, in this order, before doing anything else:
|
|
|
50
58
|
|
|
51
59
|
## Rules
|
|
52
60
|
|
|
53
|
-
- **The
|
|
54
|
-
- **Never guess a tenant.** There is no `--account`; the
|
|
61
|
+
- **The credential is a secret.** Keyless is safest — nothing to handle. A key goes into the login command or `CATALYST_CLOUD_TOKEN` and nowhere else: not into tickets, transcripts, or any file you write besides the one login writes; and never read back the `auth` block a keyless session stores.
|
|
62
|
+
- **Never guess a tenant.** There is no `--account`; the credential is the only tenant selector, on purpose. If the cloud names no tenant for the login, stop and say so.
|
|
55
63
|
- **Read the update notice.** After a bundle update, the next command prints one line with the changelog entry and what to run; read it to the person if they are watching.
|