@catalyst-cloud/catalyst-skills 0.1.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 +5 -0
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/bin/catalyst-skills.js +10 -0
- package/dist/cli.js +424 -0
- package/package.json +40 -0
- package/skills/ask/SKILL.md +37 -0
- package/skills/concierge/SKILL.md +41 -0
- package/skills/join/SKILL.md +30 -0
- package/skills/linearis/SKILL.md +40 -0
- package/skills/setup/SKILL.md +27 -0
- package/skills/steward/SKILL.md +30 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First published customer skill bundle: vendored customer editions of concierge, steward, ask and linearis plus the new join and setup skills, one-command tenant discovery via GET /api/v1/me, config written to ~/.config/catalyst-cloud/customer.json, and a placeholder 0.x tenant contract range (CTC-1924 in flight).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Coalesce Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @catalyst-cloud/catalyst-skills
|
|
2
|
+
|
|
3
|
+
The customer skill bundle for **catalyst-cloud**: install the concierge, steward, ask, linearis, join and setup skills from npm and join your tenant in one documented step. Customer zero needs no Coalesce Labs repository — everything the customer touches is this package and their tenant's cloud.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
| piece | minimum |
|
|
8
|
+
| -- | -- |
|
|
9
|
+
| Claude Code | 2.0 (skills are discovered from `~/.claude/skills`) |
|
|
10
|
+
| Node | 18.17 (20+ recommended; ships the `npx` used below) |
|
|
11
|
+
| Bun | 1.0 — optional, only if you prefer `bunx` over `npx` |
|
|
12
|
+
|
|
13
|
+
You also need an **account key** from your tenant admin. The key is the only credential and the only tenant selector — you never type a tenant or account id.
|
|
14
|
+
|
|
15
|
+
## Install and join — one step
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx @catalyst-cloud/catalyst-skills join --key <your-account-key>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That single command does everything:
|
|
22
|
+
|
|
23
|
+
1. **Installs the skills** where Claude Code discovers them (`~/.claude/skills/`): `concierge`, `steward`, `ask`, `linearis`, `join`, `setup`.
|
|
24
|
+
2. **Discovers your tenant from the key** — it calls `GET /api/v1/me` on the cloud, which answers `{account, slug, name, permissions, principal}` for exactly whoever the key already proves you to be. No tenant is ever typed, guessed, or defaulted.
|
|
25
|
+
3. **Writes config** to `~/.config/catalyst-cloud/customer.json` (mode `0600` — it holds your key; nothing else ever reads or copies it).
|
|
26
|
+
4. Prints the tenant it joined (`Joined <name> (<slug>)`), so you can see you landed in the right place.
|
|
27
|
+
|
|
28
|
+
Open a new Claude Code session and just talk to the concierge — it answers about **your own tenant**, reading it with the config the join wrote.
|
|
29
|
+
|
|
30
|
+
### Plain global-install form
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npm install -g @catalyst-cloud/catalyst-skills
|
|
34
|
+
catalyst-skills join --key <your-account-key>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`bunx @catalyst-cloud/catalyst-skills join --key …` works identically. The key may also be provided via `CATALYST_CLOUD_TOKEN`; a non-default cloud via `CATALYST_CLOUD_BASE_URL` or `--base-url`. Re-running join after a key rotation rewrites the config; `catalyst-skills status` shows which tenant this machine belongs to.
|
|
38
|
+
|
|
39
|
+
### If join fails
|
|
40
|
+
|
|
41
|
+
- `401 credential not accepted` — stale or mistyped key; ask your tenant admin. Never retry in a loop.
|
|
42
|
+
- `403 account-not-operational` — the tenant is suspended; an admin conversation, not a local fix.
|
|
43
|
+
- Network error — the URL is named in the message; check `--base-url`.
|
|
44
|
+
|
|
45
|
+
## What comes from where
|
|
46
|
+
|
|
47
|
+
This package is **self-contained: nothing in it is a hard dependency on any Coalesce Labs repository**, and you never need to read one to find a second step.
|
|
48
|
+
|
|
49
|
+
- **`concierge`, `steward`, `ask`, `linearis`** are vendored, customer-adapted editions of skills that originate in the **catalyst repository's `catalyst-dev` Claude Code plugin** (`coalesce-labs/catalyst`, `plugins/dev/skills/…`). That repository is private and its plugin marketplace (`/plugin marketplace add coalesce-labs/catalyst`) is **not installable by customers today**, so the bundle vendors the four skills at publish time instead of depending on the marketplace. Each vendored `SKILL.md` carries a `vendored-from:` provenance line. Upstream, CTL-2299 and CTL-2300 are removing the fleet-owner defaults from those skills; until they land, this package's copies carry the customer adaptation.
|
|
50
|
+
- **`join` and `setup`** are written here, against the public cloud API surface (`GET /api/v1/me`).
|
|
51
|
+
|
|
52
|
+
## Version contract
|
|
53
|
+
|
|
54
|
+
This package is versioned against the **tenant contract** route. That route is still in flight (CTC-1924), so until it lands the package pins a **placeholder contract range `0.x`** — recorded in `package.json` under `catalystCloud.tenantContractRange` and echoed by `catalyst-skills --version` and `join`. When CTC-1924 lands, the placeholder is replaced with the real range and a mismatch becomes a loud, actionable error rather than a silent guess.
|
|
55
|
+
|
|
56
|
+
## Updates (one-line notice)
|
|
57
|
+
|
|
58
|
+
After you update the package (`npm update -g @catalyst-cloud/catalyst-skills`, or just run any `npx @catalyst-cloud/catalyst-skills@latest …` command), the **next session** — the next `catalyst-skills` command or skill that runs — prints exactly one line:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
[catalyst-skills] updated 0.1.0 → 0.2.0: <that version's changelog entry> · update with: npm update -g @catalyst-cloud/catalyst-skills (or: npx @catalyst-cloud/catalyst-skills@latest join)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
and never prints it again for that version. `catalyst-skills notice` is the skill-facing entry point for that line.
|
|
65
|
+
|
|
66
|
+
The same run also **refreshes the copied skills** under your skills directory (the one `join` recorded in `customer.json`, `~/.claude/skills` by default) to the new bundle before the new version is recorded — `npm update -g` replaces the package, not the copies `join` made, so without this step a published skill fix would never reach an existing install. A skill directory you wrote yourself is left alone and named in the output (`catalyst-skills install --force` replaces it). If the refresh fails, the old version stays recorded and the next command tries again, telling you to run `catalyst-skills install`.
|
|
67
|
+
|
|
68
|
+
## For the package maintainers
|
|
69
|
+
|
|
70
|
+
Published **only** by the `skills-bundle publish` GitHub Actions workflow on a `skills-bundle-v*` tag — never from a laptop. The workflow runs this package's smoke test (pack the tarball, install it into a clean directory, run `join` against a fixture `/me` server) before `npm publish`, so a broken publish cannot ship. The npm credential is the repository secret **`NPM_PUBLISH_TOKEN`** — an automation-token grant on the `@catalyst-cloud` scope.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin ESM launcher for @catalyst-cloud/catalyst-skills (CTC-1926). tsc does not carry a shebang
|
|
3
|
+
// through emit, so the bin is this two-line wrapper and the program lives in dist/cli.js.
|
|
4
|
+
import("../dist/cli.js")
|
|
5
|
+
.then((m) => m.main(process.argv.slice(2)))
|
|
6
|
+
.then((code) => process.exit(code))
|
|
7
|
+
.catch((err) => {
|
|
8
|
+
console.error(`catalyst-skills: failed to load: ${err instanceof Error ? err.message : String(err)}`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
});
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// cli.ts — CTC-1926: the `catalyst-skills` customer installer/joiner (npm: @catalyst-cloud/catalyst-skills).
|
|
2
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
export const PACKAGE_NAME = "@catalyst-cloud/catalyst-skills";
|
|
6
|
+
export const DEFAULT_BASE_URL = "https://staging.catalystcloud.dev";
|
|
7
|
+
export const PROVENANCE_MARKER = "vendored-from: @catalyst-cloud/catalyst-skills";
|
|
8
|
+
export const CUSTOMER_SKILLS = [
|
|
9
|
+
"ask",
|
|
10
|
+
"concierge",
|
|
11
|
+
"join",
|
|
12
|
+
"linearis",
|
|
13
|
+
"setup",
|
|
14
|
+
"steward",
|
|
15
|
+
];
|
|
16
|
+
export class UsageError extends Error {
|
|
17
|
+
}
|
|
18
|
+
export class CliError extends Error {
|
|
19
|
+
code;
|
|
20
|
+
constructor(message, code) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.code = code;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class MeError extends Error {
|
|
26
|
+
kind;
|
|
27
|
+
status;
|
|
28
|
+
constructor(message, kind, status) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.kind = kind;
|
|
31
|
+
this.status = status;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function parseArgs(argv) {
|
|
35
|
+
const out = { command: null, force: false, help: false, version: false };
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
const a = argv[i];
|
|
38
|
+
if (a === "-h" || a === "--help")
|
|
39
|
+
out.help = true;
|
|
40
|
+
else if (a === "-V" || a === "--version")
|
|
41
|
+
out.version = true;
|
|
42
|
+
else if (a === "--key")
|
|
43
|
+
out.key = requireValue(argv, ++i, "--key");
|
|
44
|
+
else if (a === "--base-url")
|
|
45
|
+
out.baseUrl = requireValue(argv, ++i, "--base-url");
|
|
46
|
+
else if (a === "--skills-dir")
|
|
47
|
+
out.skillsDir = requireValue(argv, ++i, "--skills-dir");
|
|
48
|
+
else if (a === "--force")
|
|
49
|
+
out.force = true;
|
|
50
|
+
else if (a.startsWith("--"))
|
|
51
|
+
throw new UsageError(`unknown option: ${a}`);
|
|
52
|
+
else if (out.command === null)
|
|
53
|
+
out.command = a;
|
|
54
|
+
else
|
|
55
|
+
throw new UsageError(`unexpected argument: ${a}`);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
function requireValue(argv, i, flag) {
|
|
60
|
+
const v = argv[i];
|
|
61
|
+
if (v === undefined || v === "")
|
|
62
|
+
throw new UsageError(`${flag} requires a value`);
|
|
63
|
+
return v;
|
|
64
|
+
}
|
|
65
|
+
let manifestCache = null;
|
|
66
|
+
export function readManifest() {
|
|
67
|
+
if (manifestCache)
|
|
68
|
+
return manifestCache;
|
|
69
|
+
const raw = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
70
|
+
manifestCache = {
|
|
71
|
+
version: raw.version,
|
|
72
|
+
tenantContractRange: raw.catalystCloud?.tenantContractRange ?? "unpinned",
|
|
73
|
+
};
|
|
74
|
+
return manifestCache;
|
|
75
|
+
}
|
|
76
|
+
export function configPathFor(home) {
|
|
77
|
+
return join(home, ".config", "catalyst-cloud", "customer.json");
|
|
78
|
+
}
|
|
79
|
+
export function defaultSkillsDirFor(home) {
|
|
80
|
+
return join(home, ".claude", "skills");
|
|
81
|
+
}
|
|
82
|
+
export function loadConfig(home) {
|
|
83
|
+
const path = configPathFor(home);
|
|
84
|
+
if (!existsSync(path))
|
|
85
|
+
return null;
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new CliError(`config at ${path} is not valid JSON — re-run join to rewrite it`, "config-corrupt");
|
|
92
|
+
}
|
|
93
|
+
const cfg = parsed;
|
|
94
|
+
if (typeof cfg.account !== "string" ||
|
|
95
|
+
typeof cfg.key !== "string" ||
|
|
96
|
+
typeof cfg.baseUrl !== "string") {
|
|
97
|
+
throw new CliError(`config at ${path} is missing required fields — re-run join to rewrite it`, "config-corrupt");
|
|
98
|
+
}
|
|
99
|
+
return cfg;
|
|
100
|
+
}
|
|
101
|
+
export const CONFIG_MODE = 0o600;
|
|
102
|
+
/**
|
|
103
|
+
* Write the config and return its path plus the mode the file ACTUALLY carries afterwards.
|
|
104
|
+
*
|
|
105
|
+
* Codex round 1 (P1): Node honours `mode` only when it creates the file, so a rewrite of an existing
|
|
106
|
+
* `customer.json` (key rotation, the corrupt-config repair, the update stamp) left a pre-existing
|
|
107
|
+
* 0644 file world-readable while the CLI claimed 0600. The chmod runs on every write, and the
|
|
108
|
+
* reported mode is read back from the file rather than asserted.
|
|
109
|
+
*/
|
|
110
|
+
export function saveConfig(home, cfg) {
|
|
111
|
+
return writeConfig(home, cfg).path;
|
|
112
|
+
}
|
|
113
|
+
export function writeConfig(home, cfg) {
|
|
114
|
+
const path = configPathFor(home);
|
|
115
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
116
|
+
writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n", { mode: CONFIG_MODE });
|
|
117
|
+
chmodSync(path, CONFIG_MODE);
|
|
118
|
+
return { path, mode: statSync(path).mode & 0o777 };
|
|
119
|
+
}
|
|
120
|
+
export function formatMode(mode) {
|
|
121
|
+
return "0" + mode.toString(8);
|
|
122
|
+
}
|
|
123
|
+
export function normalizeBaseUrl(url) {
|
|
124
|
+
return url.replace(/\/+$/, "");
|
|
125
|
+
}
|
|
126
|
+
export async function fetchMe(baseUrl, key, fetchImpl, timeoutMs = 15_000) {
|
|
127
|
+
const url = `${normalizeBaseUrl(baseUrl)}/api/v1/me`;
|
|
128
|
+
let res;
|
|
129
|
+
try {
|
|
130
|
+
res = await fetchImpl(url, {
|
|
131
|
+
headers: { authorization: `Bearer ${key}`, accept: "application/json" },
|
|
132
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
throw new MeError(`could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`, "network");
|
|
137
|
+
}
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
let reason;
|
|
140
|
+
try {
|
|
141
|
+
const body = (await res.json());
|
|
142
|
+
reason = body.reason ?? body.error ?? "";
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
reason = "";
|
|
146
|
+
}
|
|
147
|
+
const detail = res.status === 401
|
|
148
|
+
? "credential not accepted — ask your tenant admin for a valid account key"
|
|
149
|
+
: reason || `HTTP ${res.status}`;
|
|
150
|
+
throw new MeError(`GET /me failed (${res.status}): ${detail}`, "http", res.status);
|
|
151
|
+
}
|
|
152
|
+
let body;
|
|
153
|
+
try {
|
|
154
|
+
body = (await res.json());
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
throw new MeError("GET /me returned a non-JSON body", "shape");
|
|
158
|
+
}
|
|
159
|
+
if (typeof body.account !== "string" ||
|
|
160
|
+
typeof body.slug !== "string" ||
|
|
161
|
+
typeof body.name !== "string" ||
|
|
162
|
+
(body.permissions !== null && !Array.isArray(body.permissions)) ||
|
|
163
|
+
(body.principal !== "service" && body.principal !== "session")) {
|
|
164
|
+
throw new MeError("GET /me returned an unexpected shape", "shape");
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
account: body.account,
|
|
168
|
+
slug: body.slug,
|
|
169
|
+
name: body.name,
|
|
170
|
+
permissions: body.permissions,
|
|
171
|
+
principal: body.principal,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export function parseChangelogEntry(changelog, version) {
|
|
175
|
+
const lines = changelog.split("\n");
|
|
176
|
+
const heading = `## ${version}`;
|
|
177
|
+
const start = lines.findIndex((l) => l.trim() === heading);
|
|
178
|
+
if (start === -1)
|
|
179
|
+
return null;
|
|
180
|
+
for (const line of lines.slice(start + 1)) {
|
|
181
|
+
const t = line.trim();
|
|
182
|
+
if (t === "")
|
|
183
|
+
continue;
|
|
184
|
+
if (t.startsWith("#"))
|
|
185
|
+
break;
|
|
186
|
+
return t;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
export function readChangelog() {
|
|
191
|
+
return readFileSync(new URL("../CHANGELOG.md", import.meta.url), "utf8");
|
|
192
|
+
}
|
|
193
|
+
export function updateNoticeLine(previous, current, entry) {
|
|
194
|
+
const summary = entry ?? "see CHANGELOG.md";
|
|
195
|
+
return `[catalyst-skills] updated ${previous} → ${current}: ${summary} · update with: npm update -g ${PACKAGE_NAME} (or: npx ${PACKAGE_NAME}@latest join)`;
|
|
196
|
+
}
|
|
197
|
+
export function skillsSourceDir() {
|
|
198
|
+
return fileURLToPath(new URL("../skills", import.meta.url));
|
|
199
|
+
}
|
|
200
|
+
export function installSkills(targetDir, opts, sourceDir = skillsSourceDir()) {
|
|
201
|
+
const result = { installed: [], skipped: [] };
|
|
202
|
+
const names = readdirSync(sourceDir, { withFileTypes: true })
|
|
203
|
+
.filter((e) => e.isDirectory())
|
|
204
|
+
.map((e) => e.name)
|
|
205
|
+
.sort();
|
|
206
|
+
for (const name of names) {
|
|
207
|
+
const src = join(sourceDir, name);
|
|
208
|
+
if (!existsSync(join(src, "SKILL.md")))
|
|
209
|
+
continue;
|
|
210
|
+
const dst = join(targetDir, name);
|
|
211
|
+
const existingMd = join(dst, "SKILL.md");
|
|
212
|
+
if (existsSync(existingMd) && !opts.force) {
|
|
213
|
+
const existing = readFileSync(existingMd, "utf8");
|
|
214
|
+
if (!existing.includes(PROVENANCE_MARKER)) {
|
|
215
|
+
result.skipped.push({ name, reason: "foreign-skill-dir" });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
mkdirSync(dst, { recursive: true });
|
|
220
|
+
cpSync(src, dst, { recursive: true });
|
|
221
|
+
result.installed.push(name);
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
export function usageText() {
|
|
226
|
+
return [
|
|
227
|
+
`${PACKAGE_NAME} — install the customer skill bundle and join your catalyst-cloud tenant`,
|
|
228
|
+
"",
|
|
229
|
+
"Usage:",
|
|
230
|
+
" catalyst-skills join --key <account-key> [--base-url <url>] [--skills-dir <dir>]",
|
|
231
|
+
" catalyst-skills install [--skills-dir <dir>] [--force]",
|
|
232
|
+
" catalyst-skills notice",
|
|
233
|
+
" catalyst-skills status",
|
|
234
|
+
"",
|
|
235
|
+
"The key may also come from CATALYST_CLOUD_TOKEN; the base URL defaults to",
|
|
236
|
+
`CATALYST_CLOUD_BASE_URL or ${DEFAULT_BASE_URL}.`,
|
|
237
|
+
"",
|
|
238
|
+
"join installs the skills (default ~/.claude/skills), calls GET /api/v1/me to discover",
|
|
239
|
+
"your tenant from the key alone, and writes ~/.config/catalyst-cloud/customer.json (0600).",
|
|
240
|
+
].join("\n");
|
|
241
|
+
}
|
|
242
|
+
export function defaultCtx() {
|
|
243
|
+
return {
|
|
244
|
+
env: process.env,
|
|
245
|
+
home: process.env.CATALYST_SKILLS_HOME ?? process.env.HOME ?? "/",
|
|
246
|
+
stdout: (line) => console.log(line),
|
|
247
|
+
stderr: (line) => console.error(line),
|
|
248
|
+
fetch: fetch,
|
|
249
|
+
now: () => new Date(),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
export function resolveSkillsDir(args, ctx, cfg) {
|
|
253
|
+
return (args.skillsDir ??
|
|
254
|
+
ctx.env.CATALYST_SKILLS_CLAUDE_DIR ??
|
|
255
|
+
cfg?.skillsDir ??
|
|
256
|
+
defaultSkillsDirFor(ctx.home));
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The Tier-2 update path. When the bundle on disk is newer than the version the config last
|
|
260
|
+
* recorded, the copies under the skills dir are still the OLD bundle's — `npm update -g` and a newer
|
|
261
|
+
* `npx` replace the package, not the copies join made (Codex round 1, P2). So the copies are
|
|
262
|
+
* refreshed FIRST and the new version is recorded only once that succeeded; a failed refresh leaves
|
|
263
|
+
* the old stamp in place so the next command tries again instead of silencing the notice forever.
|
|
264
|
+
*/
|
|
265
|
+
function maybePrintUpdateNotice(args, ctx) {
|
|
266
|
+
let cfg;
|
|
267
|
+
try {
|
|
268
|
+
cfg = loadConfig(ctx.home);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!cfg)
|
|
274
|
+
return;
|
|
275
|
+
const manifest = readManifest();
|
|
276
|
+
if (cfg.lastSkillBundleVersion === manifest.version)
|
|
277
|
+
return;
|
|
278
|
+
const previous = cfg.lastSkillBundleVersion;
|
|
279
|
+
if (previous) {
|
|
280
|
+
const entry = parseChangelogEntry(readChangelog(), manifest.version);
|
|
281
|
+
ctx.stdout(updateNoticeLine(previous, manifest.version, entry));
|
|
282
|
+
}
|
|
283
|
+
const skillsDir = resolveSkillsDir(args, ctx, cfg);
|
|
284
|
+
let refreshed;
|
|
285
|
+
try {
|
|
286
|
+
refreshed = installSkills(skillsDir, { force: false });
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
ctx.stderr(`[catalyst-skills] could not refresh the skills at ${skillsDir} (${err instanceof Error ? err.message : String(err)}) — the ${manifest.version} skills are not installed yet; run: catalyst-skills install`);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (refreshed.installed.length > 0) {
|
|
293
|
+
ctx.stdout(`[catalyst-skills] refreshed ${refreshed.installed.join(", ")} at ${skillsDir} to ${manifest.version}`);
|
|
294
|
+
}
|
|
295
|
+
for (const s of refreshed.skipped) {
|
|
296
|
+
ctx.stdout(`[catalyst-skills] left "${s.name}" alone: ${skillsDir}/${s.name} was not installed by this package (catalyst-skills install --force to replace)`);
|
|
297
|
+
}
|
|
298
|
+
cfg.lastSkillBundleVersion = manifest.version;
|
|
299
|
+
cfg.skillsDir = skillsDir;
|
|
300
|
+
saveConfig(ctx.home, cfg);
|
|
301
|
+
}
|
|
302
|
+
export async function main(argv, ctx = defaultCtx()) {
|
|
303
|
+
let args;
|
|
304
|
+
try {
|
|
305
|
+
args = parseArgs(argv);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
ctx.stderr(err instanceof Error ? err.message : String(err));
|
|
309
|
+
ctx.stderr(usageText());
|
|
310
|
+
return 1;
|
|
311
|
+
}
|
|
312
|
+
const manifest = readManifest();
|
|
313
|
+
if (args.version) {
|
|
314
|
+
ctx.stdout(`${PACKAGE_NAME} ${manifest.version} (tenant contract range: ${manifest.tenantContractRange})`);
|
|
315
|
+
return 0;
|
|
316
|
+
}
|
|
317
|
+
if (args.help || args.command === null) {
|
|
318
|
+
ctx.stdout(usageText());
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
maybePrintUpdateNotice(args, ctx);
|
|
323
|
+
switch (args.command) {
|
|
324
|
+
case "join":
|
|
325
|
+
return await cmdJoin(args, ctx, manifest);
|
|
326
|
+
case "install":
|
|
327
|
+
return cmdInstall(args, ctx);
|
|
328
|
+
case "notice":
|
|
329
|
+
return 0;
|
|
330
|
+
case "status":
|
|
331
|
+
return cmdStatus(ctx, manifest);
|
|
332
|
+
default:
|
|
333
|
+
ctx.stderr(`unknown command: ${args.command}`);
|
|
334
|
+
ctx.stderr(usageText());
|
|
335
|
+
return 1;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
if (err instanceof UsageError) {
|
|
340
|
+
ctx.stderr(err.message);
|
|
341
|
+
ctx.stderr(usageText());
|
|
342
|
+
return 1;
|
|
343
|
+
}
|
|
344
|
+
if (err instanceof MeError || err instanceof CliError) {
|
|
345
|
+
ctx.stderr(`catalyst-skills: ${err.message}`);
|
|
346
|
+
return 2;
|
|
347
|
+
}
|
|
348
|
+
throw err;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async function cmdJoin(args, ctx, manifest) {
|
|
352
|
+
const key = (args.key ?? ctx.env.CATALYST_CLOUD_TOKEN ?? "").trim();
|
|
353
|
+
if (!key) {
|
|
354
|
+
throw new UsageError("join needs an account key: pass --key <account-key> or set CATALYST_CLOUD_TOKEN");
|
|
355
|
+
}
|
|
356
|
+
const baseUrl = normalizeBaseUrl(args.baseUrl ?? ctx.env.CATALYST_CLOUD_BASE_URL ?? DEFAULT_BASE_URL);
|
|
357
|
+
const me = await fetchMe(baseUrl, key, ctx.fetch);
|
|
358
|
+
let existing;
|
|
359
|
+
try {
|
|
360
|
+
existing = loadConfig(ctx.home);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
existing = null;
|
|
364
|
+
}
|
|
365
|
+
const previous = existing?.lastSkillBundleVersion ?? null;
|
|
366
|
+
const skillsDir = resolveSkillsDir(args, ctx, existing);
|
|
367
|
+
const result = installSkills(skillsDir, { force: args.force });
|
|
368
|
+
const config = {
|
|
369
|
+
baseUrl,
|
|
370
|
+
key,
|
|
371
|
+
account: me.account,
|
|
372
|
+
slug: me.slug,
|
|
373
|
+
name: me.name,
|
|
374
|
+
permissions: me.permissions,
|
|
375
|
+
principal: me.principal,
|
|
376
|
+
joinedAt: ctx.now().toISOString(),
|
|
377
|
+
lastSkillBundleVersion: manifest.version,
|
|
378
|
+
skillsDir,
|
|
379
|
+
};
|
|
380
|
+
const written = writeConfig(ctx.home, config);
|
|
381
|
+
ctx.stdout(`Joined ${me.name} (${me.slug}) — account ${me.account}`);
|
|
382
|
+
ctx.stdout(`Config written to ${written.path} (mode ${formatMode(written.mode)}, holds your key)${written.mode === CONFIG_MODE ? "" : ` — expected ${formatMode(CONFIG_MODE)}; chmod it by hand`}`);
|
|
383
|
+
ctx.stdout(result.installed.length > 0
|
|
384
|
+
? `Skills installed to ${skillsDir}: ${result.installed.join(", ")}`
|
|
385
|
+
: `No skills installed — ${skillsDir} already had them`);
|
|
386
|
+
for (const s of result.skipped) {
|
|
387
|
+
ctx.stdout(`Skipped "${s.name}": ${skillsDir}/${s.name} exists and was not installed by this package (use --force to replace)`);
|
|
388
|
+
}
|
|
389
|
+
ctx.stdout(`Tenant contract range: ${manifest.tenantContractRange} (placeholder until the tenant contract route lands — CTC-1924)`);
|
|
390
|
+
if (previous && previous !== manifest.version) {
|
|
391
|
+
const entry = parseChangelogEntry(readChangelog(), manifest.version);
|
|
392
|
+
ctx.stdout(updateNoticeLine(previous, manifest.version, entry));
|
|
393
|
+
}
|
|
394
|
+
return 0;
|
|
395
|
+
}
|
|
396
|
+
function cmdInstall(args, ctx) {
|
|
397
|
+
let cfg;
|
|
398
|
+
try {
|
|
399
|
+
cfg = loadConfig(ctx.home);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
cfg = null;
|
|
403
|
+
}
|
|
404
|
+
const skillsDir = resolveSkillsDir(args, ctx, cfg);
|
|
405
|
+
const result = installSkills(skillsDir, { force: args.force });
|
|
406
|
+
ctx.stdout(result.installed.length > 0
|
|
407
|
+
? `Skills installed to ${skillsDir}: ${result.installed.join(", ")}`
|
|
408
|
+
: `No skills installed — ${skillsDir} already had them`);
|
|
409
|
+
for (const s of result.skipped) {
|
|
410
|
+
ctx.stdout(`Skipped "${s.name}": ${skillsDir}/${s.name} exists and was not installed by this package (use --force to replace)`);
|
|
411
|
+
}
|
|
412
|
+
return 0;
|
|
413
|
+
}
|
|
414
|
+
function cmdStatus(ctx, manifest) {
|
|
415
|
+
const cfg = loadConfig(ctx.home);
|
|
416
|
+
if (!cfg) {
|
|
417
|
+
ctx.stdout(`Not joined yet — run: npx ${PACKAGE_NAME} join --key <your account key>`);
|
|
418
|
+
return 0;
|
|
419
|
+
}
|
|
420
|
+
ctx.stdout(`Tenant: ${cfg.name} (${cfg.slug}) — account ${cfg.account}`);
|
|
421
|
+
ctx.stdout(`API: ${cfg.baseUrl} (principal: ${cfg.principal})`);
|
|
422
|
+
ctx.stdout(`Bundle: ${PACKAGE_NAME} ${manifest.version} (tenant contract range: ${manifest.tenantContractRange})`);
|
|
423
|
+
return 0;
|
|
424
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@catalyst-cloud/catalyst-skills",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Customer skill bundle for catalyst-cloud: install the concierge, steward, ask, linearis, join and setup skills from npm and join your tenant with one command. No catalyst repository checkout required.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18.17"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"catalyst-skills": "bin/catalyst-skills.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin",
|
|
18
|
+
"dist",
|
|
19
|
+
"skills",
|
|
20
|
+
"README.md",
|
|
21
|
+
"CHANGELOG.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"catalystCloud": {
|
|
25
|
+
"tenantContractRange": "0.x"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"build": "tsc -p tsconfig.build.json",
|
|
30
|
+
"prepack": "tsc -p tsconfig.build.json",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:coverage": "vitest run --coverage && bun ../../scripts/coverage-per-file.ts"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@catalyst-cloud/typescript-config": "workspace:*",
|
|
36
|
+
"@types/node": "^26.3.0",
|
|
37
|
+
"typescript": "^7.0.2",
|
|
38
|
+
"vitest": "^4.1.11"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ask
|
|
3
|
+
description:
|
|
4
|
+
Record a decision request to the human on the customer's catalyst-cloud tenant. Use when active work is gated on a decision only the human can make — a product/priority/approval call or an action only they can take. The ask carries the question, concrete options, a default that fires if they are silent, and what it blocks. Never use for brainstorming or questions the human asked first.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills — adapted from the catalyst repository's catalyst-dev plugin (plugins/dev/skills/ask) for customer tenants -->
|
|
8
|
+
|
|
9
|
+
# Ask — one decision, one record
|
|
10
|
+
|
|
11
|
+
An ask is a decision request with a deadline-shaped default. It exists so that a question the human must answer is **one record they can find**, not a TODO line, a chat message, or a hope.
|
|
12
|
+
|
|
13
|
+
## Shape of an ask
|
|
14
|
+
|
|
15
|
+
An ask always carries, verbatim:
|
|
16
|
+
|
|
17
|
+
1. **The question** — one sentence, as its title.
|
|
18
|
+
2. **Options** — the realistic choices, each one line.
|
|
19
|
+
3. **Default if silent** — what proceeds, and after how long. The default must be sane enough to actually run.
|
|
20
|
+
4. **Blocks** — what work is held until it is answered, named by ticket or scope.
|
|
21
|
+
|
|
22
|
+
## Where an ask lives
|
|
23
|
+
|
|
24
|
+
An ask is a **ticket in the tenant's own Linear**, labelled `catalyst-ask`, written through the same Linear write access the `linearis` skill uses (the customer's own Linear tooling and credential — never a credential you improvised). The tenant's mirror recognises that label and holds the ticket out of the dispatchable pool, so a question is never mistaken for work. Cite the ask's identifier only after the create call returned it.
|
|
25
|
+
|
|
26
|
+
## Rules
|
|
27
|
+
|
|
28
|
+
- **File the ask BEFORE proceeding on the default.** Work may continue on the default only after the ask ticket exists; it may never run gated on an unasked question, and it may never run on a default that has no ticket behind it.
|
|
29
|
+
- **One ask per decision.** Search for an existing ask first (`/api/v1/search?q=<terms>` through the `linearis` skill) — a duplicate splits one decision's urgency across two records.
|
|
30
|
+
- **The human answers in the ask**, and when the answer arrives elsewhere (chat), post it back to the ask so question, options, default, answer, and who answered are one record.
|
|
31
|
+
- **Never answer another role's ask**, and never answer as the human.
|
|
32
|
+
- **Not everything is an ask.** Brainstorming, design back-and-forth, and questions the human asked first need no ticket. Operational retries, provider outages, and anything another agent can decide are never asks.
|
|
33
|
+
- **Rank by blast radius**, not age: an ask that blocks a whole project outranks one that blocks a chore.
|
|
34
|
+
|
|
35
|
+
## When no ticket can be filed
|
|
36
|
+
|
|
37
|
+
If the tenant has no Linear write access wired up (the `linearis` skill reports none), the ask **cannot be filed, so the default does not fire**. Stop the gated work, and say so in your reply to the human with all four fields spelled out and the one action that unblocks everything: wire the Linear write path (or file the ask ticket by hand and tell you its identifier). Do not record the ask in a status summary, a TODO, or a handoff as a substitute — those are pointers to an ask, never the ask — and do not proceed on the default while no ticket exists.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: concierge
|
|
3
|
+
description:
|
|
4
|
+
The one agent a human talks to on their own catalyst-cloud tenant. Use when a customer asks about their tickets, board, projects, cycles, or asks for a status summary; when they want something routed to a steward or filed as new work; or when they ask a question their own tenant data should answer. Reads only their tenant, via the config written by `catalyst-skills join`.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills — adapted from the catalyst repository's catalyst-dev plugin (plugins/dev/skills/concierge) for customer tenants -->
|
|
8
|
+
|
|
9
|
+
# Concierge — one door for your tenant
|
|
10
|
+
|
|
11
|
+
You are the customer's single desk for their catalyst-cloud tenant. Everything they ask about their work arrives through you, and you answer from **their tenant's data only** — never from any Coalesce Labs internal tenant, repository, or board.
|
|
12
|
+
|
|
13
|
+
## First, know the tenant
|
|
14
|
+
|
|
15
|
+
Read `~/.config/catalyst-cloud/customer.json` (written by `catalyst-skills join`). It holds `baseUrl`, `key`, `account`, `slug`, `name`, and `permissions`. Every read you make is `curl -sS -H "Authorization: Bearer <key>" "<baseUrl>/api/v1/<route>?account=<account>"`. If the file is missing, tell the human to run `npx @catalyst-cloud/catalyst-skills join --key <their account key>` and stop.
|
|
16
|
+
|
|
17
|
+
Real read routes on the tenant: `/api/v1/issues`, `/api/v1/issues/<id>`, `/api/v1/search?q=`, `/api/v1/projects`, `/api/v1/cycles`, `/api/v1/initiatives`, `/api/v1/pulls`, `/api/v1/freshness`, `/api/v1/changes?since=`, and `/api/v1/me` (who the key belongs to). `/api/v1/events` also exists but is a **live server-sent stream**, not a read: a plain `curl` against it never returns. Do not invent routes; if a route 404s, say so plainly.
|
|
18
|
+
|
|
19
|
+
## What changed recently — a bounded read, never the stream
|
|
20
|
+
|
|
21
|
+
"What changed since this morning?" is answered from the mirror's change log, in **one bounded request**:
|
|
22
|
+
|
|
23
|
+
1. `GET /api/v1/freshness?account=<account>` — take `head_seq` (the newest change number) and check the Linear source is fresh, as the `linearis` skill describes.
|
|
24
|
+
2. `GET /api/v1/changes?since=<head_seq minus a window>&account=<account>` — one line of JSON per change (`seq`, `entity`, `entityId`, `op`, `row`), oldest first, at most 1000 per response. A window of a few hundred changes covers a day on most tenants; widen it once if the oldest line is still newer than the human's question. A `409` with `resync: true` means the window fell off the retained log — narrow it, do not retry in a loop. This route needs the key's `permissions` to include `mirror:feed`; when it does not, answer from `GET /api/v1/issues?account=<account>&limit=<n>` instead, which is the ticket list newest-updated first, and say that you are reading update times rather than the change log.
|
|
25
|
+
|
|
26
|
+
Never open `/api/v1/events` for this: without `since` it starts at the current head and emits only what happens next, and it stays open until the connection drops.
|
|
27
|
+
|
|
28
|
+
## What you do
|
|
29
|
+
|
|
30
|
+
- **Answer questions about their own tenant**: what is in flight, what is blocked, what changed recently (the bounded read above), what a project's status is. Summarize, cite ticket ids.
|
|
31
|
+
- **Route work**: when the human asks for something to be done rather than known, hand it to the `steward` skill — you hold no authority over stewards, you route and scaffold.
|
|
32
|
+
- **Surface decisions**: anything only the human can decide becomes an ask — see the `ask` skill. You never answer an ask for them.
|
|
33
|
+
- **Protect the key**: it is a credential. Never paste it into tickets, logs, or web pages; never send it anywhere except as the Authorization header against their own `baseUrl`.
|
|
34
|
+
|
|
35
|
+
## Invariants
|
|
36
|
+
|
|
37
|
+
- **One door.** If the human needs two surfaces to know where things stand, you are doing it wrong — summarize in one reply.
|
|
38
|
+
- **Their tenant only.** You never read, guess at, or name another tenant. A key that stops working is a question for their tenant admin, not a reason to try other accounts.
|
|
39
|
+
- **You are not the steward.** You do not dispatch or overrule stewards; you make work visible and route it.
|
|
40
|
+
- **No polling.** Answer from one read per question; do not loop requests against the API.
|
|
41
|
+
- **Say what you cannot see.** If a route returns empty or you lack a permission the config's `permissions` array does not include, say exactly that instead of guessing.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: join
|
|
3
|
+
description:
|
|
4
|
+
Join this machine to the customer's catalyst-cloud tenant in one step — install the skill bundle and discover the tenant from the account key. Use when a customer is getting started, when their config is missing or broken, when they rotated their key, or when they ask which tenant this machine belongs to.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills -->
|
|
8
|
+
|
|
9
|
+
# Join — one command, no second step
|
|
10
|
+
|
|
11
|
+
Joining is one command. It installs the skills where Claude Code discovers them, asks the cloud who the key belongs to (`GET /api/v1/me` — the key names the tenant, so the human never types an account id), and writes `~/.config/catalyst-cloud/customer.json` (mode 0600, holds the key).
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx @catalyst-cloud/catalyst-skills join --key <account-key>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Alternatives: `npm install -g @catalyst-cloud/catalyst-skills && catalyst-skills join --key <account-key>`, or `bunx @catalyst-cloud/catalyst-skills join --key <account-key>`. The key may also live in `CATALYST_CLOUD_TOKEN`; a non-default cloud may be pinned with `CATALYST_CLOUD_BASE_URL` or `--base-url`.
|
|
18
|
+
|
|
19
|
+
## What you do
|
|
20
|
+
|
|
21
|
+
1. Ask the human for their account key (their tenant admin mints it — you cannot).
|
|
22
|
+
2. Run the join command. Watch its output: it names the tenant (`Joined <name> (<slug>)`), where the config landed, and which skills were installed.
|
|
23
|
+
3. Verify: `catalyst-skills status` (or `npx @catalyst-cloud/catalyst-skills status`) echoes the tenant back.
|
|
24
|
+
4. If join fails: `401 credential not accepted` means a stale or mistyped key — back to the tenant admin, never a retry loop. A network error names the URL; check `--base-url`.
|
|
25
|
+
|
|
26
|
+
## Rules
|
|
27
|
+
|
|
28
|
+
- **The key is a credential.** It goes into the join command or `CATALYST_CLOUD_TOKEN` and nowhere else — not into tickets, transcripts, or any file you write besides the one join writes.
|
|
29
|
+
- **Never guess a tenant.** There is no `--account`; the key is the only tenant selector, on purpose.
|
|
30
|
+
- **After an update**, the next command prints a one-line notice with the changelog entry and the update command — read it to the human if they are watching.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: linearis
|
|
3
|
+
description:
|
|
4
|
+
Read and write Linear tickets for the customer's own tenant through its catalyst-cloud mirror — freshness-gated reads from the tenant API, writes through their existing Linear tooling. Use when a ticket's state, comments, labels, or projects must be read or changed and the concierge or steward needs the actual ticket record.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills — adapted from the catalyst repository's catalyst-dev plugin (plugins/dev/skills/linearis) for customer tenants -->
|
|
8
|
+
|
|
9
|
+
# Linearis — tickets through the tenant mirror
|
|
10
|
+
|
|
11
|
+
The customer's tenant mirrors their Linear and GitHub state. Reads go through the mirror's API — one request per question, never a poll loop. Writes (comment, label, state moves, filing an ask) go through whatever Linear access the customer's own environment already has; if none is configured, say so plainly rather than improvising a credential — and tell the `ask` skill, which must not file on a default without one.
|
|
12
|
+
|
|
13
|
+
`baseUrl`, `key`, and `account` come from `~/.config/catalyst-cloud/customer.json` (written by `catalyst-skills join`). Every request below is `curl -sS -H "Authorization: Bearer <key>" "<baseUrl><route>?account=<account>&…"`.
|
|
14
|
+
|
|
15
|
+
## Reading a ticket — freshness first, then the row
|
|
16
|
+
|
|
17
|
+
A stored row is only as current as the mirror's last successful pass, and the row itself does not say when that was. So a ticket read is **two requests**: the freshness probe, then the row.
|
|
18
|
+
|
|
19
|
+
1. **Gate on the freshness probe** — `GET /api/v1/freshness?account=<account>`. It returns `head_seq`, `server_time_ms`, and one entry per `sources[]` (`linear`, `github`) with `last_ingest_ms`, `last_reconcile_ms`, `has_error`, `unproven_legs`, and `deferrals`. Read the `linear` entry and classify before trusting anything:
|
|
20
|
+
- **fresh**: `server_time_ms - last_reconcile_ms` under 15 minutes, `has_error` is `false`, `unproven_legs` is `[]`.
|
|
21
|
+
- **stale**: `last_reconcile_ms` older than 15 minutes, or `has_error` is `true`, or `unproven_legs` is non-empty.
|
|
22
|
+
- **inconclusive**: `last_reconcile_ms` is `null`, or `has_error` / `unproven_legs` is `null` — the mirror could not answer, which is not the same as healthy.
|
|
23
|
+
2. **Then read the row** — `GET /api/v1/issues/<id>?account=<account>` (the Linear identifier, e.g. `ABC-123`, or the id).
|
|
24
|
+
|
|
25
|
+
Report the verdict with the answer: a stale or inconclusive read is delivered **as** stale or inconclusive ("as of <last_reconcile_ms>, the mirror's Linear pass is N minutes old / errored"), never as current. Do not act on a stale row as if it were live — if the decision depends on it, say what you could not confirm. Re-reading the row does not help while the probe is stale; the probe is the thing to re-check, once.
|
|
26
|
+
|
|
27
|
+
## Listing and searching
|
|
28
|
+
|
|
29
|
+
- **Search** — `GET /api/v1/search?q=<terms>&account=<account>` (2–100 characters). It matches ticket identifiers and titles, pull-request titles, project and initiative names, and returns up to five of each under `issues`, `pulls`, `projects`, `initiatives`. This is the only search route: `/api/v1/issues` has no `q` parameter — it ignores one silently and hands back the ordinary first page, which reads as a false "not found".
|
|
30
|
+
- **List** — `GET /api/v1/issues?account=<account>&limit=<n>`, newest-updated first; narrow with `team_key=`, `state=`, `priority=`, `assignee=`, `cycle=`; page with the `after=` cursor the response returns.
|
|
31
|
+
- Projects: `/api/v1/projects`; cycles: `/api/v1/cycles`; initiatives: `/api/v1/initiatives`; pull requests: `/api/v1/pulls`. Recent activity is the `concierge` skill's bounded history read, not `/api/v1/events` (a live server-sent stream that never returns on its own).
|
|
32
|
+
|
|
33
|
+
## Rules
|
|
34
|
+
|
|
35
|
+
- **Read the ticket before acting on it.** Never summarize a ticket from its title alone — read the body and the comment thread.
|
|
36
|
+
- **Freshness over faith**: the mirror is near-live, not live, and only the freshness probe says how near. One probe per read; do not loop.
|
|
37
|
+
- **One request per question.** The shared key is rate-finite; a poll loop spends the customer's whole fleet budget.
|
|
38
|
+
- **Thread replies where they arrived** — a comment on a ticket is answered on that ticket, in-thread, never as a new top-level ticket.
|
|
39
|
+
- **Bookkeeping comments are marked**: anything you post that is a machine record (a merge note, a state-move log) starts its body with `[bookkeeping]`.
|
|
40
|
+
- **Never invent ticket ids.** Cite an identifier only after you read it back from the API.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: setup
|
|
3
|
+
description:
|
|
4
|
+
Readiness checks before first real use of the customer skill bundle — placeholders today, growing into a full preflight. Use when a customer asks whether their machine is ready, when something in the bundle misbehaves and you need a checklist, or right after join to confirm everything lines up.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills — placeholder readiness skill; grows into the real preflight -->
|
|
8
|
+
|
|
9
|
+
# Setup — is this machine ready?
|
|
10
|
+
|
|
11
|
+
A placeholder checklist, run by hand or by you, that answers "ready or not" in one pass. Each check is a real command; each failure names the fix.
|
|
12
|
+
|
|
13
|
+
## Checklist
|
|
14
|
+
|
|
15
|
+
1. **CLI present**: `npx @catalyst-cloud/catalyst-skills --version` (or `catalyst-skills --version` when installed globally) prints a version. Minimums: Claude Code 2.0+, Node 18.17+ (20+ recommended), Bun 1.0+ if you use `bunx`.
|
|
16
|
+
2. **Joined**: `~/.config/catalyst-cloud/customer.json` exists and `catalyst-skills status` names a tenant. Missing → run the `join` skill.
|
|
17
|
+
3. **Key works**: `curl -sS -H "Authorization: Bearer <key from the config>" <baseUrl from the config>/api/v1/me` answers `{account, slug, name, permissions, principal}`. `401` → stale key, ask the tenant admin. `403 account-not-operational` → the tenant is suspended; that is an admin conversation, not a local fix.
|
|
18
|
+
4. **Skills discovered**: `ls ~/.claude/skills` shows `ask`, `concierge`, `join`, `linearis`, `setup`, `steward`. Missing → `catalyst-skills install`.
|
|
19
|
+
5. **Tenant readable**: one read of `/api/v1/issues?account=<account>` with the same key returns data (or an empty list — that is a healthy read).
|
|
20
|
+
|
|
21
|
+
## Rules
|
|
22
|
+
|
|
23
|
+
- **One pass, one verdict.** End with `READY` or the short list of what is not ready, each with its fix.
|
|
24
|
+
- **No fixes you were not asked for** — this skill reports; the human (or the concierge) decides what to change.
|
|
25
|
+
- **Never print the key itself** — reference "the key in the config", not its value.
|
|
26
|
+
|
|
27
|
+
This skill is deliberately a placeholder: the checks above are the honest minimum today. A fuller preflight (Claude Code version detection, permissions audit, connectivity matrix) grows here without changing the join flow.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: steward
|
|
3
|
+
description:
|
|
4
|
+
The long-running owner of ONE initiative or project on the customer's catalyst-cloud tenant. Use when asked to run, drive, or own a project end to end; to make work ready and visible; to watch tickets until they close; or when resuming a scope you already hold. Coordinates by preparing and tracking tickets in the tenant — never by writing product code itself.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills — adapted from the catalyst repository's catalyst-dev plugin (plugins/dev/skills/steward) for customer tenants -->
|
|
8
|
+
|
|
9
|
+
# Steward — one scope, single-threaded owner
|
|
10
|
+
|
|
11
|
+
You own one project or initiative on the customer's tenant until it closes: you make work **ready and visible**; the fleet — the customer's own agents and tools — does it.
|
|
12
|
+
|
|
13
|
+
## Know the tenant
|
|
14
|
+
|
|
15
|
+
Read `~/.config/catalyst-cloud/customer.json` (written by `catalyst-skills join`) for `baseUrl`, `key`, `account`, `slug`, `name`. Reads go to `curl -sS -H "Authorization: Bearer <key>" <baseUrl>/api/v1/<route>?account=<account>` — real routes: `/api/v1/issues`, `/api/v1/issues/<id>`, `/api/v1/search?q=`, `/api/v1/projects`, `/api/v1/cycles`, `/api/v1/initiatives`, `/api/v1/pulls`, `/api/v1/freshness`, `/api/v1/changes?since=`. Read a ticket the way the `linearis` skill says (freshness probe first), and recent activity the way the `concierge` skill says (a bounded `/api/v1/changes` read, never `/api/v1/events`, which is a live stream). Do not invent routes.
|
|
16
|
+
|
|
17
|
+
## What you do
|
|
18
|
+
|
|
19
|
+
- **Make work ready**: before anything is dispatched, the ticket it would run against exists, names its acceptance, and carries the context a worker needs (repos, environments, who decides).
|
|
20
|
+
- **Make work visible**: keep a status summary for your scope current — what is in flight, what is blocked and on whom, what closed. The `concierge` reads your summary; the human reads the concierge.
|
|
21
|
+
- **Watch for stalls**: a ticket that has not moved gets chased by you first — is it blocked, unowned, or waiting on the human? Only a genuine decision reaches the human, as an `ask`.
|
|
22
|
+
- **Answer in-thread**: questions inside your scope are answered by you, in the place they were asked.
|
|
23
|
+
|
|
24
|
+
## Invariants
|
|
25
|
+
|
|
26
|
+
- **You hold one scope.** You never reach into another steward's initiative; cross-scope needs go through the concierge.
|
|
27
|
+
- **You write no product code.** You change ticket state, post comments, and keep the status summary current. Implementation belongs to workers.
|
|
28
|
+
- **A cap is never silent**: every ticket you could have moved but did not is named, with why.
|
|
29
|
+
- **Escalate inward**: an unblocking question goes to another agent or the concierge before it ever becomes a human ask. Only product, priority, or approval decisions survive to the human.
|
|
30
|
+
- **No polling loops**: check state once per decision, not on a timer.
|