@catalyst-cloud/catalyst-skills 0.3.0 → 0.4.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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ You can connect keyless now: run `catalyst-skills login` with no key and it opens a browser device-code login — it prints a short code and a URL, you approve in your browser (or from your phone on a machine with no browser), and this machine is connected as you, with nothing to mint or paste. The short-lived session token refreshes silently on every request and the config is rewritten atomically each time, so you stay connected for months and log in again only if the session is revoked or you have been away long enough to lapse — then one clear line tells you to run `login`. `customer.json` now holds exactly one of a login session (`auth`) or a personal key; `status` names which ("Credential: your login (expires …)" vs "Credential: personal key"). The personal key rail is unchanged and remains the fallback for scripts and unattended shells — `--key` or `CATALYST_CLOUD_TOKEN` — and keyless is the preferred rail everywhere the docs and the connect-me skill lead with it. Under the hood the auth layer is reworked around one credential provider (SDK 0.9): the replica and the live watch use the SDK's bearer auth strategy and resolve a fresh token per connect, so an OAuth session rotates underneath a long-running watch without a reconnect, and every HTTP read and write refreshes on the same path.
6
+
7
+ ## 0.3.1
8
+
9
+ `ready` now names the person you connected as: when your key is a personal key, the config line reads "joined <tenant> as <you> (<role>)" instead of the generic "as service" that every api key showed, and an account (host) key still names the tenant with no person. `ready` also learns the bundle version your tenant expects, when the cloud publishes one: if this installed bundle is older than that minimum you get a one-line warning that names both versions and the upgrade command, but it stays a note — `ready` never refuses over a stale bundle, and a cloud that does not publish a minimum changes nothing. `explain` no longer calls every ticket with no dispatch row "unknown": a ticket that merely sits in Backlog (or any non-dispatch state) is now named as known to the mirror with the state it is in, and only a ticket the mirror has never seen keeps the unknown wording.
10
+
3
11
  ## 0.3.0
4
12
 
5
13
  You connect with your own personal key now, not the tenant's shared account key: mint it yourself at Settings → API keys in the Catalyst Cloud app (every member can; no admin needed), and `login` prints who you connected as beside which tenant. Your agent acts as you — an ask it raises names you, and "what needs me" means you: `ask list` (and the what-needs-me skill) keeps only the asks assigned to your Linear user by default, `--anyone` lists the whole tenant, and when your Linear identity is not matched yet it says so and shows everything rather than an empty list. `customer.json` gains a `user` block (id, label, role, your Linear user id); an older config is still read unchanged. Connecting with the account key still works — it is the right credential for a host or daemon — but `login` tells you it names no person. Every skill, reference and help line that said "account key", or said execution history and coding-account status were not readable, has been rewritten: `explain --history` and `accounts` read them, and the two things your key genuinely cannot do (release a park, read PR labels or reactions) are named instead. The catalyst-setup reference now lists all eleven team checks, including `environment_declared`. Needs a cloud that admits personal keys on the agent routes; on an older cloud the contract read says so by name, and the account key still works until it is updated.
package/README.md CHANGED
@@ -55,27 +55,37 @@ Without `--all` the installer asks which skills to take and which agents to inst
55
55
 
56
56
  ### Then connect to your tenant
57
57
 
58
- The skills call one CLI, and the CLI holds your credential. Install it once and connect this machine with your own **personal key** — mint it at Settings → API keys in the Catalyst Cloud app (every member can; no admin needed):
58
+ The skills call one CLI, and the CLI holds your credential. Install it once and connect this machine — the keyless way logs you in as yourself, with nothing to mint or paste:
59
59
 
60
60
  ```sh
61
61
  npm install -g @catalyst-cloud/catalyst-skills
62
- CATALYST_CLOUD_TOKEN=<your-personal-key> catalyst-skills login
62
+ catalyst-skills login
63
63
  catalyst-skills ready
64
64
  ```
65
65
 
66
- Passing the key as an environment variable is the recommended form, because a key typed into a command line lands in your shell history. With no key in the environment and a terminal attached, `catalyst-skills login` prompts for it without echoing it; `--key <your-personal-key>` is the third form, for a script. `npx @catalyst-cloud/catalyst-skills login` works without the global install, and `bunx` works in place of `npx`. A non-default cloud is set with `CATALYST_CLOUD_BASE_URL` or `--base-url <url>`.
66
+ `catalyst-skills login` with no key opens a device-code login: it prints a short code and a URL, you approve it in your browser, and this machine is connected as you. On a machine with no browser (a remote box, a container) the code and URL still work — approve them from your phone. The short-lived session refreshes silently afterwards, so you log in about once a year. `npx @catalyst-cloud/catalyst-skills login` works without the global install, and `bunx` works in place of `npx`. A non-default cloud is set with `CATALYST_CLOUD_BASE_URL` or `--base-url <url>`.
67
+
68
+ Prefer a key? Pass one instead — mint a **personal key** at Settings → API keys in the Catalyst Cloud app (every member can; no admin needed). The environment form keeps it out of your shell history:
69
+
70
+ ```sh
71
+ CATALYST_CLOUD_TOKEN=<your-personal-key> catalyst-skills login
72
+ ```
73
+
74
+ `--key <your-personal-key>` is the third form, for a script.
67
75
 
68
76
  That is the whole setup. Everything below explains what you just installed.
69
77
 
70
78
  ## What this is
71
79
 
72
- Eight skills that let your coding agent run your own Catalyst Cloud tenant (https://staging.catalystcloud.dev) from your seat: what is happening, what needs you, and what to do about it. They read your tenant through the Catalyst Cloud SDK, write to it through the tenant's agent proxy, and never compose a URL or run a tool of their own; every read, write and subscription is a `catalyst-skills` verb with `--help`. Every skill is plain Markdown under `skills/<name>/SKILL.md` in this repository, and your own personal key is the only credential. Your agent acts as you: the asks it raises and the comments it posts carry your name, and "what needs me" means you.
80
+ Eight skills that let your coding agent run your own Catalyst Cloud tenant (https://staging.catalystcloud.dev) from your seat: what is happening, what needs you, and what to do about it. They read your tenant through the Catalyst Cloud SDK, write to it through the tenant's agent proxy, and never compose a URL or run a tool of their own; every read, write and subscription is a `catalyst-skills` verb with `--help`. Every skill is plain Markdown under `skills/<name>/SKILL.md` in this repository, and your own login — a keyless device-code session, or a personal key — is the only credential. Your agent acts as you: the asks it raises and the comments it posts carry your name, and "what needs me" means you.
73
81
 
74
82
  ## What connecting does
75
83
 
76
- `login` does three things: it calls `GET /api/v1/me` with your key, which discovers your tenant and who you are from the key alone; it writes `~/.config/catalyst-cloud/customer.json` with file mode `0600`, holding your key, who you are (id, label, role, your Linear user id) and the absolute path of the CLI so every skill script can spawn the same binary; and it fetches the tenant contract (`GET /api/v1/agent/contract`) and caches it at `~/.config/catalyst-cloud/contract.json` with its ETag, so stage names and ids, label ids, the ask template, thresholds and the route table come from your tenant, live, and no skill restates them.
84
+ `login` does three things: it calls `GET /api/v1/me` with your credential, which discovers your tenant and who you are from the credential alone; it writes `~/.config/catalyst-cloud/customer.json` with file mode `0600`, holding your credential (a keyless login session, or a personal key), who you are (id, label, role, your Linear user id) and the absolute path of the CLI so every skill script can spawn the same binary; and it fetches the tenant contract (`GET /api/v1/agent/contract`) and caches it at `~/.config/catalyst-cloud/contract.json` with its ETag, so stage names and ids, label ids, the ask template, thresholds and the route table come from your tenant, live, and no skill restates them.
85
+
86
+ A keyless session's access token is short-lived and rotates on its own: every request refreshes it within a minute of expiry and rewrites the config atomically, so you stay connected for months without logging in again. You only log in again if the session is revoked or you have been away long enough to lapse — then one clear line tells you to run `login`.
77
87
 
78
- `login` does not install skills; the install command above did that. Re-running `login` after a key rotation rewrites the config. `catalyst-skills status` prints which tenant this machine is connected to and as whom, `catalyst-skills ready` prints one READY or NOT READY verdict with the fix for each failure and who can apply it, and `catalyst-skills --version` prints the package version and its pinned tenant contract range.
88
+ `login` does not install skills; the install command above did that. Re-running `login` after a key rotation, or to switch rails, rewrites the config. `catalyst-skills status` prints which tenant this machine is connected to and as whom, `catalyst-skills ready` prints one READY or NOT READY verdict with the fix for each failure and who can apply it, and `catalyst-skills --version` prints the package version and its pinned tenant contract range.
79
89
 
80
90
  The setup skill Catalyst seeds into your repository ends by pointing at this same command. That served skill lives in the Catalyst Cloud application, not here; this is the only connect step a customer runs. The tenant's **account key** (Settings → Account keys, admin-minted) is a different credential — for a host or daemon that runs unattended — and is not what a person connects their own agent with: it would strip your name from everything your agent writes, and `login` says so if you use one.
81
91
 
@@ -145,10 +155,10 @@ The skill files themselves are written by whichever install command you ran, in
145
155
 
146
156
  ## Updating
147
157
 
148
- A plugin install updates when we ship. Skills copied by `npx skills add` do not; run `npx skills update -y` to refresh them. Update the CLI with `npm update -g @catalyst-cloud/catalyst-skills`, or run any command against the latest publish with `npx @catalyst-cloud/catalyst-skills@latest login`. The next `catalyst-skills` run prints a one-line notice:
158
+ A plugin install updates when we ship. Skills copied by `npx skills add` do not; run `npx skills update -y` to refresh them. Update the CLI with `npm install -g @catalyst-cloud/catalyst-skills@latest && catalyst-skills login` — the re-login rewrites the CLI path the skills spawn, so they stop running the old bundle. To run one command against the latest publish without installing, use `npx @catalyst-cloud/catalyst-skills@latest login`. The next `catalyst-skills` run prints a one-line notice:
149
159
 
150
160
  ```
151
- [catalyst-skills] updated 0.1.1 → 0.2.0: <that version's CHANGELOG.md summary> · update with: npm update -g @catalyst-cloud/catalyst-skills (or: npx @catalyst-cloud/catalyst-skills@latest login)
161
+ [catalyst-skills] updated 0.1.1 → 0.2.0: <that version's CHANGELOG.md summary> · update with: npm install -g @catalyst-cloud/catalyst-skills@latest && catalyst-skills login
152
162
  ```
153
163
 
154
164
  A `customer.json` written by an older bundle is still read unchanged; it gains the CLI path and the cached contract the next time you run `catalyst-skills login`.
package/dist/args.js CHANGED
@@ -94,7 +94,7 @@ export const FLAG_TABLES = {
94
94
  accounts: {},
95
95
  };
96
96
  export const VERB_USAGE = {
97
- login: "login [--key <account-key>] [--base-url <url>] [--start-replica]",
97
+ login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
98
98
  install: "install [--skills-dir <dir>] [--force]",
99
99
  status: "status",
100
100
  notice: "notice",
package/dist/ask.js CHANGED
@@ -5,7 +5,7 @@ import { flagBool, flagList, flagString, positionals } from "./args.js";
5
5
  import { requireConfig } from "./config.js";
6
6
  import { loadContract, teamByKey } from "./contract.js";
7
7
  import { UsageError } from "./errors.js";
8
- import { apiClient } from "./http.js";
8
+ import { apiClient } from "./transport.js";
9
9
  import { rowsOf } from "./query.js";
10
10
  import { fetchWorkflowStates, postAgent, resolveIssue } from "./write.js";
11
11
  export async function cmdAsk(args, ctx) {
@@ -0,0 +1,20 @@
1
+ // browser.ts — open the device-flow verification page in the person's default browser. Best-effort:
2
+ // a machine with no browser (a headless mini, a container) simply shows the code and URL instead, so
3
+ // this never throws into the login flow — the caller wraps it, and a spawn failure is swallowed here.
4
+ import { spawn } from "node:child_process";
5
+ /** The platform's "open this URL" command. `open` on macOS, `start` via cmd on Windows, else xdg-open. */
6
+ export function openBrowser(url) {
7
+ const [cmd, args] = process.platform === "darwin"
8
+ ? ["open", [url]]
9
+ : process.platform === "win32"
10
+ ? ["cmd", ["/c", "start", "", url]]
11
+ : ["xdg-open", [url]];
12
+ try {
13
+ const child = spawn(cmd, [...args], { stdio: "ignore", detached: true });
14
+ child.on("error", () => { }); // no opener installed — the printed URL is the fallback
15
+ child.unref();
16
+ }
17
+ catch {
18
+ // spawn itself threw (unusual) — the code and URL on screen are the fallback
19
+ }
20
+ }
package/dist/cli.js CHANGED
@@ -5,12 +5,14 @@ import { parseArgs, positionals, verbHelp } from "./args.js";
5
5
  import { CONFIG_MODE, DEFAULT_BASE_URL, PACKAGE_NAME, cliPath, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, formatMode, loadConfig, normalizeBaseUrl, readManifest, requireConfig, saveConfig, writeConfig, } from "./config.js";
6
6
  import { loadContract, pickPath } from "./contract.js";
7
7
  import { CliError, MeError, UsageError } from "./errors.js";
8
- import { fetchMe } from "./http.js";
8
+ import { fetchMe } from "./transport.js";
9
+ import { bearerFor, deviceFlowLogin } from "./oauth.js";
10
+ import { openBrowser as defaultOpenBrowser } from "./browser.js";
9
11
  import { cmdAccounts, cmdExplain, cmdHistory, cmdQueue, cmdRunning } from "./execution.js";
10
12
  import { cmdQuery } from "./query.js";
11
13
  import { cmdReady } from "./ready.js";
12
14
  import { cmdReplica } from "./replica.js";
13
- import { promptSecret, stdinIsTty } from "./prompt.js";
15
+ import { stdinIsTty } from "./prompt.js";
14
16
  import { PROVENANCE_MARKER } from "./skill-shape.js";
15
17
  import { installSkills, parseChangelogEntry, readChangelog, resolveSkillsDir, skillsSourceDir, updateNoticeLine } from "./skills.js";
16
18
  import { cmdWatch } from "./watch.js";
@@ -37,7 +39,8 @@ export function usageText() {
37
39
  `${PACKAGE_NAME} — the Catalyst Cloud customer CLI: connect to your tenant, read through the SDK, write through the agent proxy`,
38
40
  "",
39
41
  "Usage:",
40
- " catalyst-skills login [--key <personal-key>] [--base-url <url>] [--start-replica]",
42
+ " catalyst-skills login [--base-url <url>] [--start-replica] (keyless: logs you in as yourself)",
43
+ " catalyst-skills login --key <personal-key> [--base-url <url>] (or CATALYST_CLOUD_TOKEN, for a key)",
41
44
  " catalyst-skills join ... (deprecated alias of login; removed in the next minor version)",
42
45
  " catalyst-skills install [--skills-dir <dir>] [--force] (repair path; your agent's own command installs the skills)",
43
46
  " catalyst-skills status | notice | me | ready | accounts",
@@ -51,14 +54,16 @@ export function usageText() {
51
54
  "",
52
55
  "Every verb takes --help. --json makes the output machine-readable.",
53
56
  "",
54
- "The key may also come from CATALYST_CLOUD_TOKEN; the base URL defaults to",
55
- `CATALYST_CLOUD_BASE_URL or ${DEFAULT_BASE_URL}.`,
57
+ "With no key, login is keyless: it opens a WorkOS device-code flow, prints a short code and a URL,",
58
+ "and connects you as yourself once you approve in the browser — nothing to mint or paste. Pass",
59
+ "--key (or set CATALYST_CLOUD_TOKEN) to use a personal or account key instead. The base URL defaults",
60
+ `to CATALYST_CLOUD_BASE_URL or ${DEFAULT_BASE_URL}.`,
56
61
  "",
57
- "login calls GET /api/v1/me to discover your tenant from the key alone, writes",
58
- "~/.config/catalyst-cloud/customer.json (0600) with the key and this CLI's path, and caches the",
59
- "tenant contract beside it. With no key in the environment and a terminal attached it prompts",
60
- "for the key without echoing it. It does not install skills: your agent's own install command",
61
- "does that, and `install` is only here to repair a copy this package made.",
62
+ "login discovers your tenant from GET /api/v1/me, writes ~/.config/catalyst-cloud/customer.json",
63
+ "(0600) holding your login session (or key) and this CLI's path, and caches the tenant contract",
64
+ "beside it. A keyless session's short-lived token refreshes silently on every request. login does",
65
+ "not install skills: your agent's own install command does that, and `install` is only here to",
66
+ "repair a copy this package made.",
62
67
  ].join("\n");
63
68
  }
64
69
  /**
@@ -190,14 +195,19 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
190
195
  const VERB_HELP_KNOWN = Object.fromEntries(["login", "join", "install", "status", "notice", "me", "contract", "query", "replica", "explain", "running", "queue", "watch", "write", "ask", "ready", "accounts"].map((v) => [v, true]));
191
196
  async function cmdLogin(args, ctx, deps) {
192
197
  const manifest = readManifest();
193
- let key = (args.key ?? ctx.env.CATALYST_CLOUD_TOKEN ?? "").trim();
194
- if (!key && (deps.isTty ?? stdinIsTty)()) {
195
- key = (await (deps.promptSecret ?? promptSecret)("Personal key (not echoed): ")).trim();
196
- }
197
- if (!key)
198
- throw new UsageError("login needs your personal key: pass --key <personal-key> or set CATALYST_CLOUD_TOKEN");
198
+ const key = (args.key ?? ctx.env.CATALYST_CLOUD_TOKEN ?? "").trim();
199
199
  const baseUrl = normalizeBaseUrl(args.baseUrl ?? ctx.env.CATALYST_CLOUD_BASE_URL ?? DEFAULT_BASE_URL);
200
- const me = await fetchMe(baseUrl, key, ctx.fetch);
200
+ // Keyless is the preferred rail: with no key in flag or env, run the WorkOS device flow and log in
201
+ // as the person. A key (flag or CATALYST_CLOUD_TOKEN) still takes the token rail unchanged.
202
+ let auth;
203
+ const bearer = key
204
+ ? key
205
+ : (auth = await deviceFlowLogin(ctx, baseUrl, {
206
+ isTty: deps.isTty ?? stdinIsTty,
207
+ openBrowser: deps.openBrowser ?? defaultOpenBrowser,
208
+ sleep: deps.sleep,
209
+ })).accessToken;
210
+ const me = await fetchMe(baseUrl, bearer, ctx.fetch);
201
211
  let existing;
202
212
  try {
203
213
  existing = loadConfig(ctx.home);
@@ -209,7 +219,8 @@ async function cmdLogin(args, ctx, deps) {
209
219
  const skillsDir = resolveSkillsDir(args, ctx, existing);
210
220
  const config = {
211
221
  baseUrl,
212
- key,
222
+ ...(key ? { key } : {}),
223
+ ...(auth ? { auth } : {}),
213
224
  account: me.account,
214
225
  slug: me.slug,
215
226
  name: me.name,
@@ -230,7 +241,7 @@ async function cmdLogin(args, ctx, deps) {
230
241
  else {
231
242
  ctx.stderr(`[catalyst-skills] this is the tenant's account key (a host credential), not your own — the skills work, but nothing your agent writes will carry your name and "what needs me" cannot mean you. Mint a personal key at Settings → API keys and log in with that.`);
232
243
  }
233
- ctx.stdout(`Config written to ${written.path} (mode ${formatMode(written.mode)}, holds your key and the CLI path)${written.mode === CONFIG_MODE ? "" : ` — expected ${formatMode(CONFIG_MODE)}; chmod it by hand`}`);
244
+ ctx.stdout(`Config written to ${written.path} (mode ${formatMode(written.mode)}, holds your ${auth ? "login session" : "key"} and the CLI path)${written.mode === CONFIG_MODE ? "" : ` — expected ${formatMode(CONFIG_MODE)}; chmod it by hand`}`);
234
245
  ctx.stdout(`Tenant contract range: ${manifest.tenantContractRange}`);
235
246
  try {
236
247
  const loaded = await loadContract(ctx, config, { refresh: true });
@@ -280,21 +291,35 @@ function cmdStatus(ctx) {
280
291
  const manifest = readManifest();
281
292
  const cfg = loadConfig(ctx.home);
282
293
  if (!cfg) {
283
- ctx.stdout(`Not connected yet — run: CATALYST_CLOUD_TOKEN=<your personal key> npx ${PACKAGE_NAME} login`);
294
+ ctx.stdout(`Not connected yet — run: npx ${PACKAGE_NAME} login (keyless; or pass --key / set CATALYST_CLOUD_TOKEN)`);
284
295
  return 0;
285
296
  }
286
297
  ctx.stdout(`Tenant: ${cfg.name} (${cfg.slug}) — account ${cfg.account}`);
287
298
  ctx.stdout(`API: ${cfg.baseUrl} (principal: ${cfg.principal})`);
288
299
  ctx.stdout(cfg.user ? `As: ${cfg.user.label} (${cfg.user.role})` : "As: the tenant's account key (a host credential — no person)");
300
+ ctx.stdout(cfg.auth ? `Credential: your login (expires ${relativeExpiry(cfg.auth.expiresAt, ctx.now())})` : "Credential: personal key");
289
301
  ctx.stdout(`Bundle: ${PACKAGE_NAME} ${manifest.version} (tenant contract range: ${manifest.tenantContractRange})`);
290
302
  if (cfg.cliPath)
291
303
  ctx.stdout(`CLI: ${cfg.cliPath}${existsSync(cfg.cliPath) ? "" : " (missing — re-run login)"}`);
292
304
  ctx.stdout(`Contract: ${existsSync(contractPathFor(ctx.home)) ? contractPathFor(ctx.home) : "not cached (run: catalyst-skills contract --refresh)"}`);
293
305
  return 0;
294
306
  }
307
+ /** A short human relative time for an ISO expiry, e.g. "in 14m", "in 2h", "in 6d", or "now" once past. */
308
+ function relativeExpiry(iso, now) {
309
+ const ms = Date.parse(iso) - now.getTime();
310
+ if (!Number.isFinite(ms) || ms <= 0)
311
+ return "now";
312
+ const mins = Math.round(ms / 60_000);
313
+ if (mins < 60)
314
+ return `in ${mins}m`;
315
+ const hours = Math.round(mins / 60);
316
+ if (hours < 48)
317
+ return `in ${hours}h`;
318
+ return `in ${Math.round(hours / 24)}d`;
319
+ }
295
320
  async function cmdMe(args, ctx) {
296
321
  const cfg = requireConfig(ctx);
297
- const me = await fetchMe(cfg.baseUrl, cfg.key, ctx.fetch);
322
+ const me = await fetchMe(cfg.baseUrl, await bearerFor(ctx, cfg), ctx.fetch);
298
323
  if (args.json)
299
324
  ctx.stdout(JSON.stringify(me));
300
325
  else {
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // config.ts — ~/.config/catalyst-cloud/customer.json and its siblings. The ONLY place the base URL
2
2
  // and the `/api/v1` prefix are joined: the SDK wants the base with the prefix, GET /me without.
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { randomBytes } from "node:crypto";
4
5
  import { dirname, join } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { CliError } from "./errors.js";
@@ -29,6 +30,10 @@ export function contractPathFor(home) {
29
30
  export function watchCursorPathFor(home) {
30
31
  return join(configDirFor(home), "watch-cursor.json");
31
32
  }
33
+ /** CTC-2112 — where the cached CLI-auth discovery document lives (the `/api/v1/auth/cli` answer). */
34
+ export function discoveryCachePathFor(home) {
35
+ return join(configDirFor(home), "auth-discovery.json");
36
+ }
32
37
  export function defaultReplicaDbFor(home) {
33
38
  return join(configDirFor(home), "replica.db");
34
39
  }
@@ -61,28 +66,65 @@ export function loadConfig(home) {
61
66
  throw new CliError(`config at ${path} is not valid JSON — re-run login to rewrite it`, "config-corrupt");
62
67
  }
63
68
  const cfg = parsed;
64
- if (typeof cfg.account !== "string" || typeof cfg.key !== "string" || typeof cfg.baseUrl !== "string") {
69
+ if (typeof cfg.account !== "string" || typeof cfg.baseUrl !== "string") {
65
70
  throw new CliError(`config at ${path} is missing required fields — re-run login to rewrite it`, "config-corrupt");
66
71
  }
72
+ const hasKey = typeof cfg.key === "string" && cfg.key !== "";
73
+ const hasOauth = isOauthAuth(cfg.auth);
74
+ if (hasKey === hasOauth) {
75
+ throw new CliError(`config at ${path} must hold exactly one of a personal key or an OAuth session — re-run login to rewrite it`, "config-corrupt");
76
+ }
67
77
  return cfg;
68
78
  }
69
79
  /** Load the config or refuse with the one line every verb prints when the machine is not connected. */
70
80
  export function requireConfig(ctx) {
71
81
  const cfg = loadConfig(ctx.home);
72
82
  if (!cfg) {
73
- throw new CliError(`not connected yet — run: CATALYST_CLOUD_TOKEN=<your personal key> npx ${PACKAGE_NAME} login`, "not-configured");
83
+ throw new CliError(`not connected yet — run: npx ${PACKAGE_NAME} login (keyless; or pass --key / set CATALYST_CLOUD_TOKEN for a key)`, "not-configured");
74
84
  }
75
85
  return cfg;
76
86
  }
77
87
  export function saveConfig(home, cfg) {
78
88
  return writeConfig(home, cfg).path;
79
89
  }
80
- /** Write the config and return the mode the file ACTUALLY carries afterwards (chmod runs on every
81
- * write: Node honours `mode` only when it creates the file). */
90
+ /** True when `v` is a well-formed OAuth session block. */
91
+ export function isOauthAuth(v) {
92
+ if (typeof v !== "object" || v === null)
93
+ return false;
94
+ const a = v;
95
+ return (a.kind === "oauth" &&
96
+ typeof a.accessToken === "string" &&
97
+ typeof a.refreshToken === "string" &&
98
+ typeof a.expiresAt === "string" &&
99
+ typeof a.sessionId === "string");
100
+ }
101
+ /**
102
+ * Write the config atomically (a rotated token must never be half-written under a crash) and return
103
+ * the mode the file ACTUALLY carries afterwards. Write to a sibling `.tmp` at 0600, then rename over
104
+ * the target — rename is atomic within a directory — and chmod once more (a pre-existing target keeps
105
+ * its own mode through the rename on some platforms).
106
+ */
82
107
  export function writeConfig(home, cfg) {
83
108
  const path = configPathFor(home);
84
109
  mkdirSync(dirname(path), { recursive: true });
85
- writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n", { mode: CONFIG_MODE });
110
+ // A UNIQUE sibling temp per write (pid + randomness): two concurrent CLI processes reaching the
111
+ // OAuth refresh window must not share `customer.json.tmp`, or one renames/removes it out from under
112
+ // the other and the second write fails with ENOENT or lands the wrong contents (Codex P2).
113
+ const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
114
+ try {
115
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: CONFIG_MODE });
116
+ chmodSync(tmp, CONFIG_MODE);
117
+ renameSync(tmp, path);
118
+ }
119
+ catch (err) {
120
+ try {
121
+ rmSync(tmp, { force: true });
122
+ }
123
+ catch {
124
+ // best-effort cleanup of the temp file
125
+ }
126
+ throw err;
127
+ }
86
128
  chmodSync(path, CONFIG_MODE);
87
129
  return { path, mode: statSync(path).mode & 0o777 };
88
130
  }
package/dist/contract.js CHANGED
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
5
5
  import { dirname } from "node:path";
6
6
  import { contractPathFor, readManifest } from "./config.js";
7
7
  import { CliError, MeError } from "./errors.js";
8
- import { apiClient } from "./http.js";
8
+ import { apiClient } from "./transport.js";
9
9
  export const CONTRACT_ROUTE = "/api/v1/agent/contract";
10
10
  export function readContractCache(home) {
11
11
  const path = contractPathFor(home);
@@ -45,7 +45,7 @@ export function assertContractRange(version, range) {
45
45
  if (ok === null)
46
46
  throw new CliError(`tenantContractRange "${range}" in package.json is not a range this CLI understands`, "contract-range");
47
47
  if (!ok) {
48
- throw new CliError(`the tenant serves contract version ${version} but this bundle accepts ${range} — update the bundle (npm update -g @catalyst-cloud/catalyst-skills) or ask your tenant admin which version is live`, "contract-version");
48
+ throw new CliError(`the tenant serves contract version ${version} but this bundle accepts ${range} — update the bundle (npm install -g @catalyst-cloud/catalyst-skills@latest && catalyst-skills login) or ask your tenant admin which version is live`, "contract-version");
49
49
  }
50
50
  }
51
51
  /**
package/dist/execution.js CHANGED
@@ -5,7 +5,7 @@ import { flagBool, flagString, positionals } from "./args.js";
5
5
  import { normalizeBaseUrl, requireConfig } from "./config.js";
6
6
  import { loadContract } from "./contract.js";
7
7
  import { CliError, UsageError } from "./errors.js";
8
- import { apiClient } from "./http.js";
8
+ import { apiClient } from "./transport.js";
9
9
  /** Every exclusion reason the eligibility evaluator names, in plain English. */
10
10
  export const EXCLUSION_REASONS = {
11
11
  ticket_terminal: "the ticket is in a done or canceled state, so there is nothing left to run",
@@ -61,9 +61,15 @@ export function describeReason(reason) {
61
61
  return "no reason was given";
62
62
  return EXCLUSION_REASONS[reason] ?? UNKNOWN_REASONS[reason] ?? `reason "${reason}" (not in this bundle's table; read it as the cloud spelled it)`;
63
63
  }
64
- export function renderExplain(ticket, row, team) {
65
- if (!row)
64
+ export function renderExplain(ticket, row, team, knownState) {
65
+ if (!row) {
66
+ // A null dispatch row is not proof the ticket does not exist — the explainer is a dispatch scan. When
67
+ // `knownState` is set (the mirror answered GET /issues/:id) the ticket is real; it just sits outside a
68
+ // dispatch column. Only a 404 (knownState null/absent) is truly unknown to the mirror.
69
+ if (knownState)
70
+ return `${ticket}: known to the mirror; state ${knownState} is not a dispatch state.`;
66
71
  return `${ticket}: not in the ${team} eligibility explainer — the ticket is unknown to the mirror, terminal, or on another team.`;
72
+ }
67
73
  const parts = [];
68
74
  const pos = typeof row.position === "number" ? `position ${row.position}` : "no queue position";
69
75
  const status = row.status ?? "unknown";
@@ -115,11 +121,20 @@ export async function cmdExplain(args, ctx) {
115
121
  });
116
122
  const rows = res.body.eligibility?.rows ?? res.body.rows ?? [];
117
123
  const row = rows.find((r) => String(r.ticket ?? "").toUpperCase() === ticket.toUpperCase()) ?? null;
124
+ // No dispatch row is not proof of non-existence: probe the mirror so a Backlog ticket reads as known.
125
+ // The mirror compares identifiers exactly, so normalize (uppercase) as the row lookup above does —
126
+ // otherwise `explain eng-7` probes a lowercase id, 404s, and reports an existing ENG-7 as unknown.
127
+ let knownState = null;
128
+ if (!row) {
129
+ const probe = await api.getJson(`/api/v1/issues/${encodeURIComponent(ticket.toUpperCase())}`, { accept: [404] });
130
+ if (probe.status !== 404)
131
+ knownState = typeof probe.body?.state === "string" ? probe.body.state : "unknown";
132
+ }
118
133
  if (args.json) {
119
- ctx.stdout(JSON.stringify({ ticket, row, explanation: renderExplain(ticket, row, team) }));
134
+ ctx.stdout(JSON.stringify({ ticket, row, explanation: renderExplain(ticket, row, team, knownState) }));
120
135
  }
121
136
  else {
122
- ctx.stdout(renderExplain(ticket, row, team));
137
+ ctx.stdout(renderExplain(ticket, row, team, knownState));
123
138
  }
124
139
  return 0;
125
140
  }
package/dist/oauth.js ADDED
@@ -0,0 +1,300 @@
1
+ import { discoveryCachePathFor, loadConfig, normalizeBaseUrl, writeConfig } from "./config.js";
2
+ import { CliError } from "./errors.js";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ const DISCOVERY_TTL_MS = 60 * 60_000;
5
+ const REFRESH_SKEW_MS = 60_000;
6
+ const REQUEST_TIMEOUT_MS = 15_000;
7
+ const SLOW_DOWN_BUMP_MS = 5_000;
8
+ const MAX_POLL_BACKOFF_MS = 30_000;
9
+ const REFRESH_MAX_ATTEMPTS = 4;
10
+ const REFRESH_BASE_BACKOFF_MS = 500;
11
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
12
+ let memo = null;
13
+ /** Test seam: forget the cached discovery document. */
14
+ export function resetDiscoveryCache() {
15
+ memo = null;
16
+ }
17
+ function readDiscoveryDisk(home) {
18
+ const path = discoveryCachePathFor(home);
19
+ if (!existsSync(path))
20
+ return null;
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
23
+ if (isDiscovery(parsed.doc) && typeof parsed.fetchedAt === "number" && typeof parsed.baseUrl === "string")
24
+ return parsed;
25
+ }
26
+ catch {
27
+ // a corrupt cache is just a cache miss
28
+ }
29
+ return null;
30
+ }
31
+ function isDiscovery(v) {
32
+ if (typeof v !== "object" || v === null)
33
+ return false;
34
+ const d = v;
35
+ return ["clientId", "issuer", "deviceAuthorizationUrl", "tokenUrl", "jwksUrl"].every((k) => typeof d[k] === "string");
36
+ }
37
+ export async function fetchDiscovery(ctx, baseUrl, opts = {}) {
38
+ const base = normalizeBaseUrl(baseUrl);
39
+ const nowMs = ctx.now().getTime();
40
+ const fresh = (e) => e !== null && e.baseUrl === base && nowMs - e.fetchedAt < DISCOVERY_TTL_MS;
41
+ if (!opts.refresh) {
42
+ if (fresh(memo))
43
+ return memo.doc;
44
+ const disk = readDiscoveryDisk(ctx.home);
45
+ if (fresh(disk)) {
46
+ memo = disk;
47
+ return disk.doc;
48
+ }
49
+ }
50
+ const url = `${base}/api/v1/auth/cli`;
51
+ const res = await safeFetch(ctx, url, { headers: { accept: "application/json" } });
52
+ if (!res.ok) {
53
+ throw new CliError(`could not read the login discovery document (${res.status}) at ${url} — is this a Catalyst Cloud origin?`, "discovery-failed");
54
+ }
55
+ const doc = (await parseJson(res, url));
56
+ if (!isDiscovery(doc))
57
+ throw new CliError(`the login discovery document at ${url} is not the expected shape`, "discovery-shape");
58
+ const entry = { baseUrl: base, fetchedAt: nowMs, doc };
59
+ memo = entry;
60
+ try {
61
+ writeFileSync(discoveryCachePathFor(ctx.home), JSON.stringify(entry, null, 2) + "\n");
62
+ }
63
+ catch {
64
+ // the disk cache is an optimisation; the in-memory memo is enough
65
+ }
66
+ return doc;
67
+ }
68
+ /**
69
+ * Run the device-authorization grant to completion and return the stored session block. Prints the
70
+ * user code and where to enter it; on a TTY it also opens the completion URL. Polls at the server's
71
+ * `interval`, honours `slow_down`, and is bounded by `AbortSignal.timeout(expires_in)` so it can
72
+ * never poll past the device code's own lifetime.
73
+ */
74
+ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
75
+ const sleep = deps.sleep ?? defaultSleep;
76
+ const discovery = await fetchDiscovery(ctx, baseUrl);
77
+ const auth = await deviceAuthorize(ctx, discovery);
78
+ ctx.stdout("");
79
+ ctx.stdout(`To connect this machine, visit: ${auth.verification_uri}`);
80
+ ctx.stdout(`and enter the code: ${auth.user_code}`);
81
+ if ((deps.isTty ?? (() => false))()) {
82
+ try {
83
+ (deps.openBrowser ?? (() => { }))(auth.verification_uri_complete);
84
+ ctx.stdout("Opened your browser to that page — approve there, or use the code above.");
85
+ }
86
+ catch {
87
+ // a browser that will not open is not a failure; the code and URL still work
88
+ }
89
+ }
90
+ ctx.stdout("Waiting for you to approve… (Ctrl-C to cancel)");
91
+ // Terminal states print ONE clear, actionable line and exit 2 (CliError's default). The duration
92
+ // comes from the device code's own `expires_in`, so it is right whatever the server set.
93
+ const mins = Math.round(auth.expires_in / 60);
94
+ const expired = () => new CliError(`the login code expired${mins >= 1 ? ` after ${mins} minute${mins === 1 ? "" : "s"}` : ""} — run: catalyst-skills login again`, "login-expired");
95
+ const deadline = AbortSignal.timeout(auth.expires_in * 1_000);
96
+ let intervalMs = Math.max(1, auth.interval) * 1_000;
97
+ for (;;) {
98
+ await sleep(intervalMs);
99
+ // The device code's own lifetime bounds the loop: once it lapses, say so plainly rather than
100
+ // letting the next fetch's aborted signal surface as a generic network error (Codex P2).
101
+ if (deadline.aborted)
102
+ throw expired();
103
+ let res;
104
+ try {
105
+ res = await safeFetch(ctx, discovery.tokenUrl, { method: "POST", headers: formHeaders(), body: form({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: auth.device_code, client_id: discovery.clientId }) }, deadline);
106
+ }
107
+ catch (err) {
108
+ // The deadline firing mid-fetch is an expiry, not a failure; anything else is a transient
109
+ // network blip — retry with backoff until the deadline, never abandon the login (CTC-2112 P1).
110
+ if (deadline.aborted)
111
+ throw expired();
112
+ intervalMs = backoff(intervalMs);
113
+ continue;
114
+ }
115
+ if (res.ok) {
116
+ const pair = (await parseJson(res, discovery.tokenUrl));
117
+ if (typeof pair.access_token !== "string" || typeof pair.refresh_token !== "string") {
118
+ throw new CliError("the login token response was missing a token", "login-shape");
119
+ }
120
+ return tokensToAuth(ctx, pair.access_token, pair.refresh_token);
121
+ }
122
+ const err = await oauthError(res, discovery.tokenUrl);
123
+ if (err === "authorization_pending")
124
+ continue;
125
+ if (err === "slow_down") {
126
+ intervalMs += SLOW_DOWN_BUMP_MS;
127
+ continue;
128
+ }
129
+ if (err === "access_denied")
130
+ throw new CliError("login was denied — run: catalyst-skills login to try again", "login-denied");
131
+ if (err === "expired_token")
132
+ throw expired();
133
+ // A transient HTTP failure (request timeout, rate limit, server error) is retried, honouring
134
+ // Retry-After for a 429; only a genuine, non-transient refusal ends the loop.
135
+ if (res.status === 408 || res.status === 429 || res.status >= 500) {
136
+ const retryAfter = Number(res.headers.get("retry-after"));
137
+ intervalMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : backoff(intervalMs);
138
+ continue;
139
+ }
140
+ throw new CliError(`login failed (${res.status}): ${err}`, "login-failed");
141
+ }
142
+ }
143
+ /** Grow the poll interval on a transient failure, capped, so retries never hammer the endpoint. */
144
+ function backoff(intervalMs) {
145
+ return Math.min(intervalMs * 2, MAX_POLL_BACKOFF_MS);
146
+ }
147
+ /**
148
+ * Persist a rotated token pair WITHOUT clobbering a newer login (Codex P1). A long-running watch or
149
+ * detached replica holds the `cfg` it started with; if the person meanwhile re-ran `login` (a new
150
+ * session, a different tenant, or the key rail), that config is already on disk and must survive. So
151
+ * we reload disk and only write when it is still the same session we just refreshed — or when there
152
+ * is nothing on disk yet. The reloaded doc is written (not our in-memory `cfg`), so its tenant, user
153
+ * and CLI-path fields are preserved and only the auth block advances.
154
+ */
155
+ function persistRotation(ctx, cfg, sessionId, rotated) {
156
+ let disk;
157
+ try {
158
+ disk = loadConfig(ctx.home);
159
+ }
160
+ catch {
161
+ return; // a corrupt/unreadable disk config: don't overwrite what we can't understand
162
+ }
163
+ if (disk && disk.auth?.sessionId !== sessionId)
164
+ return; // a newer login (or a switch to a key) — leave it
165
+ const target = disk ?? cfg;
166
+ target.auth = rotated;
167
+ writeConfig(ctx.home, target); // atomic; the rotated pair survives a crash
168
+ }
169
+ async function deviceAuthorize(ctx, discovery) {
170
+ const res = await safeFetch(ctx, discovery.deviceAuthorizationUrl, { method: "POST", headers: formHeaders(), body: form({ client_id: discovery.clientId }) });
171
+ if (!res.ok)
172
+ throw new CliError(`could not start login (${res.status}) at ${discovery.deviceAuthorizationUrl}`, "login-failed");
173
+ const d = (await parseJson(res, discovery.deviceAuthorizationUrl));
174
+ if (typeof d.device_code !== "string" || typeof d.user_code !== "string" || typeof d.verification_uri !== "string") {
175
+ throw new CliError("the login authorization response was not the expected shape", "login-shape");
176
+ }
177
+ return {
178
+ device_code: d.device_code,
179
+ user_code: d.user_code,
180
+ verification_uri: d.verification_uri,
181
+ verification_uri_complete: d.verification_uri_complete ?? d.verification_uri,
182
+ expires_in: typeof d.expires_in === "number" ? d.expires_in : 300,
183
+ interval: typeof d.interval === "number" ? d.interval : 5,
184
+ };
185
+ }
186
+ // ── the silent refresh ─────────────────────────────────────────────────────────────────────────
187
+ const inFlight = new Map();
188
+ /**
189
+ * The current bearer for `cfg`: the personal key verbatim, or (for an OAuth session) the access
190
+ * token, refreshed in place when it is within 60s of expiry. Concurrent callers that both hit the
191
+ * refresh window share one refresh (single-flight, keyed by the config's home) so the rotating
192
+ * refresh token is spent exactly once.
193
+ */
194
+ export async function bearerFor(ctx, cfg, deps = {}) {
195
+ if (typeof cfg.key === "string" && cfg.key !== "")
196
+ return cfg.key;
197
+ if (!cfg.auth)
198
+ throw new CliError("this machine is not connected — run: catalyst-skills login", "not-configured");
199
+ const msToExpiry = Date.parse(cfg.auth.expiresAt) - ctx.now().getTime();
200
+ if (Number.isFinite(msToExpiry) && msToExpiry > REFRESH_SKEW_MS)
201
+ return cfg.auth.accessToken;
202
+ const pending = inFlight.get(ctx.home);
203
+ if (pending) {
204
+ const rotated = await pending;
205
+ return rotated.accessToken;
206
+ }
207
+ const promise = refreshAndPersist(ctx, cfg, deps).finally(() => inFlight.delete(ctx.home));
208
+ inFlight.set(ctx.home, promise);
209
+ const rotated = await promise;
210
+ return rotated.accessToken;
211
+ }
212
+ async function refreshAndPersist(ctx, cfg, deps) {
213
+ const sleep = deps.sleep ?? defaultSleep;
214
+ const discovery = await fetchDiscovery(ctx, cfg.baseUrl);
215
+ const current = cfg.auth;
216
+ let lastStatus = 0;
217
+ for (let attempt = 0; attempt < REFRESH_MAX_ATTEMPTS; attempt++) {
218
+ if (attempt > 0)
219
+ await sleep(REFRESH_BASE_BACKOFF_MS * 2 ** (attempt - 1));
220
+ const res = await safeFetch(ctx, discovery.tokenUrl, { method: "POST", headers: formHeaders(), body: form({ grant_type: "refresh_token", refresh_token: current.refreshToken, client_id: discovery.clientId }) });
221
+ if (res.ok) {
222
+ const pair = (await parseJson(res, discovery.tokenUrl));
223
+ if (typeof pair.access_token !== "string" || typeof pair.refresh_token !== "string")
224
+ throw new CliError("the refresh response was missing a token", "session-refresh-shape");
225
+ const rotated = tokensToAuth(ctx, pair.access_token, pair.refresh_token, current.sessionId);
226
+ cfg.auth = rotated; // this process keeps using its own refreshed token
227
+ persistRotation(ctx, cfg, current.sessionId, rotated);
228
+ return rotated;
229
+ }
230
+ lastStatus = res.status;
231
+ const err = await oauthError(res, discovery.tokenUrl);
232
+ if (err === "invalid_grant") {
233
+ // revocation or > inactivity window — the ONLY session-expired case; NOT routine expiry.
234
+ throw new CliError("your login expired or was revoked — run: catalyst-skills login", "session-expired");
235
+ }
236
+ const transient = res.status === 429 || res.status >= 500;
237
+ if (!transient)
238
+ throw new CliError(`could not refresh your login (${res.status}): ${err}`, "session-refresh-failed");
239
+ // transient: fall through to backoff + retry, keeping the stored tokens untouched
240
+ }
241
+ throw new CliError(`could not refresh your login after ${REFRESH_MAX_ATTEMPTS} attempts (last status ${lastStatus}) — your tokens are unchanged; try again`, "session-refresh-failed");
242
+ }
243
+ /** The SDK auth strategy for `cfg`: a static token for the key rail, a fresh-per-connect bearer for
244
+ * the OAuth rail (its `getToken` runs the same refresh path as every HTTP request). */
245
+ export function authStrategyFor(ctx, cfg) {
246
+ if (typeof cfg.key === "string" && cfg.key !== "")
247
+ return { kind: "token", token: cfg.key };
248
+ return { kind: "bearer", getToken: () => bearerFor(ctx, cfg) };
249
+ }
250
+ // ── helpers ──────────────────────────────────────────────────────────────────────────────────────
251
+ function tokensToAuth(ctx, accessToken, refreshToken, fallbackSid) {
252
+ const claims = decodeJwtClaims(accessToken);
253
+ const exp = typeof claims.exp === "number" ? claims.exp * 1_000 : ctx.now().getTime() + 15 * 60_000;
254
+ const sid = typeof claims.sid === "string" ? claims.sid : (fallbackSid ?? "");
255
+ return { kind: "oauth", accessToken, refreshToken, expiresAt: new Date(exp).toISOString(), sessionId: sid };
256
+ }
257
+ function decodeJwtClaims(token) {
258
+ const parts = token.split(".");
259
+ if (parts.length < 2)
260
+ return {};
261
+ try {
262
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
263
+ }
264
+ catch {
265
+ return {};
266
+ }
267
+ }
268
+ function formHeaders() {
269
+ return { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
270
+ }
271
+ function form(fields) {
272
+ return new URLSearchParams(fields).toString();
273
+ }
274
+ async function safeFetch(ctx, url, init, signal) {
275
+ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
276
+ try {
277
+ return await ctx.fetch(url, { ...init, signal: sig });
278
+ }
279
+ catch (err) {
280
+ throw new CliError(`could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`, "network");
281
+ }
282
+ }
283
+ async function parseJson(res, url) {
284
+ try {
285
+ return await res.json();
286
+ }
287
+ catch {
288
+ throw new CliError(`${url} returned a non-JSON body`, "shape");
289
+ }
290
+ }
291
+ /** The OAuth `error` code from a 4xx body, or a short reason when there is none. */
292
+ async function oauthError(res, url) {
293
+ try {
294
+ const body = (await res.json());
295
+ return body.error ?? body.error_description ?? `HTTP ${res.status}`;
296
+ }
297
+ catch {
298
+ return `HTTP ${res.status}`;
299
+ }
300
+ }
package/dist/query.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { flagInt, flagString, positionals } from "./args.js";
6
6
  import { requireConfig, replicaDbPath } from "./config.js";
7
7
  import { CliError, UsageError } from "./errors.js";
8
- import { apiClient } from "./http.js";
8
+ import { apiClient } from "./transport.js";
9
9
  import { engineFor, replicaStatus } from "./replica.js";
10
10
  import { loadSdk } from "./sdk.js";
11
11
  const REPLICA_CAPABLE = new Set(["issues", "issue", "pulls", "projects"]);
package/dist/ready.js CHANGED
@@ -8,6 +8,22 @@ import { contractVersionInRange, readContractCache } from "./contract.js";
8
8
  import { CliError } from "./errors.js";
9
9
  import { replicaStatus, statusLine } from "./replica.js";
10
10
  import { loadSdk } from "./sdk.js";
11
+ /** True when `a` is a semver-older release than `b`. Prerelease/build metadata is ignored; a segment
12
+ * that does not parse counts as 0. A tiny compare on purpose — no dependency for three fields. */
13
+ export function semverOlder(a, b) {
14
+ const parse = (v) => {
15
+ const core = v.split("-")[0].split("+")[0];
16
+ const p = core.split(".");
17
+ return [Number(p[0]) || 0, Number(p[1]) || 0, Number(p[2]) || 0];
18
+ };
19
+ const [a0, a1, a2] = parse(a);
20
+ const [b0, b1, b2] = parse(b);
21
+ if (a0 !== b0)
22
+ return a0 < b0;
23
+ if (a1 !== b1)
24
+ return a1 < b1;
25
+ return a2 < b2;
26
+ }
11
27
  function whoCanAnswer(doc) {
12
28
  const roles = doc.humans.map((h) => `${h.role} ${h.linearUserId}`);
13
29
  return roles.length ? roles.join(", ") : "a tenant owner or admin (none resolved on the contract)";
@@ -22,8 +38,17 @@ export async function readyReport(ctx, deps) {
22
38
  try {
23
39
  cfg = loadConfig(ctx.home);
24
40
  checks.push(cfg
25
- ? { id: "config", ok: true, line: `config: joined ${cfg.name} (${cfg.slug}) as ${cfg.principal}` }
26
- : { id: "config", ok: false, line: "config: not connected", fix: "CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login", who: "you (mint the key at Settings → API keys)" });
41
+ ? {
42
+ id: "config",
43
+ ok: true,
44
+ // `principal` is "service" for every api key (it means "a key, not a browser session"); it
45
+ // never names a person. Since CTC-2076 a personal key's /me carries a `user` block, so name
46
+ // the connected person when there is one, and fall back to the account for a host key.
47
+ line: cfg.user
48
+ ? `config: joined ${cfg.name} as ${cfg.user.label} (${cfg.user.role})`
49
+ : `config: joined ${cfg.name} (${cfg.slug}) as ${cfg.principal}`,
50
+ }
51
+ : { id: "config", ok: false, line: "config: not connected", fix: "npx @catalyst-cloud/catalyst-skills login (keyless; or pass --key / set CATALYST_CLOUD_TOKEN)", who: "you (approve the login in your browser)" });
27
52
  }
28
53
  catch (err) {
29
54
  checks.push({ id: "config", ok: false, line: `config: ${err instanceof CliError ? err.message : String(err)}`, fix: "re-run login to rewrite it", who: "you" });
@@ -34,11 +59,26 @@ export async function readyReport(ctx, deps) {
34
59
  checks.push({ id: "contract", ok: false, line: "contract: not cached", fix: "catalyst-skills contract --refresh", who: "you" });
35
60
  }
36
61
  else if (contractVersionInRange(cache.contractVersion, range) !== true) {
37
- checks.push({ id: "contract", ok: false, line: `contract: version ${cache.contractVersion} is outside this bundle's range ${range}`, fix: "npm update -g @catalyst-cloud/catalyst-skills", who: "you" });
62
+ checks.push({ id: "contract", ok: false, line: `contract: version ${cache.contractVersion} is outside this bundle's range ${range}`, fix: "npm install -g @catalyst-cloud/catalyst-skills@latest && catalyst-skills login", who: "you" });
38
63
  }
39
64
  else {
40
65
  checks.push({ id: "contract", ok: true, line: `contract: ${cache.contractVersion} cached ${cache.fetchedAt} (range ${range})` });
41
66
  }
67
+ // The cloud MAY publish the bundle it expects (catalyst-cloud#3746). Read it defensively: warn (never
68
+ // refuse) when this installed bundle is older than the minimum, and emit nothing when the field is
69
+ // absent (an older cloud) — a stale bundle is a degrading note, so `ready` still returns READY.
70
+ const minVersion = cache?.doc.skillsBundle?.minVersion;
71
+ if (minVersion) {
72
+ const installed = readManifest().version;
73
+ if (semverOlder(installed, minVersion)) {
74
+ checks.push({
75
+ id: "bundle",
76
+ ok: false,
77
+ note: true,
78
+ line: `bundle: ${installed} installed is older than the tenant's minimum ${minVersion} — upgrade: npm install -g @catalyst-cloud/catalyst-skills@latest && catalyst-skills login`,
79
+ });
80
+ }
81
+ }
42
82
  if (cfg) {
43
83
  checks.push(cfg.cliPath && existsSync(cfg.cliPath)
44
84
  ? { id: "cliPath", ok: true, line: `cliPath: ${cfg.cliPath}` }
package/dist/replica.js CHANGED
@@ -10,7 +10,8 @@ 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 "./http.js";
13
+ import { apiClient } from "./transport.js";
14
+ import { authStrategyFor } from "./oauth.js";
14
15
  import { loadSdk } from "./sdk.js";
15
16
  export const DEFAULT_STALE_MS = 15_000;
16
17
  export function pidfilePath(dbPath) {
@@ -204,7 +205,7 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
204
205
  baseUrl: apiBase(cfg),
205
206
  account: cfg.account,
206
207
  accountSource: "declared",
207
- auth: { kind: "token", token: cfg.key },
208
+ auth: authStrategyFor(ctx, cfg),
208
209
  dbPath,
209
210
  engine,
210
211
  fetchImpl: ctx.fetch,
package/dist/skills.js CHANGED
@@ -70,5 +70,9 @@ export function readChangelog() {
70
70
  }
71
71
  export function updateNoticeLine(previous, current, entry) {
72
72
  const summary = entry ?? "see CHANGELOG.md";
73
- return `[catalyst-skills] updated ${previous} → ${current}: ${summary} · update with: npm update -g ${PACKAGE_NAME} (or: npx ${PACKAGE_NAME}@latest login)`;
73
+ // `npm update -g` treats a global install as a caret range, and a caret below 1.0.0 never moves a
74
+ // minor — so it never crosses 0.2.x → 0.3.0. Pin @latest via `npm install`, THEN re-run login: the
75
+ // install alone does not rewrite customer.json.cliPath, so the skill helpers would keep spawning the
76
+ // stale recorded bundle and repeat this notice forever; the new global bin records its own path.
77
+ return `[catalyst-skills] updated ${previous} → ${current}: ${summary} · update with: npm install -g ${PACKAGE_NAME}@latest && catalyst-skills login`;
74
78
  }
@@ -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
- cfg;
6
+ baseUrl;
7
+ credential;
6
8
  fetchImpl;
7
- constructor(cfg, fetchImpl) {
8
- this.cfg = cfg;
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.cfg.baseUrl)}${path}`);
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.cfg.key}`,
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.cfg.key}`, accept: "application/x-ndjson, application/json" },
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 };
@@ -69,7 +75,7 @@ export class ApiClient {
69
75
  const res = await this.send(url, {
70
76
  method: "POST",
71
77
  headers: {
72
- authorization: `Bearer ${this.cfg.key}`,
78
+ authorization: `Bearer ${await this.credential()}`,
73
79
  accept: "application/json",
74
80
  "content-type": "application/json",
75
81
  },
@@ -121,8 +127,10 @@ export class ApiClient {
121
127
  throw new CliError(`${what} failed (${res.status})${reason ? `: ${reason}` : ""}`, "http", 2, res.status);
122
128
  }
123
129
  }
130
+ /** Build the authenticated client for a config: the bearer resolves through {@link bearerFor}, so a
131
+ * key config sends the key and an OAuth config sends a freshly-refreshed access token per request. */
124
132
  export function apiClient(cfg, ctx) {
125
- return new ApiClient(cfg, ctx.fetch);
133
+ return new ApiClient(cfg.baseUrl, () => bearerFor(ctx, cfg), ctx.fetch);
126
134
  }
127
135
  /** GET /api/v1/me — how a key learns its own tenant. Unchanged contract from 0.1. */
128
136
  export async function fetchMe(baseUrl, key, fetchImpl, timeoutMs = REQUEST_TIMEOUT_MS) {
@@ -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.token}` }, signal });
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: { kind: "token", token: opts.token },
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 "./http.js";
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
- token: cfg.key,
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 "./http.js";
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/catalyst-skills",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Customer skill bundle for Catalyst Cloud: eight skills named from the executive's seat (whats-happening, what-needs-me, run-this-project, 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",
@@ -41,7 +41,7 @@
41
41
  "version:sync": "node scripts/sync-plugin-version.mjs"
42
42
  },
43
43
  "dependencies": {
44
- "@catalyst-cloud/sdk": "^0.8.49"
44
+ "@catalyst-cloud/sdk": "^0.9.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "^26.3.0",
@@ -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, cache the tenant contract, and verify. Use when a person is getting started, when their config is missing or broken, when 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 key 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.
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. It asks the cloud who the key belongs to — which tenant, and which person — writes the config every skill and the CLI read, and caches the tenant contract beside it.
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
- With no environment variable and a terminal attached, `catalyst-skills login` prompts for the key without echoing it. `--key <personal-key>` is the third form, for a script. `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`.
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 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.
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. Ask the person to mint their **personal key** at Settings → API keys in the Catalyst Cloud web app (every active member can; no admin is needed) and hand it to the login command. It is shown once. Never put it in a file, a ticket, or this conversation beyond that one command. 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.
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 key is a credential.** It 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.
54
- - **Never guess a tenant.** There is no `--account`; the key is the only tenant selector, on purpose. If the cloud names no tenant for the key, stop and say so.
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.