@heart-of-gold/toolkit 0.1.49 → 0.1.51

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.
Files changed (2) hide show
  1. package/package.json +3 -2
  2. package/scripts/release.mjs +261 -0
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@heart-of-gold/toolkit",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
4
4
  "type": "module",
5
- "description": "Cross-platform installer for Heart of Gold skills \u2014 works with Codex, OpenCode, Pi, Claude Code, and more",
5
+ "description": "Cross-platform installer for Heart of Gold skills works with Codex, OpenCode, Pi, Claude Code, and more",
6
6
  "bin": {
7
7
  "heart-of-gold": "src/index.ts"
8
8
  },
@@ -12,6 +12,7 @@
12
12
  "check:compat": "python3 scripts/check-harness-compatibility.py",
13
13
  "test:pi-guided": "node --test tests/pi-guided-workflows.test.mjs",
14
14
  "test:visualize": "node --test tests/visualize-smart-render.test.mjs",
15
+ "release": "node scripts/release.mjs",
15
16
  "prepublishOnly": "npm run check:publish-safety && npm run check:compat"
16
17
  },
17
18
  "dependencies": {
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ // One-shot release tool. Bumps a plugin (plugin.json + marketplace.json) and
3
+ // the root package.json in lockstep, runs the prepublish checks, commits, and
4
+ // — only with --ship — pushes and publishes to NPM.
5
+ //
6
+ // Usage:
7
+ // node scripts/release.mjs <plugin|root>:<patch|minor|major> [flags]
8
+ //
9
+ // Examples:
10
+ // node scripts/release.mjs marvin:patch # bump + commit, no push/publish
11
+ // node scripts/release.mjs marvin:minor --ship -m "new skill" # bump + commit + push + npm publish
12
+ // node scripts/release.mjs root:patch # root only (CLI fix; no plugin)
13
+ // node scripts/release.mjs --dry-run marvin:patch # preview, mutate nothing
14
+ //
15
+ // Flags:
16
+ // --ship after commit, run `git push` and `npm publish --access public`
17
+ // --dry-run print the plan, change no files
18
+ // -m, --message TEXT description after the standard "Release X.Y.Z — " prefix
19
+ // -h, --help this text
20
+
21
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
22
+ import { execSync } from "node:child_process";
23
+ import { dirname, join } from "node:path";
24
+ import { createInterface } from "node:readline";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
28
+ const ROOT_PKG = join(ROOT, "package.json");
29
+ const MARKETPLACE = join(ROOT, ".claude-plugin/marketplace.json");
30
+ const PLUGINS = ["guide", "deep-thought", "marvin", "babel-fish", "quellis"];
31
+
32
+ const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
33
+ const writeJson = (p, d) => writeFileSync(p, JSON.stringify(d, null, 2) + "\n");
34
+ const sh = (cmd, opts = {}) => execSync(cmd, { cwd: ROOT, stdio: "inherit", ...opts });
35
+ const shRead = (cmd) => execSync(cmd, { cwd: ROOT, encoding: "utf8" });
36
+
37
+ function bump(version, kind) {
38
+ const [maj, min, pat] = version.split(".").map(Number);
39
+ if (kind === "patch") return `${maj}.${min}.${pat + 1}`;
40
+ if (kind === "minor") return `${maj}.${min + 1}.0`;
41
+ if (kind === "major") return `${maj + 1}.0.0`;
42
+ throw new Error(`Unknown bump kind: ${kind} (use patch, minor, or major)`);
43
+ }
44
+
45
+ function help() {
46
+ console.log(`One-shot release tool. Bumps a plugin (plugin.json + marketplace.json) and
47
+ the root package.json in lockstep, runs prepublish checks, commits, and —
48
+ only with --ship — pushes and publishes to NPM.
49
+
50
+ Usage:
51
+ node scripts/release.mjs <plugin|root>:<patch|minor|major> [flags]
52
+
53
+ Examples:
54
+ node scripts/release.mjs marvin:patch # bump + commit, no push/publish
55
+ node scripts/release.mjs marvin:minor --ship -m "new skill" # bump + commit + push + npm publish
56
+ node scripts/release.mjs root:patch # root only (CLI fix; no plugin)
57
+ node scripts/release.mjs --dry-run marvin:patch # preview, mutate nothing
58
+
59
+ Flags:
60
+ --ship after commit, run \`git push\` and \`npm publish --access public\`
61
+ --dry-run print the plan, change no files
62
+ --add-pi-skills skip the prompt and add every detected new skill to pi.skills
63
+ --no-pi-skills skip the pi.skills detection entirely
64
+ -m, --message TEXT description after the standard "Release X.Y.Z — " prefix
65
+ -h, --help this text
66
+
67
+ Plugins: ${PLUGINS.join(" | ")} | root`);
68
+ }
69
+
70
+ function prompt(question) {
71
+ return new Promise((resolve) => {
72
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
73
+ rl.question(question, (answer) => {
74
+ rl.close();
75
+ resolve(answer.trim().toLowerCase());
76
+ });
77
+ });
78
+ }
79
+
80
+ function detectMissingPiSkills(rootPkg) {
81
+ const exported = new Set(rootPkg.pi?.skills ?? []);
82
+ const found = [];
83
+ for (const plugin of PLUGINS) {
84
+ const skillsDir = join(ROOT, "plugins", plugin, "skills");
85
+ if (!existsSync(skillsDir)) continue;
86
+ for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
87
+ if (!entry.isDirectory()) continue;
88
+ if (!existsSync(join(skillsDir, entry.name, "SKILL.md"))) continue;
89
+ found.push(`./plugins/${plugin}/skills/${entry.name}`);
90
+ }
91
+ }
92
+ return found.filter((path) => !exported.has(path));
93
+ }
94
+
95
+ function addPiSkills(rootPkg, missing) {
96
+ // Insert each path at the end of its plugin's contiguous block — preserves
97
+ // existing per-plugin ordering (which isn't always alphabetical) and just
98
+ // appends new entries within each plugin's section.
99
+ const skills = [...(rootPkg.pi?.skills ?? [])];
100
+ for (const path of missing) {
101
+ const pluginPrefix = path.split("/").slice(0, 3).join("/") + "/";
102
+ let insertAt = skills.length;
103
+ for (let i = skills.length - 1; i >= 0; i--) {
104
+ if (skills[i].startsWith(pluginPrefix)) {
105
+ insertAt = i + 1;
106
+ break;
107
+ }
108
+ }
109
+ skills.splice(insertAt, 0, path);
110
+ }
111
+ rootPkg.pi = rootPkg.pi ?? {};
112
+ rootPkg.pi.skills = skills;
113
+ }
114
+
115
+ function parse(argv) {
116
+ const out = {
117
+ target: null,
118
+ kind: null,
119
+ dryRun: false,
120
+ ship: false,
121
+ message: "",
122
+ piSkills: "prompt",
123
+ };
124
+ for (let i = 0; i < argv.length; i++) {
125
+ const a = argv[i];
126
+ if (a === "--help" || a === "-h") { help(); process.exit(0); }
127
+ else if (a === "--dry-run") out.dryRun = true;
128
+ else if (a === "--ship") out.ship = true;
129
+ else if (a === "--add-pi-skills") out.piSkills = "auto";
130
+ else if (a === "--no-pi-skills") out.piSkills = "skip";
131
+ else if (a === "--message" || a === "-m") out.message = argv[++i] ?? "";
132
+ else if (a.includes(":")) [out.target, out.kind] = a.split(":");
133
+ else throw new Error(`Unknown arg: ${a}`);
134
+ }
135
+ if (!out.target || !out.kind) { help(); process.exit(1); }
136
+ if (out.target !== "root" && !PLUGINS.includes(out.target)) {
137
+ throw new Error(`Unknown plugin: ${out.target}. Known: ${PLUGINS.join(", ")}, root`);
138
+ }
139
+ if (!["patch", "minor", "major"].includes(out.kind)) {
140
+ throw new Error(`Unknown bump kind: ${out.kind}. Use patch | minor | major`);
141
+ }
142
+ if (out.target === "root" && out.kind !== "patch") {
143
+ throw new Error("Root bumps are reserved for patch releases (plugin changes drive minor/major).");
144
+ }
145
+ return out;
146
+ }
147
+
148
+ function ensureNoDrift(target, marketplace) {
149
+ if (target === "root") return;
150
+ const pluginPath = join(ROOT, `plugins/${target}/.claude-plugin/plugin.json`);
151
+ const pluginVersion = readJson(pluginPath).version;
152
+ const marketplaceVersion = marketplace.plugins.find((p) => p.name === target).version;
153
+ if (pluginVersion !== marketplaceVersion) {
154
+ throw new Error(
155
+ `Version drift on ${target}: plugin.json=${pluginVersion}, marketplace.json=${marketplaceVersion}. ` +
156
+ `Reconcile both files before releasing.`,
157
+ );
158
+ }
159
+ }
160
+
161
+ function ensureGitClean(allowedRelativePaths) {
162
+ const status = shRead("git status --porcelain").split("\n").filter(Boolean);
163
+ const offending = status.filter((line) => {
164
+ const file = line.slice(3);
165
+ return !allowedRelativePaths.some((a) => file === a);
166
+ });
167
+ if (offending.length) {
168
+ throw new Error(
169
+ `Working tree has changes outside the release scope. Commit or stash them first:\n${offending.join("\n")}`,
170
+ );
171
+ }
172
+ }
173
+
174
+ async function main() {
175
+ const { target, kind, dryRun, ship, message, piSkills } = parse(process.argv.slice(2));
176
+
177
+ const rootPkg = readJson(ROOT_PKG);
178
+ const marketplace = readJson(MARKETPLACE);
179
+ ensureNoDrift(target, marketplace);
180
+
181
+ const oldRoot = rootPkg.version;
182
+ const newRoot = bump(oldRoot, "patch");
183
+ let pluginPlan = null;
184
+ if (target !== "root") {
185
+ const pluginPath = join(ROOT, `plugins/${target}/.claude-plugin/plugin.json`);
186
+ const pluginJson = readJson(pluginPath);
187
+ const oldPlugin = pluginJson.version;
188
+ const newPlugin = bump(oldPlugin, kind);
189
+ pluginPlan = { name: target, pluginPath, pluginJson, oldPlugin, newPlugin };
190
+ }
191
+
192
+ const missingPi = piSkills === "skip" ? [] : detectMissingPiSkills(rootPkg);
193
+
194
+ console.log("\nRelease plan");
195
+ console.log("------------");
196
+ if (pluginPlan) console.log(` plugin ${pluginPlan.name}: ${pluginPlan.oldPlugin} → ${pluginPlan.newPlugin}`);
197
+ console.log(` root @heart-of-gold/toolkit: ${oldRoot} → ${newRoot}`);
198
+ if (missingPi.length > 0) {
199
+ console.log(` pi.skills: ${missingPi.length} new skill${missingPi.length === 1 ? "" : "s"} detected`);
200
+ for (const p of missingPi) console.log(` + ${p}`);
201
+ }
202
+ console.log(` prepublish checks: will run`);
203
+ console.log(` commit: yes`);
204
+ console.log(` push: ${ship ? "yes" : "no"}`);
205
+ console.log(` npm publish: ${ship ? "yes" : "no"}`);
206
+ if (dryRun) {
207
+ console.log("\n[dry-run] Nothing changed.");
208
+ return;
209
+ }
210
+
211
+ let addPi = piSkills === "auto" && missingPi.length > 0;
212
+ if (missingPi.length > 0 && piSkills === "prompt") {
213
+ const answer = await prompt(`\nAdd ${missingPi.length} new skill${missingPi.length === 1 ? "" : "s"} to pi.skills? [Y/n] `);
214
+ addPi = answer === "" || answer === "y" || answer === "yes";
215
+ }
216
+
217
+ const allowed = [
218
+ "package.json",
219
+ ".claude-plugin/marketplace.json",
220
+ pluginPlan ? `plugins/${pluginPlan.name}/.claude-plugin/plugin.json` : null,
221
+ ].filter(Boolean);
222
+ ensureGitClean(allowed);
223
+
224
+ if (pluginPlan) {
225
+ pluginPlan.pluginJson.version = pluginPlan.newPlugin;
226
+ writeJson(pluginPlan.pluginPath, pluginPlan.pluginJson);
227
+ const idx = marketplace.plugins.findIndex((p) => p.name === pluginPlan.name);
228
+ marketplace.plugins[idx].version = pluginPlan.newPlugin;
229
+ writeJson(MARKETPLACE, marketplace);
230
+ }
231
+ rootPkg.version = newRoot;
232
+ if (addPi) addPiSkills(rootPkg, missingPi);
233
+ writeJson(ROOT_PKG, rootPkg);
234
+
235
+ console.log("\nRunning prepublish checks…");
236
+ sh("npm run check:publish-safety && npm run check:compat");
237
+
238
+ const subject = pluginPlan
239
+ ? `Release ${newRoot} — bump ${pluginPlan.name} to ${pluginPlan.newPlugin}${message ? `: ${message}` : ""}`
240
+ : `Release ${newRoot} — root patch${message ? `: ${message}` : ""}`;
241
+ sh(`git add ${allowed.map((p) => `"${p}"`).join(" ")}`);
242
+ sh(`git commit -m ${JSON.stringify(subject)}`);
243
+ console.log(`\nCommit created: ${subject}`);
244
+
245
+ if (!ship) {
246
+ console.log("\nNot shipping. To finish:");
247
+ console.log(" git push && npm publish --access public");
248
+ return;
249
+ }
250
+
251
+ console.log("\nPushing to origin…");
252
+ sh("git push");
253
+ console.log("\nPublishing to NPM…");
254
+ sh("npm publish --access public");
255
+ console.log(`\nReleased @heart-of-gold/toolkit@${newRoot}.`);
256
+ }
257
+
258
+ main().catch((err) => {
259
+ console.error(`\nrelease: ${err.message}\n`);
260
+ process.exit(1);
261
+ });