universal-plugin 0.17.1 → 0.18.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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Compiled by `universal-plugin plugin build` from src/skill-scripts/doctor.ts. Edit the source, not this file.
3
- globalThis.__SKILL_SCRIPT__ = {"package":"universal-plugin","bin":"universal-plugin","version":"0.17.1","entry":"skills/doctor-universal-plugin/scripts/doctor.mjs","commands":{"marketplace add":"skills/marketplace/scripts/add.mjs","marketplace init":"skills/marketplace/scripts/marketplace.mjs","marketplace update":"skills/marketplace/scripts/update.mjs","marketplace validate":"skills/marketplace/scripts/validate.mjs","plugin build":"skills/build-plugin/scripts/build.mjs","plugin init":"skills/init-universal-plugin/scripts/init.mjs","plugin version":"skills/version/scripts/version.mjs"}};
3
+ globalThis.__SKILL_SCRIPT__ = {"package":"universal-plugin","bin":"universal-plugin","version":"0.18.0","entry":"skills/doctor-universal-plugin/scripts/doctor.mjs","commands":{"marketplace add":"skills/marketplace/scripts/add.mjs","marketplace init":"skills/marketplace/scripts/marketplace.mjs","marketplace update":"skills/marketplace/scripts/update.mjs","marketplace validate":"skills/marketplace/scripts/validate.mjs","plugin build":"skills/build-plugin/scripts/build.mjs","plugin init":"skills/init-universal-plugin/scripts/init.mjs","plugin version":"skills/version/scripts/version.mjs"}};
4
4
  import { spawnSync } from "node:child_process";
5
5
  import * as fs from "node:fs";
6
6
  import * as path from "node:path";
@@ -51,6 +51,17 @@ const marketplaceRoots = argv.flatMap((arg, i) => arg === "--marketplace-root" ?
51
51
  const findings = [];
52
52
  /** semantic-release's version placeholder until its publish run stamps the real number. */
53
53
  const UNSTAMPED_VERSION = "0.0.0-development";
54
+ /** The config files release-it reads, besides the `release-it` key of package.json. */
55
+ const RELEASE_IT_FILES = [
56
+ "json",
57
+ "yaml",
58
+ "yml",
59
+ "toml",
60
+ "js",
61
+ "cjs",
62
+ "mjs",
63
+ "ts"
64
+ ].map((ext) => `.release-it.${ext}`);
54
65
  const add = (code, severity, detail, repair) => findings.push({
55
66
  code,
56
67
  severity,
@@ -187,7 +198,7 @@ if (packagePath !== void 0 && !packageMissing) {
187
198
  if (manifest.version !== void 0 && pkg.version !== UNSTAMPED_VERSION && pkg.version !== manifest.version) add("version-drift", "high", `plugin.json is ${manifest.version}, ${packagePath}/package.json is ${pkg.version}`, "/universal-plugin:version");
188
199
  }
189
200
  }
190
- if (manifest.version !== void 0 && packagePath === null) {
201
+ if (manifest.version !== void 0 && packagePath === null && releaseTool() === null) {
191
202
  const introduced = commitThatSetVersion(manifest.version);
192
203
  if (introduced !== null) {
193
204
  const files = (git("diff", "--name-only", `${introduced}..HEAD`, "--", ...shippedPaths()) ?? "").split("\n").filter(Boolean);
@@ -197,6 +208,10 @@ if (manifest.version !== void 0 && packagePath === null) {
197
208
  }
198
209
  }
199
210
  }
211
+ for (const finding of changesetsReleaseFindings()) add(...finding);
212
+ for (const finding of releasePleaseReleaseFindings()) add(...finding);
213
+ for (const file of releaseItConfigsWithoutStamp()) add("release-it-no-stamp", "high", `${file} configures release-it, but no hook runs plugin version — the release is tagged with plugin.json and the vendor manifests still on the previous version`, "add \"hooks\": { \"after:bump\": \"universal-plugin plugin version ${version}\" } to the release-it config");
214
+ for (const finding of semanticReleaseFindings()) add(...finding);
200
215
  const missingMarketplaceRoots = marketplaceRoots.filter((r) => !fs.existsSync(path.resolve(r)));
201
216
  for (const r of missingMarketplaceRoots) add("marketplace-root-missing", "high", `--marketplace-root ${r} does not exist`, "clone the marketplace repository, or fix the path");
202
217
  const existingMarketplaceRoots = marketplaceRoots.filter((r) => fs.existsSync(path.resolve(r)));
@@ -222,6 +237,214 @@ function invalidCatalogs(extraRoots) {
222
237
  })) : [];
223
238
  });
224
239
  }
240
+ /** The release-config gaps of a non-npm plugin in a changesets repository: no `packagePath`, and a
241
+ * plugin-root package.json that is absent or private. An npm-shipped plugin is not checked here —
242
+ * `changeset publish` there publishes to npm whatever the private-package settings say. */
243
+ function changesetsReleaseFindings() {
244
+ if (packagePath !== null) return [];
245
+ const repoRoot = git("rev-parse", "--show-toplevel") ?? root;
246
+ const workspace = changesetsWorkspace(repoRoot);
247
+ if (workspace === void 0) return [];
248
+ const pkg = readJson(path.join(root, "package.json"));
249
+ const out = [];
250
+ if (pkg === null) out.push([
251
+ "changesets-no-package",
252
+ "high",
253
+ "the repository is released with changesets, but the plugin root has no package.json — changesets releases only a package, so changeset version and changeset publish fail",
254
+ "/universal-plugin:init-universal-plugin — it writes a private package.json (name, \"private\": true, version)"
255
+ ]);
256
+ const setting = readJson(path.join(workspace, ".changeset", "config.json"))?.privatePackages;
257
+ if (!(typeof setting === "object" && setting !== null && setting.version !== false && setting.tag === true)) out.push([
258
+ "changesets-private-untagged",
259
+ "high",
260
+ ".changeset/config.json does not set \"privatePackages\": { \"version\": true, \"tag\": true } — changeset publish creates no tag for a private package, so no release is made",
261
+ "set \"privatePackages\": { \"version\": true, \"tag\": true } in .changeset/config.json"
262
+ ]);
263
+ for (const workflow of changesetsWorkflowsWithoutPublish(repoRoot)) out.push([
264
+ "changesets-no-publish-script",
265
+ "high",
266
+ `${workflow} runs changesets/action with no publish script — after the Version Packages PR merges it does nothing, so no tag or release is made`,
267
+ "add `publish-script: pnpm release` (changesets/action v1: `publish:`) to the changesets/action step, with a \"release\" script running changeset publish"
268
+ ]);
269
+ return out;
270
+ }
271
+ /** The directory holding `.changeset/` for a non-npm plugin a changesets repository releases: the
272
+ * plugin root or the repository root, with a plugin-root package.json that is absent or private.
273
+ * `undefined` when changesets does not release this plugin as a non-npm one. */
274
+ function changesetsWorkspace(repoRoot) {
275
+ const workspace = [root, repoRoot].find((dir) => fs.existsSync(path.join(dir, ".changeset")));
276
+ if (workspace === void 0) return void 0;
277
+ const pkg = readJson(path.join(root, "package.json"));
278
+ return pkg !== null && pkg.private !== true ? void 0 : workspace;
279
+ }
280
+ /** The release tool that picks a non-npm plugin's next version, or `null` when the author picks it
281
+ * (ADR-0010 §6). Each tool moves the number at release time, from what accumulated since the last
282
+ * release: changesets from changeset files, release-please and semantic-release from commits,
283
+ * release-it from its prompt or increment argument. Config is looked for where each tool loads it:
284
+ * the plugin root and the repository root. */
285
+ function releaseTool() {
286
+ const repoRoot = git("rev-parse", "--show-toplevel") ?? root;
287
+ if (changesetsWorkspace(repoRoot) !== void 0) return "changesets";
288
+ const rpConfig = readJson(path.join(repoRoot, "release-please-config.json"));
289
+ if (rpConfig !== null && releasePleaseBumpsManifest(rpConfig, repoRoot)) return "release-please";
290
+ const dirs = [.../* @__PURE__ */ new Set([root, repoRoot])];
291
+ if (dirs.some((dir) => semanticReleaseConfig(dir) !== null)) return "semantic-release";
292
+ if (dirs.some((dir) => hasConfig(dir, RELEASE_IT_FILES, "release-it"))) return "release-it";
293
+ return null;
294
+ }
295
+ /** Whether `dir` holds one of the config files `files`, or a package.json carrying `key`. */
296
+ function hasConfig(dir, files, key) {
297
+ if (files.some((file) => fs.existsSync(path.join(dir, file)))) return true;
298
+ const pkg = readJson(path.join(dir, "package.json"));
299
+ return pkg !== null && typeof pkg === "object" && Object.hasOwn(pkg, key);
300
+ }
301
+ /** The release-please gap of a non-npm plugin: `release-please-config.json` bumps this plugin's
302
+ * plugin.json through `extra-files`, and no workflow runs `plugin version` to restamp the rest, or
303
+ * the restamp is pushed with the default `GITHUB_TOKEN` and so gets no checks. A plugin with `packagePath` is not checked: its number comes from the package, through
304
+ * `--from-package`. */
305
+ function releasePleaseReleaseFindings() {
306
+ if (packagePath !== null) return [];
307
+ const repoRoot = git("rev-parse", "--show-toplevel") ?? root;
308
+ const config = readJson(path.join(repoRoot, "release-please-config.json"));
309
+ if (config === null || !releasePleaseBumpsManifest(config, repoRoot)) return [];
310
+ if (!workflowsMention(repoRoot, /plugin version|version\.mjs/)) return [[
311
+ "release-please-no-restamp",
312
+ "high",
313
+ "release-please-config.json bumps plugin.json through extra-files, but no workflow runs plugin version — the release PR leaves the vendor manifests, catalogs, skill pins and compiled scripts on the old version",
314
+ "add a workflow step on the release PR branch that runs `universal-plugin plugin version --from-manifest` and pushes the stamped files (see the version skill)"
315
+ ]];
316
+ return restampWorkflowsWithoutCi(repoRoot).map((workflow) => [
317
+ "release-please-restamp-no-ci",
318
+ "medium",
319
+ `${workflow} pushes the --from-manifest restamp with the default GITHUB_TOKEN — that push starts no workflow run, so the release PR's checks never run on its head commit and a required check blocks the merge`,
320
+ "mint a GitHub App token with actions/create-github-app-token and pass it to release-please-action and actions/checkout as `token:` (see the version skill)"
321
+ ]);
322
+ }
323
+ /** Workflow files that restamp with `--from-manifest`, push, and leave the push on the default
324
+ * `GITHUB_TOKEN`, which starts no workflow run. Read as text, so it clears a file on any sign of
325
+ * another credential: a GitHub App token, a deploy key, or a `token:` input that is not the default
326
+ * token. A workflow dispatch does not clear it: its checks do not count toward a required check. */
327
+ function restampWorkflowsWithoutCi(repoRoot) {
328
+ const startsCi = /create-github-app-token|ssh-key:|token:\s*\$\{\{(?!\s*(?:secrets\.GITHUB_TOKEN|github\.token)\b)/;
329
+ return workflowFiles(repoRoot).filter(({ text }) => /--from-manifest/.test(text) && /git push/.test(text) && !startsCi.test(text)).map(({ file }) => file);
330
+ }
331
+ /** Whether a release-please config lists this plugin's plugin.json in `extra-files`. A package's own
332
+ * `extra-files` replaces the top-level default. A path is relative to the package path, or to the
333
+ * repository root with a leading `/`. */
334
+ function releasePleaseBumpsManifest(config, repoRoot) {
335
+ const target = path.join(root, "plugin.json");
336
+ const packages = config.packages ?? {};
337
+ return Object.entries(packages).some(([packageDir, options]) => {
338
+ const extra = options?.["extra-files"] ?? config["extra-files"];
339
+ if (!Array.isArray(extra)) return false;
340
+ return extra.some((entry) => {
341
+ const file = typeof entry === "string" ? entry : entry?.path;
342
+ if (typeof file !== "string") return false;
343
+ return (file.startsWith("/") ? path.join(repoRoot, file) : path.join(repoRoot, packageDir, file)) === target;
344
+ });
345
+ });
346
+ }
347
+ /** The semantic-release wiring gaps, for the config at the plugin root and at the repository root.
348
+ * Each config is read as text, so a JavaScript config is checked only for what it names. A config
349
+ * that `extends` a shareable config is skipped: the wiring may live in the shared one. */
350
+ function semanticReleaseFindings() {
351
+ const repoRoot = git("rev-parse", "--show-toplevel") ?? root;
352
+ return [.../* @__PURE__ */ new Set([root, repoRoot])].flatMap((dir) => {
353
+ const config = semanticReleaseConfig(dir);
354
+ if (config === null || /\bextends\b/.test(config.text)) return [];
355
+ const label = path.relative(root, path.join(dir, config.file)) || config.file;
356
+ const out = [];
357
+ if (!/\bprepareCmd\b['"]?\s*:\s*(?:[>|][-+]?\s*\n\s*)?[^,\n}]*(?:\bplugin\s+version\b|version\.mjs)/.test(config.text)) out.push([
358
+ "semantic-release-no-stamp",
359
+ "high",
360
+ `${label} configures semantic-release, but no @semantic-release/exec prepareCmd runs plugin version — the release is tagged with plugin.json, the vendor manifests and the catalogs still on the previous version`,
361
+ "add [\"@semantic-release/exec\", { \"prepareCmd\": \"universal-plugin plugin version ${nextRelease.version}\" }] before @semantic-release/git — /universal-plugin:init-universal-plugin wires it"
362
+ ]);
363
+ const runsNpm = /@semantic-release\/npm(?![\w-])/.test(config.text) || !/\bplugins\b/.test(config.text);
364
+ if (packagePath === null && runsNpm) out.push([
365
+ "semantic-release-npm-unshipped",
366
+ "high",
367
+ `${label} runs @semantic-release/npm, but the plugin ships to no npm package — with no package.json the release fails (ENOPKG), and with one it still rewrites that version`,
368
+ "remove @semantic-release/npm from the plugins list (write the list out if the config relies on the defaults) — /universal-plugin:init-universal-plugin wires it"
369
+ ]);
370
+ return out;
371
+ });
372
+ }
373
+ /** The semantic-release config semantic-release would read in `dir`, as text, or `null` when there
374
+ * is none. The search order is cosmiconfig 9's default for the module name `release`: the `release`
375
+ * key of package.json, then the files below. The first one found is the only one read. */
376
+ function semanticReleaseConfig(dir) {
377
+ const rc = [
378
+ "",
379
+ ".json",
380
+ ".yaml",
381
+ ".yml",
382
+ ".js",
383
+ ".ts",
384
+ ".cjs",
385
+ ".mjs"
386
+ ];
387
+ const files = [
388
+ ...rc.map((ext) => `.releaserc${ext}`),
389
+ ...rc.map((ext) => `.config/releaserc${ext}`),
390
+ ...[
391
+ ".js",
392
+ ".ts",
393
+ ".cjs",
394
+ ".mjs"
395
+ ].map((ext) => `release.config${ext}`)
396
+ ];
397
+ const fromPackage = readJson(path.join(dir, "package.json"))?.release;
398
+ if (fromPackage !== void 0 && fromPackage !== null) return {
399
+ file: "package.json",
400
+ text: JSON.stringify(fromPackage, null, 2)
401
+ };
402
+ for (const file of files) {
403
+ const abs = path.join(dir, file);
404
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return {
405
+ file,
406
+ text: fs.readFileSync(abs, "utf8")
407
+ };
408
+ }
409
+ return null;
410
+ }
411
+ /** Whether any workflow file's text matches. */
412
+ function workflowsMention(repoRoot, pattern) {
413
+ return workflowFiles(repoRoot).some(({ text }) => pattern.test(text));
414
+ }
415
+ /** The workflow files under `.github/workflows`, each with its path from the repository root and its text. */
416
+ function workflowFiles(repoRoot) {
417
+ const dir = path.join(repoRoot, ".github", "workflows");
418
+ if (!fs.existsSync(dir)) return [];
419
+ return fs.readdirSync(dir).filter((file) => /\.ya?ml$/.test(file)).map((file) => ({
420
+ file: `.github/workflows/${file}`,
421
+ text: fs.readFileSync(path.join(dir, file), "utf8")
422
+ }));
423
+ }
424
+ /** Workflow files that run changesets/action and give it no publish command. Read as text: a step
425
+ * names it as `publish-script:` (v2) or `publish:` (v1). */
426
+ function changesetsWorkflowsWithoutPublish(repoRoot) {
427
+ const dir = path.join(repoRoot, ".github", "workflows");
428
+ if (!fs.existsSync(dir)) return [];
429
+ return fs.readdirSync(dir).filter((file) => /\.ya?ml$/.test(file)).filter((file) => {
430
+ const text = fs.readFileSync(path.join(dir, file), "utf8");
431
+ return /uses:\s*['"]?changesets\/action@/.test(text) && !/^\s*(publish-script|publish)\s*:/m.test(text);
432
+ }).map((file) => `.github/workflows/${file}`);
433
+ }
434
+ /** release-it configs, at the plugin root or the repository root, that never call `plugin version`.
435
+ * release-it reads `.release-it.{json,yaml,yml,toml,js,cjs,mjs,ts}` or the `release-it` key of
436
+ * package.json. Each is read as text, so the check is only that the stamp is named somewhere. */
437
+ function releaseItConfigsWithoutStamp() {
438
+ const repoRoot = git("rev-parse", "--show-toplevel") ?? root;
439
+ const stamps = (text) => /\bplugin\s+version\b/.test(text);
440
+ return [.../* @__PURE__ */ new Set([root, repoRoot])].flatMap((dir) => {
441
+ const label = (file) => path.relative(root, path.join(dir, file)) || file;
442
+ const found = RELEASE_IT_FILES.filter((file) => fs.existsSync(path.join(dir, file))).filter((file) => !stamps(fs.readFileSync(path.join(dir, file), "utf8"))).map(label);
443
+ const config = readJson(path.join(dir, "package.json"))?.["release-it"];
444
+ if (config !== void 0 && !stamps(JSON.stringify(config))) found.push(label("package.json"));
445
+ return found;
446
+ });
447
+ }
225
448
  /** Runs git inside `root`, returning its stdout or `null` — a non-zero status, a missing git, and a
226
449
  * directory outside any repository are all the same answer here: no history to read. */
227
450
  function git(...args) {
@@ -70,6 +70,9 @@ covers moving one that sits in the wrong place.
70
70
 
71
71
  Inventory both the canonical surface (root `plugin.json`, its `extensions` block, the component
72
72
  directories it names) and every pre-existing vendor artifact listed in `references/detection.md`.
73
+ Note the release tool the repository uses, if any: `.changeset/` (changesets), `.releaserc*`,
74
+ `release.config.*`, or a `release` key in `package.json` (semantic-release), `.release-it.*` or a
75
+ `release-it` key in `package.json` (release-it), or `release-please-config.json` (release-please).
73
76
  Read what you find. Write nothing yet.
74
77
 
75
78
  ## 2. Classify
@@ -126,9 +129,63 @@ plan, and ask before running init: it rewrites the user's root `package.json`. I
126
129
  run init without `--npm` and wire `files` by hand. A custom `version` script is never rewritten;
127
130
  init prints the line to append, so relay it and let the user place it.
128
131
 
132
+ A plugin that ships to **no** npm package in a changesets repository gets its own plan line.
133
+ Changesets releases only a package, so when the repository root carries `.changeset/` and the plugin
134
+ root has no `package.json`, `plugin init` writes a minimal private one: `name` from the plugin name,
135
+ `"private": true`, and `version` from the `plugin.json` it replaces (else `0.0.0`), with no `files`,
136
+ `main`, or `exports`. It also wires the root `version` script as above and adds
137
+ `"release": "changeset publish"`, which tags the release for changesets/action. Offer this in the
138
+ plan and show the file; if the user declines, add `--no-changesets` to the init call. Relay init's
139
+ notes: a plugin below the repository root must be a workspace package, and `.changeset/config.json`
140
+ needs `"privatePackages": { "version": true, "tag": true }` before `changeset publish` tags anything.
141
+
142
+ A plugin released with **semantic-release** gets the release wiring as a plan line too.
143
+ `plugin init` writes nothing for it, so the skill edits the config itself, once the user approves.
144
+ Add `@semantic-release/exec` with
145
+ `prepareCmd: "universal-plugin plugin version ${nextRelease.version}"`, and add
146
+ `@semantic-release/git` with the stamped files as `assets`. For a plugin that ships to no npm
147
+ package, also drop `@semantic-release/npm`: `npmPublish: false` is not enough, because the plugin
148
+ still edits `package.json`. Show the `plugins` list before and after, and edit the config file the
149
+ survey found, in its own format. [`references/release-tools.md`](./references/release-tools.md) has
150
+ the config, the plugin order, the full `assets` list, and the packages the release job must
151
+ install. If the user declines, leave the config alone.
152
+
153
+ A plugin released with **release-it** gets its wiring as a plan line as well. Add an `after:bump`
154
+ hook running `universal-plugin plugin version ${version}`, after any hook the user already has. For a
155
+ plugin that ships to no npm package, also set `"npm": false`, and run the hook through the pinned
156
+ `npx universal-plugin@0.18.0`. release-it then reads the current number from the latest git tag,
157
+ so when `git describe --tags` finds none, propose tagging the current commit with the version
158
+ `plugin.json` holds (`git tag v1.2.0`). Without that tag the first release starts from `0.0.0` and
159
+ fails. Create the tag only once the user approves, and leave pushing it to the user.
160
+
161
+ A plugin released with **release-please** gets its wiring as a plan line too. For a plugin that
162
+ ships to no npm package, write `release-please-config.json` with `release-type: simple` and an
163
+ `extra-files` entry bumping `plugin.json`. Put the version `plugin.json` holds in
164
+ `.release-please-manifest.json`, so the first release PR starts from it. The restamp runs in a
165
+ workflow step on the release PR and pushes with a GitHub App token, so the PR's checks run. Show that
166
+ step and the App setup, and hand both to the user.
167
+
168
+ [`references/release-tools.md`](./references/release-tools.md) has the configs for release-it and
169
+ release-please, the npm-shipped variants, and the tag and manifest checks. Show each file before and
170
+ after. If the user declines, leave the config alone.
171
+
172
+ When the survey found **no** release tool, ask the user how the plugin will be released before
173
+ running init. A new repository may simply not have set one up yet:
174
+
175
+ - **changesets.** Set up `.changeset/` first (`buddy-changesets:init-changesets` when it is
176
+ installed, else `npx @changesets/cli init`), then run init. It then finds `.changeset/` and writes
177
+ the private `package.json` above.
178
+ - **semantic-release.** Propose a new `.releaserc.json` carrying the wiring above, and write it once
179
+ the user approves. No `package.json` is needed.
180
+ - **release-it.** Propose a new `.release-it.json` carrying the wiring above, with the initial tag
181
+ for a plugin that ships to no npm package. Write it once the user approves.
182
+ - **release-please.** Propose `release-please-config.json` and `.release-please-manifest.json` as
183
+ above, and hand over the workflow step. Write them once the user approves.
184
+ - **None.** Run init as planned. The author picks each number with `version`.
185
+
129
186
  ## 4. Apply
130
187
 
131
- Route by what Phase 2 found. Read exactly the reference for the work at hand — do not load all six.
188
+ Route by what Phase 2 found. Read exactly the reference for the work at hand — do not load them all.
132
189
 
133
190
  | What Phase 2 found | Do this | Reference |
134
191
  | --- | --- | --- |
@@ -227,6 +284,8 @@ This skill is not a formatter. If the project has one, run it over the written f
227
284
  - **Offer adoption once.** If the user declines, or asked for something unrelated, drop it and do
228
285
  what they asked.
229
286
  - Plugin authoring only. Do not change CI workflows, repository settings, or unrelated project files.
287
+ The release tool's config is the one file outside the plugin this skill edits, and only once the
288
+ user approves it in Phase 3. The one git write is release-it's initial tag, on the same approval.
230
289
 
231
290
  ## Related skills
232
291
 
@@ -43,10 +43,13 @@ node scripts/init.mjs --name <plugin-name> --vendor claude-code --vendor cursor
43
43
  ```
44
44
 
45
45
  Resolve `scripts/init.mjs` against this skill's directory; it runs the project's installed CLI, else
46
- the pinned `npx universal-plugin@0.17.1`. Add `--npm` when an npm package ships the plugin,
46
+ the pinned `npx universal-plugin@0.18.0`. Add `--npm` when an npm package ships the plugin,
47
47
  which also wires that `package.json`'s `files` to carry the derived manifests, copies its `version`
48
48
  into the manifest, and, in a changesets repository, wires the root `version` script to run
49
49
  `plugin version --from-package` (SKILL.md Phase 3 asks before that rewrite).
50
+ Without `--npm`, in a changesets repository with no `package.json` at the plugin root, init writes a
51
+ private `package.json` so changesets can release the plugin (SKILL.md Phase 3 offers it; pass
52
+ `--no-changesets` when the user declines).
50
53
 
51
54
  `init` writes a minimal manifest — `$schema`, `name`, the vendor list, and the package `version`
52
55
  under `--npm`. Fill in the Step 1
@@ -0,0 +1,272 @@
1
+ # Release tool wiring
2
+
3
+ Each release tool decides the release number in its own way. The plugin still needs that number
4
+ stamped into `plugin.json`, the vendor manifests, the catalogs, and the skill pins. The `version`
5
+ skill owns the stamp: `plugin version` with a number the tool hands it. This file covers the wiring
6
+ that makes a release tool call it.
7
+
8
+ `plugin init` writes the changesets wiring itself (SKILL.md Phase 3). It writes nothing for the
9
+ other tools. Their config takes several file formats, JavaScript modules among them, and it often
10
+ carries plugins the user added. Editing it is a judgment call, so the skill makes the edit and the
11
+ user approves it.
12
+
13
+ | Tool | Config the survey finds | Wiring |
14
+ | --- | --- | --- |
15
+ | changesets | `.changeset/` | `plugin init` writes it (SKILL.md Phase 3) |
16
+ | semantic-release | `.releaserc`, `.releaserc.{json,yaml,yml,js,cjs,mjs}`, `release.config.{js,cjs,mjs}`, or a `release` key in `package.json` | [below](#semantic-release) |
17
+ | release-it | `.release-it.{json,yaml,yml,toml,js,cjs,ts}`, or a `release-it` key in `package.json` | [below](#release-it) |
18
+ | release-please | `release-please-config.json`, `.release-please-manifest.json` | [below](#release-please) |
19
+
20
+ ## semantic-release
21
+
22
+ semantic-release writes the version only during its own run. Two plugins carry the number into the
23
+ plugin:
24
+
25
+ - **`@semantic-release/exec`** runs the stamp in its `prepare` step:
26
+ `prepareCmd: "universal-plugin plugin version ${nextRelease.version}"`. semantic-release fills in
27
+ `${nextRelease.version}` with its lodash template. Add `--root <plugin-root>` when the plugin root
28
+ is not the repository root.
29
+ - **`@semantic-release/git`** commits the stamped files back. Copilot CLI and GitHub-hosted
30
+ marketplaces read `plugin.json` from the repository, not from a package, so without this commit
31
+ those channels never see the new number (ADR-0010 §2).
32
+
33
+ A `plugins` list replaces semantic-release's default list. It does not merge with it. The defaults
34
+ are `commit-analyzer`, `release-notes-generator`, `npm`, and `github`. A config with no `plugins`
35
+ key therefore gets the full list written out, minus whatever the case below drops.
36
+
37
+ Within each step, plugins run in list order. Put `exec` after `npm` and before `git`. That way `npm`
38
+ moves `package.json` first, `exec` stamps the plugin, and `git` commits both.
39
+
40
+ ### A plugin that ships to no npm package
41
+
42
+ Drop `@semantic-release/npm` from the list. `npmPublish: false` does not cover this: the plugin
43
+ still sets `package.json` `version`, and it requires a `package.json` that a git-installed plugin
44
+ usually lacks. Without `npm`, semantic-release needs no `package.json` at all.
45
+
46
+ ```json
47
+ {
48
+ "branches": ["main"],
49
+ "plugins": [
50
+ "@semantic-release/commit-analyzer",
51
+ "@semantic-release/release-notes-generator",
52
+ ["@semantic-release/exec", {
53
+ "prepareCmd": "npx universal-plugin@0.18.0 plugin version ${nextRelease.version}"
54
+ }],
55
+ ["@semantic-release/git", {
56
+ "assets": [
57
+ "plugin.json",
58
+ ".claude-plugin/**",
59
+ ".cursor-plugin/**",
60
+ ".codex-plugin/**",
61
+ ".agents/plugins/marketplace.json",
62
+ ".github/plugin/**",
63
+ "com.github.copilot/**",
64
+ "skills/**",
65
+ ".plugin/pins.json"
66
+ ]
67
+ }],
68
+ "@semantic-release/github"
69
+ ]
70
+ }
71
+ ```
72
+
73
+ With no `package.json`, `universal-plugin` is not installed, so the command runs it through `npx`.
74
+ Pin it to the CLI version this skill ships with, as shown. The `upgrade-plugin` skill moves that pin
75
+ later.
76
+ Where the repository does install `universal-plugin` as a devDependency, use the bare
77
+ `universal-plugin` instead.
78
+
79
+ ### A plugin that ships on npm
80
+
81
+ Keep `@semantic-release/npm` and add the other two after it:
82
+
83
+ ```json
84
+ ["@semantic-release/npm"],
85
+ ["@semantic-release/exec", { "prepareCmd": "universal-plugin plugin version ${nextRelease.version}" }],
86
+ ["@semantic-release/git", { "assets": ["package.json", "plugin.json", "..."] }]
87
+ ```
88
+
89
+ Setting `assets` replaces the git plugin's default list, which holds `package.json`,
90
+ `package-lock.json`, `npm-shrinkwrap.json`, and `CHANGELOG.md`. List again each of those the
91
+ repository needs, alongside the plugin files above.
92
+
93
+ ### Assets
94
+
95
+ `plugin version` rewrites `plugin.json` and the skill pins, and then re-derives every vendor
96
+ manifest and repository catalog. Commit all of them, or the repository's files disagree with the
97
+ tag. The list above covers every place the CLI writes. A glob that matches nothing adds nothing, so
98
+ the whole list is safe to keep for a plugin that does not declare every vendor. Under a plugin root
99
+ below the repository root, prefix the plugin's own paths with that root. The catalogs
100
+ (`.claude-plugin/marketplace.json`, `.cursor-plugin/marketplace.json`,
101
+ `.agents/plugins/marketplace.json`, `.github/plugin/marketplace.json`) stay at the repository root.
102
+ Add the mcp file when the plugin declares one.
103
+
104
+ The git plugin's default commit message ends in `[skip ci]`, so the commit back does not start
105
+ another release.
106
+
107
+ ### Applying it
108
+
109
+ 1. Edit the config file the survey found, in its own format. For a JavaScript config, edit the
110
+ `plugins` array in place. If no config exists, write `.releaserc.json`.
111
+ 2. Keep every plugin the user already lists, and keep their order relative to each other.
112
+ 3. Show the before and after `plugins` list in the Phase 3 plan. Apply only once the user approves.
113
+ 4. Name the packages the release job must install: `@semantic-release/exec` and
114
+ `@semantic-release/git`. The `--no-ci` local run, and the CI job that runs semantic-release, both
115
+ need them. Installing them is the user's step. Do not edit the CI workflow (SKILL.md Rules).
116
+
117
+ ## release-it
118
+
119
+ release-it runs its `after:bump` hook after it settles the new number and before its release
120
+ commit and tag. That commit stages every tracked file the hook changed (`git add . --update`), so
121
+ the stamp ships in the tagged commit with no extra plugin. `${version}` in a hook is the new number.
122
+
123
+ ### A plugin that ships to no npm package
124
+
125
+ Set `"npm": false`. release-it then ignores `package.json`, needs none, and reads the current version
126
+ from the latest git tag. Add the stamp as the `after:bump` hook:
127
+
128
+ ```json
129
+ {
130
+ "npm": false,
131
+ "hooks": {
132
+ "after:bump": "npx universal-plugin@0.18.0 plugin version ${version}"
133
+ }
134
+ }
135
+ ```
136
+
137
+ With no `package.json`, `universal-plugin` is not installed, so the hook runs it through `npx`. Pin
138
+ it to the CLI version this skill ships with, as shown. The `upgrade-plugin` skill moves that pin
139
+ later.
140
+
141
+ ### A plugin that ships on npm
142
+
143
+ Keep release-it's npm plugin. It bumps `package.json`, and the hook then stamps the same number into
144
+ the plugin:
145
+
146
+ ```json
147
+ {
148
+ "hooks": { "after:bump": "universal-plugin plugin version ${version}" }
149
+ }
150
+ ```
151
+
152
+ Use the bare `universal-plugin` when the repository installs it as a devDependency, as an
153
+ npm-shipped plugin usually does. Otherwise use the pinned `npx` form above. Here release-it reads
154
+ the current version from `package.json`, so the first release needs no tag.
155
+
156
+ Add `--root <plugin-root>` to the hook when release-it runs from a directory other than the plugin
157
+ root.
158
+
159
+ ### The first release needs a tag
160
+
161
+ With `"npm": false`, release-it reads the current number from the latest tag, never from
162
+ `plugin.json`. With no tag it starts from `0.0.0`, proposes `0.0.1`, and the stamp refuses a number
163
+ that does not advance on the version `plugin.json` holds. The release then fails.
164
+
165
+ So, when the plugin ships to no npm package, check for a tag in the Phase 3 plan:
166
+
167
+ ```bash
168
+ git describe --tags --abbrev=0
169
+ ```
170
+
171
+ If that finds no tag, propose tagging the current commit with the version `plugin.json` holds, and
172
+ create the tag once the user approves:
173
+
174
+ ```bash
175
+ git tag v1.2.0
176
+ ```
177
+
178
+ - Use `v<version>` unless the repository already tags in another form. release-it names its next
179
+ tag after the latest one, so the first tag sets the form every later release keeps.
180
+ - A lightweight tag is enough. release-it finds it with `git describe --tags`.
181
+ - Tag the commit whose `plugin.json` holds that version, and only after the plugin's files are
182
+ committed. Do not move or replace an existing tag.
183
+ - The tag is local until pushed. A release that runs in CI needs it on the remote. Pushing it is the
184
+ user's step, so name `git push origin v1.2.0` and run it only if the user asks.
185
+ - If the user declines, say the first release fails until a tag on the current version exists.
186
+
187
+ ### Applying it
188
+
189
+ 1. Edit the config the survey found, in its own format: a `.release-it.*` file, or the `release-it`
190
+ key in `package.json`. If none exists, write `.release-it.json`.
191
+ 2. Keep every key the user already set. If an `after:bump` hook already exists, keep its command and
192
+ run the stamp after it: a string becomes an array of both commands. Show the hooks before and
193
+ after in the Phase 3 plan. Apply only once the user approves.
194
+ 3. For a plugin that ships to no npm package, set `"npm": false`, and handle the tag as above.
195
+ 4. Commit the plugin's generated files (`plugin.json`, the vendor manifests, the catalogs,
196
+ `.plugin/pins.json`) before the first release. The release commit stages only tracked files, so
197
+ a stamped file git does not track yet stays out of the tagged commit.
198
+ 5. Installing release-it is the user's step. Do not edit the CI workflow (SKILL.md Rules).
199
+
200
+ `doctor` reports `release-it-no-stamp` for a release-it config that never runs `plugin version`.
201
+
202
+ ## release-please
203
+
204
+ release-please runs no command before its release commit. Its release PR moves the number in the
205
+ files it knows, and a workflow step on that PR then restamps the plugin. The wiring is two files the
206
+ skill writes, and one workflow step the user adds.
207
+
208
+ ### A plugin that ships to no npm package
209
+
210
+ `release-type: simple` keeps the number in `.release-please-manifest.json`, and `extra-files` bumps
211
+ `plugin.json`. Write `release-please-config.json`:
212
+
213
+ ```json
214
+ {
215
+ "packages": {
216
+ ".": {
217
+ "release-type": "simple",
218
+ "extra-files": [{ "type": "json", "path": "plugin.json", "jsonpath": "$.version" }]
219
+ }
220
+ }
221
+ }
222
+ ```
223
+
224
+ The restamp step runs `npx -y universal-plugin@0.18.0 plugin version --from-manifest`, which takes
225
+ the number `plugin.json` already holds. Pin it to the CLI version this skill ships with.
226
+
227
+ ### A plugin that ships on npm
228
+
229
+ `release-type: node` bumps `package.json`. The restamp step runs
230
+ `universal-plugin plugin version --from-package` instead. Do not add `plugin.json` to `extra-files`
231
+ here: the package owns the number, and `--from-package` carries it into the plugin.
232
+
233
+ ### The manifest holds the current version
234
+
235
+ release-please reads the last release from `.release-please-manifest.json`. A package with no entry
236
+ starts from a default version, not from the plugin's. Write the entry with the version `plugin.json`
237
+ holds, keyed by the package path:
238
+
239
+ ```json
240
+ { ".": "1.2.0" }
241
+ ```
242
+
243
+ For a plugin below the repository root, use its path as the key in both files (`"packages/my-plugin"`),
244
+ and make `extra-files` paths relative to that package.
245
+
246
+ ### The workflow step
247
+
248
+ The `version` skill's section *release-please with no `package.json`* has the workflow: a GitHub
249
+ App token minted for the run, the release-please action given that token, then a checkout of the
250
+ release PR branch with the same token, the restamp, and a commit pushed back. Show it in the plan and
251
+ hand it to the user. Do not write or edit the workflow (SKILL.md Rules). For an npm-shipped plugin,
252
+ the step runs `--from-package` in place of `--from-manifest`.
253
+
254
+ The token matters. A push made with the default `GITHUB_TOKEN` starts no workflow run, so the
255
+ release PR's checks never run on its head commit and a required check blocks the merge. Tell the
256
+ user the App setup the workflow needs: an App with Contents, Pull requests and Issues set to read and
257
+ write, installed on the repository, its client ID in the variable `RELEASE_APP_CLIENT_ID` and a
258
+ private key in the secret `RELEASE_APP_PRIVATE_KEY`. Do not offer a `workflow_dispatch` re-run of CI
259
+ instead: its checks do not count toward a required check.
260
+
261
+ ### Applying it
262
+
263
+ 1. If `release-please-config.json` exists, add `extra-files` to the plugin's package entry and keep
264
+ every other key. If it does not, write the config above.
265
+ 2. Add the plugin's entry to `.release-please-manifest.json`, or write the file. Never lower or
266
+ replace an entry that already exists: that is the last released version.
267
+ 3. Show both files before and after in the Phase 3 plan. Apply only once the user approves.
268
+ 4. Hand over the workflow step and the App setup.
269
+
270
+ `doctor` reports `release-please-no-restamp` when `extra-files` bumps `plugin.json` and no workflow
271
+ runs `plugin version`, and `release-please-restamp-no-ci` when the restamp is pushed with the default
272
+ `GITHUB_TOKEN`.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Compiled by `universal-plugin plugin build` from src/skill-scripts/run-cli.ts. Edit the source, not this file.
3
- globalThis.__SKILL_SCRIPT__ = {"package":"universal-plugin","bin":"universal-plugin","version":"0.17.1","entry":"skills/init-universal-plugin/scripts/init.mjs","commands":{"marketplace add":"skills/marketplace/scripts/add.mjs","marketplace init":"skills/marketplace/scripts/marketplace.mjs","marketplace update":"skills/marketplace/scripts/update.mjs","marketplace validate":"skills/marketplace/scripts/validate.mjs","plugin build":"skills/build-plugin/scripts/build.mjs","plugin init":"skills/init-universal-plugin/scripts/init.mjs","plugin version":"skills/version/scripts/version.mjs"}};
3
+ globalThis.__SKILL_SCRIPT__ = {"package":"universal-plugin","bin":"universal-plugin","version":"0.18.0","entry":"skills/init-universal-plugin/scripts/init.mjs","commands":{"marketplace add":"skills/marketplace/scripts/add.mjs","marketplace init":"skills/marketplace/scripts/marketplace.mjs","marketplace update":"skills/marketplace/scripts/update.mjs","marketplace validate":"skills/marketplace/scripts/validate.mjs","plugin build":"skills/build-plugin/scripts/build.mjs","plugin init":"skills/init-universal-plugin/scripts/init.mjs","plugin version":"skills/version/scripts/version.mjs"}};
4
4
  import { spawnSync } from "node:child_process";
5
5
  //#region src/compile/scripts.ts
6
6
  /** The global the header assigns the script data to. Framework-neutral: a script's own code, or the
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Compiled by `universal-plugin plugin build` from src/skill-scripts/run-cli.ts. Edit the source, not this file.
3
- globalThis.__SKILL_SCRIPT__ = {"package":"universal-plugin","bin":"universal-plugin","version":"0.17.1","entry":"skills/marketplace/scripts/add.mjs","commands":{"marketplace add":"skills/marketplace/scripts/add.mjs","marketplace init":"skills/marketplace/scripts/marketplace.mjs","marketplace update":"skills/marketplace/scripts/update.mjs","marketplace validate":"skills/marketplace/scripts/validate.mjs","plugin build":"skills/build-plugin/scripts/build.mjs","plugin init":"skills/init-universal-plugin/scripts/init.mjs","plugin version":"skills/version/scripts/version.mjs"}};
3
+ globalThis.__SKILL_SCRIPT__ = {"package":"universal-plugin","bin":"universal-plugin","version":"0.18.0","entry":"skills/marketplace/scripts/add.mjs","commands":{"marketplace add":"skills/marketplace/scripts/add.mjs","marketplace init":"skills/marketplace/scripts/marketplace.mjs","marketplace update":"skills/marketplace/scripts/update.mjs","marketplace validate":"skills/marketplace/scripts/validate.mjs","plugin build":"skills/build-plugin/scripts/build.mjs","plugin init":"skills/init-universal-plugin/scripts/init.mjs","plugin version":"skills/version/scripts/version.mjs"}};
4
4
  import { spawnSync } from "node:child_process";
5
5
  //#region src/compile/scripts.ts
6
6
  /** The global the header assigns the script data to. Framework-neutral: a script's own code, or the