@catalyst-cloud/cli 0.8.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 +75 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/bin/catalyst-skills.js +8 -0
- package/bin/catalyst.js +5 -0
- package/bin/launch.js +154 -0
- package/dist/args.js +280 -0
- package/dist/ask.js +161 -0
- package/dist/browser.js +20 -0
- package/dist/cli.js +397 -0
- package/dist/config.js +241 -0
- package/dist/contract-types.js +4 -0
- package/dist/contract.js +184 -0
- package/dist/detach.js +10 -0
- package/dist/environment.js +207 -0
- package/dist/errors.js +27 -0
- package/dist/events.js +106 -0
- package/dist/execution.js +451 -0
- package/dist/oauth.js +300 -0
- package/dist/pagination.js +76 -0
- package/dist/prompt.js +35 -0
- package/dist/published.js +79 -0
- package/dist/query.js +248 -0
- package/dist/ready.js +380 -0
- package/dist/release.js +142 -0
- package/dist/replica.js +614 -0
- package/dist/runtime-store.js +135 -0
- package/dist/runtime-verb.js +66 -0
- package/dist/runtime.js +87 -0
- package/dist/sdk.js +29 -0
- package/dist/secret.js +190 -0
- package/dist/semver.js +18 -0
- package/dist/skill-shape.js +189 -0
- package/dist/skills.js +129 -0
- package/dist/transport.js +205 -0
- package/dist/ts-deps-loader.js +113 -0
- package/dist/watch/consumer.js +141 -0
- package/dist/watch/cursor-file.js +62 -0
- package/dist/watch.js +175 -0
- package/dist/write.js +224 -0
- package/package.json +60 -0
- package/skills/catalyst-github/SKILL.md +35 -0
- package/skills/catalyst-github/agents/openai.yaml +6 -0
- package/skills/catalyst-github/agents/portability.yaml +4 -0
- package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
- package/skills/catalyst-github/scripts/lib/cli.mjs +103 -0
- package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
- package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
- package/skills/catalyst-linear/SKILL.md +43 -0
- package/skills/catalyst-linear/agents/openai.yaml +6 -0
- package/skills/catalyst-linear/agents/portability.yaml +5 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +52 -0
- package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
- package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
- package/skills/catalyst-linear/scripts/comment.mjs +59 -0
- package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
- package/skills/catalyst-linear/scripts/label.mjs +48 -0
- package/skills/catalyst-linear/scripts/lib/cli.mjs +164 -0
- package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-linear/scripts/move.mjs +41 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
- package/skills/catalyst-linear/scripts/search.mjs +49 -0
- package/skills/catalyst-onboard/SKILL.md +57 -0
- package/skills/catalyst-onboard/agents/openai.yaml +6 -0
- package/skills/catalyst-onboard/agents/portability.yaml +5 -0
- package/skills/catalyst-onboard/references/declaring-a-repository.md +23 -0
- package/skills/catalyst-onboard/references/skill-sources.md +35 -0
- package/skills/catalyst-onboard/references/the-one-path.md +149 -0
- package/skills/catalyst-onboard/references/what-a-phase-needs.md +46 -0
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
- package/skills/catalyst-onboard/references/who-fixes-what.md +44 -0
- package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
- package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +345 -0
- package/skills/catalyst-setup/SKILL.md +36 -0
- package/skills/catalyst-setup/agents/openai.yaml +6 -0
- package/skills/catalyst-setup/agents/portability.yaml +4 -0
- package/skills/catalyst-setup/references/what-each-check-means.md +88 -0
- package/skills/catalyst-setup/scripts/check.mjs +75 -0
- package/skills/catalyst-setup/scripts/lib/cli.mjs +103 -0
- package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
- package/skills/connect-me/SKILL.md +63 -0
- package/skills/connect-me/agents/openai.yaml +6 -0
- package/skills/connect-me/agents/portability.yaml +5 -0
- package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
- package/skills/connect-me/scripts/lib/cli.mjs +185 -0
- package/skills/connect-me/scripts/lib/credential.mjs +29 -0
- package/skills/connect-me/scripts/verify-connection.mjs +68 -0
- package/skills/how-catalyst-works/SKILL.md +43 -0
- package/skills/how-catalyst-works/agents/openai.yaml +6 -0
- package/skills/how-catalyst-works/agents/portability.yaml +4 -0
- package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
- package/skills/how-catalyst-works/references/stages-and-mapping.md +56 -0
- package/skills/how-catalyst-works/references/the-ladder.md +41 -0
- package/skills/how-catalyst-works/references/what-catalyst-is.md +30 -0
- package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
- package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
- package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
- package/skills/how-catalyst-works/scripts/lib/cli.mjs +164 -0
- package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
- package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
- package/skills/how-catalyst-works/scripts/whats-running.mjs +65 -0
- package/skills/run-this-project/SKILL.md +45 -0
- package/skills/run-this-project/agents/openai.yaml +6 -0
- package/skills/run-this-project/agents/portability.yaml +5 -0
- package/skills/run-this-project/assets/stall-policy.json +15 -0
- package/skills/run-this-project/references/making-work-ready.md +60 -0
- package/skills/run-this-project/references/reacting-to-events.md +76 -0
- package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
- package/skills/run-this-project/scripts/lib/cli.mjs +185 -0
- package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
- package/skills/run-this-project/scripts/make-ready.mjs +64 -0
- package/skills/run-this-project/scripts/scope-status.mjs +0 -0
- package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
- package/skills/unstick/SKILL.md +41 -0
- package/skills/unstick/agents/openai.yaml +6 -0
- package/skills/unstick/agents/portability.yaml +5 -0
- package/skills/unstick/references/playbook.md +51 -0
- package/skills/unstick/scripts/lib/cli.mjs +135 -0
- package/skills/unstick/scripts/lib/credential.mjs +29 -0
- package/skills/unstick/scripts/unstick.mjs +57 -0
- package/skills/what-needs-me/SKILL.md +41 -0
- package/skills/what-needs-me/agents/openai.yaml +6 -0
- package/skills/what-needs-me/agents/portability.yaml +5 -0
- package/skills/what-needs-me/references/raising-a-decision.md +41 -0
- package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
- package/skills/what-needs-me/references/settling-an-answer.md +37 -0
- package/skills/what-needs-me/scripts/inbox.mjs +56 -0
- package/skills/what-needs-me/scripts/lib/cli.mjs +135 -0
- package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
- package/skills/what-needs-me/scripts/raise.mjs +53 -0
- package/skills/what-needs-me/scripts/settle.mjs +73 -0
- package/skills/whats-happening/SKILL.md +43 -0
- package/skills/whats-happening/agents/openai.yaml +6 -0
- package/skills/whats-happening/agents/portability.yaml +4 -0
- package/skills/whats-happening/assets/status-reply.json +77 -0
- package/skills/whats-happening/references/reading-the-board.md +43 -0
- package/skills/whats-happening/references/reprioritising.md +37 -0
- package/skills/whats-happening/references/routing-work.md +36 -0
- package/skills/whats-happening/references/status-reply.md +34 -0
- package/skills/whats-happening/references/why-is-it-stuck.md +62 -0
- package/skills/whats-happening/scripts/explain.mjs +28 -0
- package/skills/whats-happening/scripts/lib/cli.mjs +135 -0
- package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
- package/skills/whats-happening/scripts/snapshot.mjs +149 -0
- package/vendor/README.md +9 -0
- package/vendor/paths/index.d.ts +85 -0
- package/vendor/paths/index.js +148 -0
- package/vendor/paths/legacy-installer.d.ts +36 -0
- package/vendor/paths/legacy-installer.js +154 -0
- package/vendor/paths/node.d.ts +18 -0
- package/vendor/paths/node.js +102 -0
- package/vendor/paths/provenance.json +17 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/cli.mjs — the one way a skill script reaches Catalyst Cloud: spawn the catalyst-skills CLI.
|
|
3
|
+
// The CLI holds the SDK and the key; this file holds neither. It reads customer.json only
|
|
4
|
+
// to learn where the CLI lives, and it exits 2 with one line when the machine is not connected.
|
|
5
|
+
// Run any script beside this one with --help; this file is a library and is never run directly.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
|
|
10
|
+
|
|
11
|
+
export const PACKAGE = "@catalyst-cloud/catalyst-skills";
|
|
12
|
+
export const NOT_CONFIGURED_EXIT = 2;
|
|
13
|
+
|
|
14
|
+
export function configPath() {
|
|
15
|
+
const home = process.env.CATALYST_SKILLS_HOME ?? process.env.HOME ?? "/";
|
|
16
|
+
return join(home, ".config", "catalyst-cloud", "customer.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The connected-machine config, or exit 2 with the one line that says how to connect. */
|
|
20
|
+
export function loadConfig() {
|
|
21
|
+
const path = configPath();
|
|
22
|
+
if (!existsSync(path)) notConfigured(`no config at ${path}`);
|
|
23
|
+
try {
|
|
24
|
+
const cfg = JSON.parse(readFileSync(path, "utf8"));
|
|
25
|
+
if (!hasCredential(cfg)) notConfigured(`config at ${path} has no key or login`);
|
|
26
|
+
return cfg;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (err && err.exitCode === NOT_CONFIGURED_EXIT) throw err;
|
|
29
|
+
notConfigured(`config at ${path} is unreadable`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function notConfigured(why) {
|
|
34
|
+
process.stderr.write(`not connected (${why}) — run: ${CONNECT_COMMAND}\n`);
|
|
35
|
+
process.exit(NOT_CONFIGURED_EXIT);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run one catalyst-skills verb and return {code, stdout, stderr}. Uses the CLI path the login
|
|
40
|
+
* recorded when it still exists, else `npx <package>`. A CLI exit of 2 with a "not joined" or
|
|
41
|
+
* "not connected" line is turned into this script's own exit 2, so every caller sees one contract.
|
|
42
|
+
*/
|
|
43
|
+
export function runCli(args, opts = {}) {
|
|
44
|
+
const cfg = loadConfig();
|
|
45
|
+
const useRecorded = typeof cfg.cliPath === "string" && existsSync(cfg.cliPath);
|
|
46
|
+
const cmd = useRecorded ? process.execPath : "npx";
|
|
47
|
+
const argv = useRecorded ? [cfg.cliPath, ...args] : [PACKAGE, ...args];
|
|
48
|
+
const res = spawnSync(cmd, argv, {
|
|
49
|
+
input: opts.stdin,
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
52
|
+
shell: !useRecorded && process.platform === "win32",
|
|
53
|
+
env: process.env,
|
|
54
|
+
});
|
|
55
|
+
if (res.error) {
|
|
56
|
+
process.stderr.write(`could not run ${cmd}: ${res.error.message}\n`);
|
|
57
|
+
process.exit(NOT_CONFIGURED_EXIT);
|
|
58
|
+
}
|
|
59
|
+
const stdout = res.stdout ?? "";
|
|
60
|
+
const stderr = res.stderr ?? "";
|
|
61
|
+
if (res.status === NOT_CONFIGURED_EXIT && /not (joined|connected|configured)/i.test(stderr)) {
|
|
62
|
+
process.stderr.write(stderr);
|
|
63
|
+
process.exit(NOT_CONFIGURED_EXIT);
|
|
64
|
+
}
|
|
65
|
+
return { code: res.status ?? 1, stdout, stderr };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Run a verb and require success; on failure print the CLI's own stderr and exit with its code (or 1). */
|
|
69
|
+
export function mustRun(args, opts = {}) {
|
|
70
|
+
const r = runCli(args, opts);
|
|
71
|
+
if (r.code !== 0) {
|
|
72
|
+
if (r.stderr) process.stderr.write(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
|
|
73
|
+
if (r.stdout && !opts.quiet) process.stdout.write(r.stdout.endsWith("\n") ? r.stdout : `${r.stdout}\n`);
|
|
74
|
+
process.exit(r.code === 0 ? 1 : r.code);
|
|
75
|
+
}
|
|
76
|
+
return r;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Parse the CLI's --json stdout; a non-JSON answer is a failed check, exit 1. */
|
|
80
|
+
export function parseJson(stdout, what = "output") {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(stdout.trim());
|
|
83
|
+
} catch {
|
|
84
|
+
process.stderr.write(`could not parse ${what} as JSON: ${stdout.trim().slice(0, 200)}\n`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Minimal flag parsing for the scripts: --name value, --name=value, --flag, repeated flags collect. */
|
|
90
|
+
export function parseFlags(argv, spec) {
|
|
91
|
+
const flags = {};
|
|
92
|
+
const positionals = [];
|
|
93
|
+
for (let i = 0; i < argv.length; i++) {
|
|
94
|
+
const a = argv[i];
|
|
95
|
+
if (a === "--help" || a === "-h") return { help: true, flags, positionals };
|
|
96
|
+
if (a.startsWith("--")) {
|
|
97
|
+
let name = a.slice(2);
|
|
98
|
+
let value;
|
|
99
|
+
const eq = name.indexOf("=");
|
|
100
|
+
if (eq !== -1) {
|
|
101
|
+
value = name.slice(eq + 1);
|
|
102
|
+
name = name.slice(0, eq);
|
|
103
|
+
}
|
|
104
|
+
const s = spec[name];
|
|
105
|
+
if (!s) {
|
|
106
|
+
process.stderr.write(`unknown option --${name} (try --help)\n`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
if (s.value) {
|
|
110
|
+
if (value === undefined) value = argv[++i];
|
|
111
|
+
if (value === undefined || value === "") {
|
|
112
|
+
process.stderr.write(`--${name} needs a value\n`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
if (s.repeat) (flags[name] ??= []).push(value);
|
|
116
|
+
else flags[name] = value;
|
|
117
|
+
} else flags[name] = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
positionals.push(a);
|
|
121
|
+
}
|
|
122
|
+
return { help: false, flags, positionals };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function printHelp(usage, spec, notes = []) {
|
|
126
|
+
const lines = [`Usage: ${usage}`, ""];
|
|
127
|
+
const names = Object.keys(spec);
|
|
128
|
+
if (names.length) {
|
|
129
|
+
lines.push("Options:");
|
|
130
|
+
for (const n of names) lines.push(` --${n}${spec[n].value ? " <value>" : ""} ${spec[n].help}`);
|
|
131
|
+
lines.push("");
|
|
132
|
+
}
|
|
133
|
+
lines.push(...notes, "", "Exit codes: 0 ok · 1 the check failed or the arguments were wrong · 2 this machine is not connected to a tenant");
|
|
134
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
135
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/credential.mjs — is this machine connected? The ONE place a skill script decides it, vendored
|
|
3
|
+
// byte-identical into every skill's scripts/lib/ from skill-lib/credential.mjs at the package root
|
|
4
|
+
// (`npm run skill-lib:sync`; a test fails on any drift). Skills install one directory at a time, so
|
|
5
|
+
// each carries its own copy. This file is a library — run a sibling script with --help for usage.
|
|
6
|
+
//
|
|
7
|
+
// customer.json carries exactly one credential: a personal key (`key`), or the keyless login's
|
|
8
|
+
// session (`auth`, the recommended rail). A script never reads either for its value: it spawns the
|
|
9
|
+
// catalyst-skills CLI, which authenticates with whichever is present and refreshes a login's token
|
|
10
|
+
// itself. A new credential kind lands here, once.
|
|
11
|
+
|
|
12
|
+
/** The command that connects this machine, as every not-connected line names it. */
|
|
13
|
+
export const CONNECT_COMMAND =
|
|
14
|
+
"npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)";
|
|
15
|
+
|
|
16
|
+
/** True when `cfg` (parsed customer.json) holds a usable credential of either kind. Never throws. */
|
|
17
|
+
export function hasCredential(cfg) {
|
|
18
|
+
if (cfg === null || typeof cfg !== "object") return false;
|
|
19
|
+
const key = cfg["key"];
|
|
20
|
+
if (typeof key === "string" && key !== "") return true;
|
|
21
|
+
const login = cfg["auth"];
|
|
22
|
+
return (
|
|
23
|
+
login !== null &&
|
|
24
|
+
typeof login === "object" &&
|
|
25
|
+
login["kind"] === "oauth" &&
|
|
26
|
+
typeof login["refreshToken"] === "string" &&
|
|
27
|
+
login["refreshToken"] !== ""
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// unstick.mjs — "why is this stuck, and can it be released?" in one JSON document. Runs
|
|
3
|
+
// `catalyst-skills explain`, `explain --history` and a dry-run `release` for one ticket (or a dry-run
|
|
4
|
+
// class release for one team), and only with --because runs the real release. Reaches the cloud only
|
|
5
|
+
// by spawning the CLI.
|
|
6
|
+
import { mustRun, parseFlags, parseJson, printHelp, runCli } from "./lib/cli.mjs";
|
|
7
|
+
|
|
8
|
+
const SPEC = {
|
|
9
|
+
because: { value: true, help: "what changed since the ticket was held; runs the real release after the preview" },
|
|
10
|
+
"retry-unchanged": { value: false, help: "release even though nothing the cloud can see changed (say what did in --because)" },
|
|
11
|
+
class: { value: true, help: "release every ticket on --team parked under this failure class" },
|
|
12
|
+
team: { value: true, help: "with --class: the team key" },
|
|
13
|
+
limit: { value: true, help: "with --class: at most this many tickets (the cloud caps it at 25)" },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const { help, flags, positionals } = parseFlags(process.argv.slice(2), SPEC);
|
|
17
|
+
const ticket = positionals[0];
|
|
18
|
+
const byClass = typeof flags.class === "string";
|
|
19
|
+
if (help || (!ticket && !byClass) || (byClass && (ticket || typeof flags.team !== "string"))) {
|
|
20
|
+
printHelp("node scripts/unstick.mjs <ticket> [--because <text>] [--retry-unchanged] [--help] | --class <c> --team <K> [--because <text>] [--retry-unchanged] [--limit N] [--help]", SPEC, [
|
|
21
|
+
"Without --because it changes nothing: it prints the explanation, the history (every governor holding the ticket",
|
|
22
|
+
"and past releases) and a dry-run release. With --because it then runs the real release. Read references/playbook.md",
|
|
23
|
+
"before passing --because. A refused release exits 1 with the refusals in the document.",
|
|
24
|
+
]);
|
|
25
|
+
process.exit(help ? 0 : 1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const releaseArgs = byClass ? ["release", "--class", flags.class, "--team", flags.team] : ["release", ticket];
|
|
29
|
+
if (flags["retry-unchanged"]) releaseArgs.push("--retry-unchanged");
|
|
30
|
+
if (byClass && typeof flags.limit === "string") releaseArgs.push("--limit", flags.limit);
|
|
31
|
+
|
|
32
|
+
const out = {};
|
|
33
|
+
if (!byClass) {
|
|
34
|
+
out.explain = parseJson(mustRun(["explain", ticket, "--json"]).stdout, "explain");
|
|
35
|
+
out.history = parseJson(mustRun(["explain", ticket, "--history", "--json"]).stdout, "history");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const preview = runCli([...releaseArgs, "--dry-run", "--json"]);
|
|
39
|
+
if (preview.code !== 0 && preview.code !== 1) {
|
|
40
|
+
process.stderr.write(preview.stderr);
|
|
41
|
+
process.exit(preview.code);
|
|
42
|
+
}
|
|
43
|
+
out.preview = parseJson(preview.stdout, "the dry-run release");
|
|
44
|
+
|
|
45
|
+
let exit = 0;
|
|
46
|
+
if (typeof flags.because === "string") {
|
|
47
|
+
const real = runCli([...releaseArgs, "--because", flags.because, "--json"]);
|
|
48
|
+
if (real.code !== 0 && real.code !== 1) {
|
|
49
|
+
process.stderr.write(real.stderr);
|
|
50
|
+
process.exit(real.code);
|
|
51
|
+
}
|
|
52
|
+
out.release = parseJson(real.stdout, "the release");
|
|
53
|
+
exit = real.code;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
57
|
+
process.exit(exit);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: what-needs-me
|
|
3
|
+
description: >-
|
|
4
|
+
The human's decision inbox on a Catalyst Cloud tenant, and the one way an agent raises a decision on their behalf. Use when the person asks "what needs me?", "what am I blocking?", or when active work is gated on a choice only they can make. Lists open asks ranked by what each answer releases, files an ask through the cloud's ask route with the tenant's own template, and records the answer so the held work releases. Never answers as the human, never duplicates an open ask.
|
|
5
|
+
disable-model-invocation: true
|
|
6
|
+
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
7
|
+
---
|
|
8
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.8.0 — written in this repository for customer tenants -->
|
|
9
|
+
|
|
10
|
+
# What needs me
|
|
11
|
+
|
|
12
|
+
An ask is one decision only the human can make, filed as a ticket in their own Linear so that question, options, default, answer and who answered are one record. This skill reads that inbox ranked by blast radius, raises a new ask when work is gated, and settles the answer so the held work releases. The cloud renders the ask body from the tenant's template, applies the ask labels and creates the blocking relations; you pass fields, never headings.
|
|
13
|
+
|
|
14
|
+
## Run first
|
|
15
|
+
|
|
16
|
+
Scripts are run, never read. Each prints `--help`; exit 2 means this machine is not connected (run the `connect-me` skill), exit 1 means the check itself failed or an argument was missing.
|
|
17
|
+
|
|
18
|
+
- `node scripts/inbox.mjs --help` — the open asks assigned to the connected person, ranked by the priority-weighted work each holds; `--anyone` for the whole tenant; `--json` for `{scope, asks}`.
|
|
19
|
+
- `node scripts/raise.mjs --help` — file one decision: `--team`, `--title`, `--option` (repeated), `--default`, and `--blocks` (repeated) or `--nothing-to-block`.
|
|
20
|
+
- `node scripts/settle.mjs --help` — record the answering comment on an ask and post a release note on every ticket it held; `--close` moves the ask to the done slot.
|
|
21
|
+
|
|
22
|
+
## Load on demand
|
|
23
|
+
|
|
24
|
+
| when | read |
|
|
25
|
+
| -- | -- |
|
|
26
|
+
| about to file an ask, or unsure whether something is one | `references/raising-a-decision.md` |
|
|
27
|
+
| presenting the inbox, or the person asks what "waiting on me" means | `references/reading-the-inbox.md` |
|
|
28
|
+
| an answer arrived, anywhere | `references/settling-an-answer.md` |
|
|
29
|
+
|
|
30
|
+
The `catalyst-linear` skill posts the comment that carries an answer given in chat and reads an ask's thread; `whats-happening` explains a held ticket that stays excluded after the answer.
|
|
31
|
+
|
|
32
|
+
## Rules
|
|
33
|
+
|
|
34
|
+
- **File before proceeding on the default.** Work may run on a default only after the ask exists; a default with no ticket behind it is a guess, not a decision.
|
|
35
|
+
- **One ask per decision.** Read the inbox first; attach new held tickets to an open ask rather than filing a twin. Duplicates split one decision's urgency across rows.
|
|
36
|
+
- **Every ask names what it blocks.** The ranking the human sees is by held work weighted by priority, never by age; an ask that holds nothing does not reach Waiting on me unless you chose `--nothing-to-block` on purpose.
|
|
37
|
+
- **Never answer as the human.** You do not pick an option, close an ask on their behalf, or post in their voice. Their answer is quoted into the ask as the app actor and recorded with its comment id.
|
|
38
|
+
- **A free-text reply is recorded, never interpreted into an option.** If it does not answer the question, ask the one clarification in the thread and leave the ask open.
|
|
39
|
+
- **Escalate inward.** A project owner raises asks for its scope; the desk raises what has no owner; a single stuck ticket or a provider outage is never an ask.
|
|
40
|
+
- **Tenant facts come from the contract.** Team keys, label names, the option cap and the template are read live by the CLI; never restate them.
|
|
41
|
+
- **Cite an identifier only after the create call returned it.**
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "What needs me"
|
|
3
|
+
short_description: "Your decision inbox on Catalyst Cloud, ranked by what each answer releases; raises and settles asks"
|
|
4
|
+
default_prompt: "Use $what-needs-me to show me what is waiting on my decision."
|
|
5
|
+
policy:
|
|
6
|
+
allow_implicit_invocation: false
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Raising a decision
|
|
2
|
+
|
|
3
|
+
This reference restates invariants: what an ask is, when it is filed, and what it must carry. The body text, the headings, the option format and the cap on options are tenant facts served by the contract's `askTemplate` block and rendered by the cloud itself; `node scripts/raise.mjs` passes fields and never composes a heading.
|
|
4
|
+
|
|
5
|
+
## What an ask is
|
|
6
|
+
|
|
7
|
+
An ask is one decision only the human can make, filed as a ticket in the tenant's own Linear so that the question, the options, the default, the answer and who answered are one record the next agent can read. The cloud labels it as an ask, excludes it from dispatch (a question is never work), and holds every ticket it blocks until it is answered. It appears in the human's Waiting-on-me view when it is assigned to them and blocks open work.
|
|
8
|
+
|
|
9
|
+
## When to file one
|
|
10
|
+
|
|
11
|
+
File an ask when active work is gated on a product call, a priority call between two things that cannot both go first, an approval, or an action only the human can physically take (a click in settings, a credential, a payment). File it **before** proceeding on the default, never after, and never only as a TODO line, a board row, a handoff note or a chat question. Those may point at the ask's identifier; they never replace it.
|
|
12
|
+
|
|
13
|
+
Do not file one for brainstorming, a design back-and-forth, a question the human asked first, a retry-or-abandon call a project owner can make, or a system-level failure (a provider down, out of capacity, rate-limited: that is one status line, and the affected tickets retry on their own).
|
|
14
|
+
|
|
15
|
+
## What it carries
|
|
16
|
+
|
|
17
|
+
1. **The question**, one sentence, as the title (`--title`).
|
|
18
|
+
2. **Context** the human needs to answer without opening anything else (`--context`), short.
|
|
19
|
+
3. **Options**, each one line, realistic, at most the number the contract allows (`--option`, repeated). The cloud letters and formats them.
|
|
20
|
+
4. **The default if silent** (`--default`): what proceeds and after how long. It must be sane enough to actually run. Note that nothing in the cloud applies the default on a timer; the raising agent applies it, after the ask exists, and records that it did.
|
|
21
|
+
5. **What it blocks** (`--blocks`, repeated): every ticket held until the answer lands. The cloud creates the blocking relations atomically with the ticket. An ask that holds nothing is refused unless you say `--nothing-to-block` on purpose, because an ask with no blocking relation never surfaces in Waiting on me and is indistinguishable from ordinary work.
|
|
22
|
+
|
|
23
|
+
`--ask-key` is an idempotency key: a re-run with the same key does not file a second ask.
|
|
24
|
+
|
|
25
|
+
## One ask per decision
|
|
26
|
+
|
|
27
|
+
Run `node scripts/inbox.mjs` first and read the titles. When the same decision is already open, attach the new held tickets to it rather than filing again (the `catalyst-linear` skill adds the relation or a comment naming them). Duplicates split one decision's urgency across several rows and sink it below trivia in the ranking.
|
|
28
|
+
|
|
29
|
+
## Who raises, and where
|
|
30
|
+
|
|
31
|
+
- A decision inside a project scope is raised by that project's owner; the desk raises what has no owner.
|
|
32
|
+
- The ask is filed on the team the held work belongs to (`--team`), from the contract's team list. An approvals team, when the contract names one, is where an approval with no natural team goes.
|
|
33
|
+
- Never raise an ask on someone else's behalf about their own scope, and never answer one for the human.
|
|
34
|
+
|
|
35
|
+
## After filing
|
|
36
|
+
|
|
37
|
+
Cite the identifier the script printed, and only that. Proceed on the default if the work allows it, and say so in the ask's thread (a bookkeeping comment) so the record shows the default was taken. When the answer arrives, `references/settling-an-answer.md`.
|
|
38
|
+
|
|
39
|
+
## Ranking
|
|
40
|
+
|
|
41
|
+
The human sees asks ranked by how much open work each holds, weighted by that work's priority (urgent counts most), never by age. That is why `--blocks` must be complete: an ask that names one held ticket when it really holds a project sinks below a chore.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Reading the inbox
|
|
2
|
+
|
|
3
|
+
This reference restates invariants: what "waiting on me" means, how declared and inferred asks differ, and how to present the queue. The label that marks an ask, its prefix, and the release label are the contract's `vocabulary`; `node scripts/inbox.mjs` reads them from the CLI and never from prose.
|
|
4
|
+
|
|
5
|
+
## What waiting-on-me means
|
|
6
|
+
|
|
7
|
+
The tenant's own Waiting-on-me view lists a ticket for a person when all four hold:
|
|
8
|
+
|
|
9
|
+
1. it is **assigned** to that person;
|
|
10
|
+
2. it has **no delegate** (a ticket delegated to an agent is the agent's, not the human's);
|
|
11
|
+
3. it is still **open**;
|
|
12
|
+
4. what it **blocks** reaches open work: at least one ticket it holds is itself not done or canceled.
|
|
13
|
+
|
|
14
|
+
A ticket that carries the ask marker label and is assigned to the person shows even with zero blocks. That is the **declared** ask. The four-part rule above is the **inferred** one, and the view offers both modes: asks only (the default) and everything the person is holding up, minus items an open unblock ask already covers.
|
|
15
|
+
|
|
16
|
+
So an ask that holds nothing is real, but the person may never see it in their own view. `inbox.mjs` lists such asks last and marked, so you can attach the work they should block.
|
|
17
|
+
|
|
18
|
+
## What the script reads
|
|
19
|
+
|
|
20
|
+
`catalyst-skills ask list` reads every page of the tenant's issues, not a windowed sample, so an empty list means nothing is assigned to you rather than that the read stopped early. It reads the open tickets carrying the team's ask label (by the label ids and prefix the contract serves), follows each one's blocking relations to the tickets it holds, drops held tickets that are already terminal, and scores the rest by priority weight: urgent 4, high 3, medium 2, low 1, none 1. It filters to **the connected person** by default: `login` recorded who they are and their Linear user id, and the list keeps only asks assigned to that id. `--anyone` lists the whole tenant. The JSON answer carries a `scope` — `mine` (with the label and Linear id), `anyone`, `unmatched` (the person's Linear identity is not matched yet: an admin does that in Settings → Members, and until then the whole list is shown with a stderr line saying so), or `no-person` (the machine is connected with the tenant's account key, which names nobody: the whole list, with a line saying to log in with a personal key). Read the scope before presenting the list, and say which you got: "what needs me" is the `mine` list, "what needs anyone" is `--anyone`, and an empty `mine` list names the wider count so it never reads as "nothing needs anyone".
|
|
21
|
+
|
|
22
|
+
## Presenting the queue
|
|
23
|
+
|
|
24
|
+
- Ranked by score, highest first. Never by age, never by identifier. An ask that holds a project outranks one that holds a chore, whatever their dates.
|
|
25
|
+
- One line per ask: rank, identifier, what it holds (identifiers), the question. The human decides from the question and the held work, so both must be on the line.
|
|
26
|
+
- Asks that hold nothing come after a break, marked as not visible in Waiting on me until they block something.
|
|
27
|
+
- When the list is empty, say "nothing needs you" and stop. Do not pad it with suspected asks.
|
|
28
|
+
- A ticket `explain` flagged as `ask_shape_suspected` (its text reads as a decision but it carries no ask label) is mentioned separately with a question mark: it is either an ask the human should label, or a false positive they release with the release label the contract names.
|
|
29
|
+
|
|
30
|
+
## What the inbox is not
|
|
31
|
+
|
|
32
|
+
- It is not the dispatch queue. Held tickets are excluded from dispatch by their blocking relation; answering the ask releases them into the ordinary order.
|
|
33
|
+
- It is not a place to answer. Nothing in this skill picks an option, closes an ask on the human's behalf, or posts in their voice. When they answer in chat, `references/settling-an-answer.md`.
|
|
34
|
+
- It is not a stall detector. A ticket parked by repeated failures, a merge hold, or a coding-account wall is a status question for `whats-happening`, not a decision until someone makes it one by raising an ask.
|
|
35
|
+
|
|
36
|
+
## Free-text and interpreted replies
|
|
37
|
+
|
|
38
|
+
A human reply that names no option is recorded by the cloud and interpreted for display, but never auto-applied and never written back to Linear as a decision. Treat it the same way: read it, ask the human to confirm which option it means if that is unclear, and settle only once a comment on the ask states the answer.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Settling an answer
|
|
2
|
+
|
|
3
|
+
This reference restates invariants: where an answer is posted, how it is recorded, and how held work is released. The routes and the bookkeeping marker are the contract's; `node scripts/settle.mjs` reads them through the CLI.
|
|
4
|
+
|
|
5
|
+
## The rule
|
|
6
|
+
|
|
7
|
+
The answer lives on the ask. Wherever it arrived (a comment on the ask, a comment on a held ticket, a chat message, a call), it ends as a comment on the ask ticket, and that comment is recorded as the accepted answer. Question, options, default, answer and who answered are then one record.
|
|
8
|
+
|
|
9
|
+
## The three steps
|
|
10
|
+
|
|
11
|
+
**1. Post the answer where it should live.** If the human answered in the ask's thread, nothing to do. If they answered anywhere else, post a comment on the ask that states the answer, as the app actor, never as the human, and attribute it in the text ("the owner answered in chat: option B"). Use the `catalyst-linear` skill's comment script; it returns the comment id. Do not paraphrase into a different option; quote what they said.
|
|
12
|
+
|
|
13
|
+
**2. Record it.** `node scripts/settle.mjs <ask> --answer <commentId> --role <role>` calls the cloud's ask-accept with the ask, the answering comment and the role doing the recording. The script first checks the comment is really on the ask (an id from a different ticket is refused before anything is written), then records, then reads the ask's blocking relations.
|
|
14
|
+
|
|
15
|
+
**3. Release the held work.** For every open ticket the ask blocks, the script posts one bookkeeping comment naming the ask, the comment id and the first line of the answer, so the next phase or agent on that ticket reads the decision without opening the ask. The comment carries the contract's bookkeeping prefix, which means the cloud's comment-wake trigger ignores it: a record, not a turn in a conversation. Pass `--no-release-note` to skip this when the held tickets are about to be canceled anyway.
|
|
16
|
+
|
|
17
|
+
With `--close`, the script also moves the ask to its team's done slot. The blocking relations stay on the record; a done ticket does not block anything, so the held tickets become dispatchable on the cloud's next pass. Close only when the answer is complete; an answer that raises a follow-up question keeps the ask open and the follow-up goes in the same thread.
|
|
18
|
+
|
|
19
|
+
## Free-text replies
|
|
20
|
+
|
|
21
|
+
A reply that names no option ("do whichever is cheaper", "ask me again Thursday") is recorded exactly as written. Nothing in the cloud or in this skill turns it into an option. Read it, decide whether it answers the question, and if it does not, reply in the thread with the one clarification needed and leave the ask open. If it does, settle with that comment as the answer and let the release note carry the quoted line.
|
|
22
|
+
|
|
23
|
+
## Who settles
|
|
24
|
+
|
|
25
|
+
The role that raised the ask, or the owner of the scope it belongs to. The desk settles asks that have no owner. The human never has to run anything; their part ends when they answer. Never settle another role's ask without saying so in the thread.
|
|
26
|
+
|
|
27
|
+
## After settling
|
|
28
|
+
|
|
29
|
+
- Tell the human, in one line, what was recorded and what it released.
|
|
30
|
+
- The held tickets need no further action from you: their exclusion reason was the blocking relation, and the queue recomputes on the cloud's next pass. If one stays excluded, `whats-happening` explains why.
|
|
31
|
+
- If the answer changes priorities or scope, that is a routing change for the project owner, not a second ask.
|
|
32
|
+
|
|
33
|
+
## When it cannot be settled
|
|
34
|
+
|
|
35
|
+
- The comment id is not on the ask: post the answer on the ask first, then settle with the new id.
|
|
36
|
+
- The write budget for the day is spent: the CLI names the budget from the contract and exits 2. Say so; the record waits, the human's answer is not lost.
|
|
37
|
+
- The ask is on a team the contract does not list: the tenant admin maps the team in settings; the `catalyst-setup` skill reads readiness.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// inbox.mjs — "what needs me?": the open asks on this tenant, ranked by what each answer releases.
|
|
3
|
+
// A thin wrapper over `catalyst-skills ask list`, which reads the open tickets carrying the team's
|
|
4
|
+
// ask label (from the contract), follows each one's blocking relations, and weights held work by
|
|
5
|
+
// priority. By default the CLI keeps only the asks assigned to the connected person; --anyone widens
|
|
6
|
+
// to the whole tenant. Prints the ranked list; --json prints the CLI's {scope, asks} unchanged.
|
|
7
|
+
import { mustRun, parseFlags, parseJson, printHelp } from "./lib/cli.mjs";
|
|
8
|
+
|
|
9
|
+
const SPEC = {
|
|
10
|
+
json: { value: false, help: "print the CLI's answer as JSON: {scope, asks: [{identifier, title, state, blocks[], score, assigneeId}]}" },
|
|
11
|
+
team: { value: true, help: "only asks whose identifier carries this team key" },
|
|
12
|
+
all: { value: false, help: "include asks that hold nothing (by default they are listed last, marked)" },
|
|
13
|
+
anyone: { value: false, help: "every open ask in the tenant, not only the ones assigned to you" },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const { help, flags } = parseFlags(process.argv.slice(2), SPEC);
|
|
17
|
+
if (help) {
|
|
18
|
+
printHelp("node scripts/inbox.mjs [--json] [--team K] [--all] [--anyone] [--help]", SPEC, [
|
|
19
|
+
"Rank: the sum over held open tickets of a priority weight (urgent 4 ... low 1, none 1), highest first.",
|
|
20
|
+
"An ask that holds nothing is real but will not appear in the tenant's Waiting-on-me view; it is",
|
|
21
|
+
"listed last with a marker so you can attach the work it should block (references/reading-the-inbox.md).",
|
|
22
|
+
]);
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const answer = parseJson(mustRun(["ask", "list", "--json", ...(flags.anyone ? ["--anyone"] : [])], { quiet: true }).stdout, "ask list");
|
|
27
|
+
const rows = answer.asks;
|
|
28
|
+
const scope = answer.scope;
|
|
29
|
+
const wanted = flags.team ? rows.filter((r) => String(r.identifier).toUpperCase().startsWith(`${flags.team.toUpperCase()}-`)) : rows;
|
|
30
|
+
if (flags.json) {
|
|
31
|
+
process.stdout.write(JSON.stringify({ scope, asks: wanted }) + "\n");
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
// Say whose list this is before the list, so an empty "mine" never reads as "nothing needs anyone".
|
|
35
|
+
if (scope.kind === "mine") process.stdout.write(`asks assigned to ${scope.label}:\n`);
|
|
36
|
+
else if (scope.kind === "anyone") process.stdout.write("every open ask in the tenant:\n");
|
|
37
|
+
else if (scope.kind === "unmatched") process.stdout.write(`every open ask (your Linear identity is not matched yet — an admin matches it in Settings → Members):\n`);
|
|
38
|
+
else process.stdout.write("every open ask (connected with the tenant's account key, which names no person — log in with your personal key to see only yours):\n");
|
|
39
|
+
if (wanted.length === 0) {
|
|
40
|
+
process.stdout.write(scope.kind === "mine" ? "nothing needs you: no open asks assigned to you (--anyone lists the tenant's)\n" : "nothing needs anyone: no open asks\n");
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
const holding = wanted.filter((r) => r.blocks.length > 0);
|
|
44
|
+
const idle = wanted.filter((r) => r.blocks.length === 0);
|
|
45
|
+
let n = 0;
|
|
46
|
+
for (const r of holding) {
|
|
47
|
+
n += 1;
|
|
48
|
+
process.stdout.write(`${n}. ${r.identifier} holds ${r.blocks.length} (weight ${r.score}): ${r.blocks.join(", ")}\n ${r.title}\n`);
|
|
49
|
+
}
|
|
50
|
+
if (idle.length && (flags.all || holding.length === 0)) {
|
|
51
|
+
process.stdout.write(`${holding.length ? "\n" : ""}asks that hold nothing (not shown in Waiting on me until they block something):\n`);
|
|
52
|
+
for (const r of idle) process.stdout.write(` ${r.identifier} ${r.title}\n`);
|
|
53
|
+
} else if (idle.length) {
|
|
54
|
+
process.stdout.write(`\n(${idle.length} more ask${idle.length === 1 ? "" : "s"} hold nothing; --all lists them)\n`);
|
|
55
|
+
}
|
|
56
|
+
process.exit(0);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/cli.mjs — the one way a skill script reaches Catalyst Cloud: spawn the catalyst-skills CLI.
|
|
3
|
+
// The CLI holds the SDK and the key; this file holds neither. It reads customer.json only
|
|
4
|
+
// to learn where the CLI lives, and it exits 2 with one line when the machine is not connected.
|
|
5
|
+
// Run any script beside this one with --help; this file is a library and is never run directly.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { CONNECT_COMMAND, hasCredential } from "./credential.mjs";
|
|
10
|
+
|
|
11
|
+
export const PACKAGE = "@catalyst-cloud/catalyst-skills";
|
|
12
|
+
export const NOT_CONFIGURED_EXIT = 2;
|
|
13
|
+
|
|
14
|
+
export function configPath() {
|
|
15
|
+
const home = process.env.CATALYST_SKILLS_HOME ?? process.env.HOME ?? "/";
|
|
16
|
+
return join(home, ".config", "catalyst-cloud", "customer.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The connected-machine config, or exit 2 with the one line that says how to connect. */
|
|
20
|
+
export function loadConfig() {
|
|
21
|
+
const path = configPath();
|
|
22
|
+
if (!existsSync(path)) notConfigured(`no config at ${path}`);
|
|
23
|
+
try {
|
|
24
|
+
const cfg = JSON.parse(readFileSync(path, "utf8"));
|
|
25
|
+
if (!hasCredential(cfg)) notConfigured(`config at ${path} has no key or login`);
|
|
26
|
+
return cfg;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (err && err.exitCode === NOT_CONFIGURED_EXIT) throw err;
|
|
29
|
+
notConfigured(`config at ${path} is unreadable`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function notConfigured(why) {
|
|
34
|
+
process.stderr.write(`not connected (${why}) — run: ${CONNECT_COMMAND}\n`);
|
|
35
|
+
process.exit(NOT_CONFIGURED_EXIT);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run one catalyst-skills verb and return {code, stdout, stderr}. Uses the CLI path the login
|
|
40
|
+
* recorded when it still exists, else `npx <package>`. A CLI exit of 2 with a "not joined" or
|
|
41
|
+
* "not connected" line is turned into this script's own exit 2, so every caller sees one contract.
|
|
42
|
+
*/
|
|
43
|
+
export function runCli(args, opts = {}) {
|
|
44
|
+
const cfg = loadConfig();
|
|
45
|
+
const useRecorded = typeof cfg.cliPath === "string" && existsSync(cfg.cliPath);
|
|
46
|
+
const cmd = useRecorded ? process.execPath : "npx";
|
|
47
|
+
const argv = useRecorded ? [cfg.cliPath, ...args] : [PACKAGE, ...args];
|
|
48
|
+
const res = spawnSync(cmd, argv, {
|
|
49
|
+
input: opts.stdin,
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
52
|
+
shell: !useRecorded && process.platform === "win32",
|
|
53
|
+
env: process.env,
|
|
54
|
+
});
|
|
55
|
+
if (res.error) {
|
|
56
|
+
process.stderr.write(`could not run ${cmd}: ${res.error.message}\n`);
|
|
57
|
+
process.exit(NOT_CONFIGURED_EXIT);
|
|
58
|
+
}
|
|
59
|
+
const stdout = res.stdout ?? "";
|
|
60
|
+
const stderr = res.stderr ?? "";
|
|
61
|
+
if (res.status === NOT_CONFIGURED_EXIT && /not (joined|connected|configured)/i.test(stderr)) {
|
|
62
|
+
process.stderr.write(stderr);
|
|
63
|
+
process.exit(NOT_CONFIGURED_EXIT);
|
|
64
|
+
}
|
|
65
|
+
return { code: res.status ?? 1, stdout, stderr };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Run a verb and require success; on failure print the CLI's own stderr and exit with its code (or 1). */
|
|
69
|
+
export function mustRun(args, opts = {}) {
|
|
70
|
+
const r = runCli(args, opts);
|
|
71
|
+
if (r.code !== 0) {
|
|
72
|
+
if (r.stderr) process.stderr.write(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
|
|
73
|
+
if (r.stdout && !opts.quiet) process.stdout.write(r.stdout.endsWith("\n") ? r.stdout : `${r.stdout}\n`);
|
|
74
|
+
process.exit(r.code === 0 ? 1 : r.code);
|
|
75
|
+
}
|
|
76
|
+
return r;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Parse the CLI's --json stdout; a non-JSON answer is a failed check, exit 1. */
|
|
80
|
+
export function parseJson(stdout, what = "output") {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(stdout.trim());
|
|
83
|
+
} catch {
|
|
84
|
+
process.stderr.write(`could not parse ${what} as JSON: ${stdout.trim().slice(0, 200)}\n`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Minimal flag parsing for the scripts: --name value, --name=value, --flag, repeated flags collect. */
|
|
90
|
+
export function parseFlags(argv, spec) {
|
|
91
|
+
const flags = {};
|
|
92
|
+
const positionals = [];
|
|
93
|
+
for (let i = 0; i < argv.length; i++) {
|
|
94
|
+
const a = argv[i];
|
|
95
|
+
if (a === "--help" || a === "-h") return { help: true, flags, positionals };
|
|
96
|
+
if (a.startsWith("--")) {
|
|
97
|
+
let name = a.slice(2);
|
|
98
|
+
let value;
|
|
99
|
+
const eq = name.indexOf("=");
|
|
100
|
+
if (eq !== -1) {
|
|
101
|
+
value = name.slice(eq + 1);
|
|
102
|
+
name = name.slice(0, eq);
|
|
103
|
+
}
|
|
104
|
+
const s = spec[name];
|
|
105
|
+
if (!s) {
|
|
106
|
+
process.stderr.write(`unknown option --${name} (try --help)\n`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
if (s.value) {
|
|
110
|
+
if (value === undefined) value = argv[++i];
|
|
111
|
+
if (value === undefined || value === "") {
|
|
112
|
+
process.stderr.write(`--${name} needs a value\n`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
if (s.repeat) (flags[name] ??= []).push(value);
|
|
116
|
+
else flags[name] = value;
|
|
117
|
+
} else flags[name] = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
positionals.push(a);
|
|
121
|
+
}
|
|
122
|
+
return { help: false, flags, positionals };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function printHelp(usage, spec, notes = []) {
|
|
126
|
+
const lines = [`Usage: ${usage}`, ""];
|
|
127
|
+
const names = Object.keys(spec);
|
|
128
|
+
if (names.length) {
|
|
129
|
+
lines.push("Options:");
|
|
130
|
+
for (const n of names) lines.push(` --${n}${spec[n].value ? " <value>" : ""} ${spec[n].help}`);
|
|
131
|
+
lines.push("");
|
|
132
|
+
}
|
|
133
|
+
lines.push(...notes, "", "Exit codes: 0 ok · 1 the check failed or the arguments were wrong · 2 this machine is not connected to a tenant");
|
|
134
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
135
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/credential.mjs — is this machine connected? The ONE place a skill script decides it, vendored
|
|
3
|
+
// byte-identical into every skill's scripts/lib/ from skill-lib/credential.mjs at the package root
|
|
4
|
+
// (`npm run skill-lib:sync`; a test fails on any drift). Skills install one directory at a time, so
|
|
5
|
+
// each carries its own copy. This file is a library — run a sibling script with --help for usage.
|
|
6
|
+
//
|
|
7
|
+
// customer.json carries exactly one credential: a personal key (`key`), or the keyless login's
|
|
8
|
+
// session (`auth`, the recommended rail). A script never reads either for its value: it spawns the
|
|
9
|
+
// catalyst-skills CLI, which authenticates with whichever is present and refreshes a login's token
|
|
10
|
+
// itself. A new credential kind lands here, once.
|
|
11
|
+
|
|
12
|
+
/** The command that connects this machine, as every not-connected line names it. */
|
|
13
|
+
export const CONNECT_COMMAND =
|
|
14
|
+
"npx @catalyst-cloud/catalyst-skills login (or, with a personal key: CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login)";
|
|
15
|
+
|
|
16
|
+
/** True when `cfg` (parsed customer.json) holds a usable credential of either kind. Never throws. */
|
|
17
|
+
export function hasCredential(cfg) {
|
|
18
|
+
if (cfg === null || typeof cfg !== "object") return false;
|
|
19
|
+
const key = cfg["key"];
|
|
20
|
+
if (typeof key === "string" && key !== "") return true;
|
|
21
|
+
const login = cfg["auth"];
|
|
22
|
+
return (
|
|
23
|
+
login !== null &&
|
|
24
|
+
typeof login === "object" &&
|
|
25
|
+
login["kind"] === "oauth" &&
|
|
26
|
+
typeof login["refreshToken"] === "string" &&
|
|
27
|
+
login["refreshToken"] !== ""
|
|
28
|
+
);
|
|
29
|
+
}
|