universal-plugin 0.4.0 → 0.5.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/dist/cli.mjs CHANGED
@@ -1,17 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, Option } from "commander";
3
- import * as fsNode from "node:fs";
3
+ import * as fs from "node:fs";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import { encode } from "@toon-format/toon";
7
- import { fileURLToPath } from "node:url";
7
+ import * as semver$1 from "semver";
8
+ import semver from "semver";
8
9
  import * as childProcess from "node:child_process";
9
- import * as semver from "semver";
10
+ import { execFileSync } from "node:child_process";
11
+ import { fileURLToPath } from "node:url";
10
12
  //#region src/cli-options.ts
11
13
  /** Repo root; resolves to cwd when omitted. */
12
14
  const ROOT_OPTION = new Option("--root <path>", "Plugin root directory");
13
- function resolveRoot(root) {
14
- return root ?? process.cwd();
15
+ /** Resolves `--root` to an absolute directory. Every command reports and joins against this path,
16
+ * so it must name a real directory rather than a fragment of one.
17
+ *
18
+ * A relative root resolves against the cwd, with one repair: in a monorepo a package is named by
19
+ * its workspace-relative path (`packages/pods`), and passing that from inside the package itself
20
+ * re-joins it onto a cwd that already ends with it — `<repo>/packages/pods/packages/pods`, a
21
+ * directory that does not exist. When the re-joined path is missing and the cwd already ends with
22
+ * the given path, the cwd is the package that was named. */
23
+ function resolveRoot(root, deps = {}) {
24
+ const cwd = deps.cwd ?? process.cwd();
25
+ const exists = deps.exists ?? ((candidate) => fs.existsSync(candidate));
26
+ if (root === void 0) return cwd;
27
+ const resolved = path.resolve(cwd, root);
28
+ if (path.isAbsolute(root) || exists(resolved)) return resolved;
29
+ const relative = path.normalize(root).replace(new RegExp(`\\${path.sep}+$`), "");
30
+ return cwd.endsWith(path.sep + relative) ? cwd : resolved;
15
31
  }
16
32
  //#endregion
17
33
  //#region src/state/state.ts
@@ -109,19 +125,19 @@ function storeEntryPath(storePath, segment) {
109
125
  //#endregion
110
126
  //#region src/asset-store/fs.ts
111
127
  function entryExists(entryPath) {
112
- return fsNode.existsSync(entryPath);
128
+ return fs.existsSync(entryPath);
113
129
  }
114
130
  function populateEntry(entryPath, pluginRoot) {
115
131
  for (const dir of ASSET_DIRS) {
116
132
  const src = path.join(pluginRoot, dir);
117
133
  const dest = path.join(entryPath, dir);
118
- if (!fsNode.existsSync(src)) continue;
119
- fsNode.mkdirSync(dest, { recursive: true });
120
- for (const file of fsNode.readdirSync(src)) fsNode.copyFileSync(path.join(src, file), path.join(dest, file));
134
+ if (!fs.existsSync(src)) continue;
135
+ fs.mkdirSync(dest, { recursive: true });
136
+ for (const file of fs.readdirSync(src)) fs.copyFileSync(path.join(src, file), path.join(dest, file));
121
137
  }
122
138
  }
123
139
  function removeStore(storePath) {
124
- fsNode.rmSync(storePath, {
140
+ fs.rmSync(storePath, {
125
141
  recursive: true,
126
142
  force: true
127
143
  });
@@ -134,11 +150,11 @@ function globalStatePath$3() {
134
150
  function clearStateIndex(statePath) {
135
151
  try {
136
152
  const cleared = {
137
- ...mergeSafeState(JSON.parse(fsNode.readFileSync(statePath, "utf8"))),
153
+ ...mergeSafeState(JSON.parse(fs.readFileSync(statePath, "utf8"))),
138
154
  plugins: {},
139
155
  assets: {}
140
156
  };
141
- fsNode.writeFileSync(statePath, JSON.stringify(cleared, null, 2) + "\n", "utf8");
157
+ fs.writeFileSync(statePath, JSON.stringify(cleared, null, 2) + "\n", "utf8");
142
158
  } catch (err) {
143
159
  if (err.code !== "ENOENT") throw err;
144
160
  }
@@ -184,6 +200,197 @@ function outputText(data, text) {
184
200
  else text();
185
201
  }
186
202
  //#endregion
203
+ //#region src/dependencies/dependencies.ts
204
+ /** Plugin dependencies: one canonical declaration of the plugins this plugin needs, delivered to the
205
+ * vendors that read one and dropped with a warning for the vendors that do not (ADR-0013).
206
+ *
207
+ * The canonical form is Claude Code's, because Claude Code is the only runtime that reads a
208
+ * dependency at all: an array whose entries are a plugin name — optionally `@marketplace`-qualified
209
+ * — or an object carrying that name plus a constraint. Pure domain code; reading and writing the
210
+ * manifests belongs to the build. */
211
+ /** Vendors that read a `dependencies` field. Sources, re-verified August 2026:
212
+ * Claude Code 2.1.235 (`claude plugin validate` accepts every form below, and its resolver installs,
213
+ * enables, and version-checks what it finds); Cursor 2026.07.01 and Codex 0.147.0 both parse a plugin
214
+ * manifest with no such field; GitHub Copilot CLI documents none
215
+ * (docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference).
216
+ * `.research/plugin-schema/` carries the evidence and the recheck triggers. */
217
+ const VENDOR_READS_DEPENDENCIES = {
218
+ "claude-code": true,
219
+ cursor: false,
220
+ codex: false,
221
+ "copilot-cli": false
222
+ };
223
+ /** Vendors the canonical root manifest serves as-is: nothing is derived for them, so a declaration
224
+ * they cannot read is ignored at runtime rather than dropped from a file. */
225
+ const CANONICAL_SERVED$1 = new Set(["copilot-cli"]);
226
+ /** What Claude Code accepts as a dependency string: a plugin name, an optional `@marketplace`, and an
227
+ * optional `@^…` range tail that the runtime strips before resolving. */
228
+ const DEPENDENCY_STRING = /^[A-Za-z0-9][-A-Za-z0-9._]*(@[A-Za-z0-9][-A-Za-z0-9._]*)?(@\^[^@]*)?$/;
229
+ const RANGE_TAIL = /@(\^[^@]*)$/;
230
+ /** Checks the shape of a `dependencies` declaration. Errors are author mistakes the runtime would
231
+ * reject; warnings are declarations the runtime accepts and then quietly ignores. */
232
+ function validateDependencies(declaration) {
233
+ const errors = [];
234
+ const warnings = [];
235
+ if (declaration === void 0 || declaration === null) return {
236
+ errors,
237
+ warnings
238
+ };
239
+ if (!Array.isArray(declaration)) {
240
+ errors.push("dependencies must be an array of plugin names or objects, not an object map");
241
+ return {
242
+ errors,
243
+ warnings
244
+ };
245
+ }
246
+ declaration.forEach((entry, index) => {
247
+ if (typeof entry === "string") {
248
+ if (!DEPENDENCY_STRING.test(entry)) {
249
+ errors.push(`dependencies[${index}] "${entry}" is not a plugin name, optionally qualified with @marketplace`);
250
+ return;
251
+ }
252
+ const range = entry.match(RANGE_TAIL)?.[1];
253
+ if (range) warnings.push(discardedRangeWarning(index, entry, range));
254
+ return;
255
+ }
256
+ if (!isDependencyObject(entry)) {
257
+ errors.push(`dependencies[${index}] must be a plugin name or an object with a name`);
258
+ return;
259
+ }
260
+ if (!DEPENDENCY_STRING.test(dependencyId(entry))) errors.push(`dependencies[${index}] "${dependencyId(entry)}" is not a plugin name, optionally qualified with @marketplace`);
261
+ if (entry.version !== void 0 && semver.validRange(entry.version) === null) errors.push(`dependencies[${index}].version "${entry.version}" is not a semver range`);
262
+ });
263
+ return {
264
+ errors,
265
+ warnings
266
+ };
267
+ }
268
+ /** Derives the `dependencies` field one vendor's manifest carries. */
269
+ function translateDependencies(declaration, vendor) {
270
+ if (declaration.length === 0) return {
271
+ dependencies: null,
272
+ warnings: []
273
+ };
274
+ if (VENDOR_READS_DEPENDENCIES[vendor] !== false) return {
275
+ dependencies: declaration,
276
+ warnings: []
277
+ };
278
+ return {
279
+ dependencies: null,
280
+ warnings: [`${vendor} does not read plugin dependencies — ${declaration.map((entry) => `"${dependencyId(entry)}"`).join(", ")} ${declaration.length === 1 ? "is" : "are"} ${CANONICAL_SERVED$1.has(vendor) ? "ignored at runtime" : "dropped from the derived manifest"}`]
281
+ };
282
+ }
283
+ /** The `name[@marketplace]` a runtime resolves a declaration to, minus any constraint beside it. */
284
+ function dependencyId(entry) {
285
+ if (typeof entry === "string") return entry.replace(RANGE_TAIL, "");
286
+ return entry.marketplace ? `${entry.name}@${entry.marketplace}` : entry.name;
287
+ }
288
+ function isDependencyObject(entry) {
289
+ return typeof entry === "object" && entry !== null && !Array.isArray(entry) && typeof entry.name === "string" && entry.name.length > 0;
290
+ }
291
+ /** A range in the string form parses and is then thrown away: Claude Code strips the `@^…` tail
292
+ * before resolving, and reads a constraint only off the object form. Naming the object the author
293
+ * meant to write is the whole remedy. */
294
+ function discardedRangeWarning(index, entry, range) {
295
+ const [name, marketplace] = entry.replace(RANGE_TAIL, "").split("@");
296
+ return `dependencies[${index}] "${entry}" carries a version range the runtime discards — declare ${marketplace ? `{ "name": "${name}", "marketplace": "${marketplace}", "version": "${range}" }` : `{ "name": "${name}", "version": "${range}" }`} for a range that is enforced`;
297
+ }
298
+ //#endregion
299
+ //#region src/hooks/hooks.ts
300
+ /** Per-vendor hook support. Source: `.research/hook-event-survey/conclusion.md`, re-verified against
301
+ * vendor documentation August 2026. A vendor fact decays — re-verify before trusting this table. */
302
+ const VENDOR_HOOK_PROFILE = {
303
+ "claude-code": {
304
+ casing: "pascal",
305
+ shape: "matcher-groups",
306
+ handlers: [
307
+ "command",
308
+ "http",
309
+ "prompt",
310
+ "agent"
311
+ ]
312
+ },
313
+ codex: {
314
+ casing: "pascal",
315
+ shape: "matcher-groups",
316
+ handlers: ["command"]
317
+ },
318
+ cursor: {
319
+ casing: "camel",
320
+ shape: "flat",
321
+ handlers: ["command", "prompt"],
322
+ schemaVersion: 1
323
+ },
324
+ "copilot-cli": {
325
+ casing: "pascal",
326
+ shape: "matcher-groups",
327
+ handlers: [
328
+ "command",
329
+ "http",
330
+ "prompt"
331
+ ]
332
+ }
333
+ };
334
+ function translateHooks(canonical, vendor) {
335
+ const profile = VENDOR_HOOK_PROFILE[vendor];
336
+ if (!profile) return {
337
+ hooks: canonical,
338
+ drops: [],
339
+ changed: false
340
+ };
341
+ const drops = [];
342
+ const events = {};
343
+ for (const [event, rules] of Object.entries(canonical.hooks ?? {})) {
344
+ const kept = [];
345
+ for (const rule of rules) {
346
+ const handlers = rule.hooks.filter((handler) => {
347
+ const type = handlerType(handler);
348
+ if (profile.handlers.includes(type)) return true;
349
+ drops.push({
350
+ event,
351
+ type
352
+ });
353
+ return false;
354
+ });
355
+ if (handlers.length > 0) kept.push({
356
+ ...rule,
357
+ hooks: handlers
358
+ });
359
+ }
360
+ if (kept.length === 0) continue;
361
+ events[eventName(event, profile.casing)] = profile.shape === "flat" ? flatten(kept) : kept;
362
+ }
363
+ if (Object.keys(events).length === 0) return {
364
+ hooks: null,
365
+ drops,
366
+ changed: true
367
+ };
368
+ const hooks = profile.schemaVersion === void 0 ? { hooks: events } : {
369
+ version: profile.schemaVersion,
370
+ hooks: events
371
+ };
372
+ return {
373
+ hooks,
374
+ drops,
375
+ changed: JSON.stringify(hooks) !== JSON.stringify(canonical)
376
+ };
377
+ }
378
+ /** A handler with no declared type is a command — the canonical schema's only required-by-default shape. */
379
+ function handlerType(handler) {
380
+ return handler.type ?? "command";
381
+ }
382
+ function eventName(event, casing) {
383
+ return casing === "camel" ? event.charAt(0).toLowerCase() + event.slice(1) : event;
384
+ }
385
+ /** Cursor has no matcher-group level: one group of N handlers becomes N entries, each repeating the
386
+ * group's matcher. */
387
+ function flatten(rules) {
388
+ return rules.flatMap((rule) => rule.hooks.map((handler) => rule.matcher === void 0 ? handler : {
389
+ ...handler,
390
+ matcher: rule.matcher
391
+ }));
392
+ }
393
+ //#endregion
187
394
  //#region src/json.ts
188
395
  /** Detects indentation style from JSON text. Returns `'\t'` for tabs or a number for space count.
189
396
  * Falls back to `'\t'` when no indentation is detected. */
@@ -193,6 +400,271 @@ function detectIndent(json) {
193
400
  return match[1].startsWith(" ") ? " " : match[1].length;
194
401
  }
195
402
  //#endregion
403
+ //#region src/marketplace/marketplace.ts
404
+ /** Claude Code ignores `$schema` at load time; it is there for editor completion and for
405
+ * `claude plugin validate`. Cursor documents no schema key, so only Claude's catalog carries one. */
406
+ const CLAUDE_SCHEMA = "https://json.schemastore.org/claude-code-marketplace.json";
407
+ /** Where each target's catalog sits, relative to the repository root. One place, because the
408
+ * generator writes these paths and `plugin init` reads the same ones to fold its entry in. */
409
+ const TARGET_CATALOG_PATHS = {
410
+ claude: ".claude-plugin/marketplace.json",
411
+ cursor: ".cursor-plugin/marketplace.json",
412
+ codex: ".agents/plugins/marketplace.json",
413
+ copilot: ".github/plugin/marketplace.json"
414
+ };
415
+ const COMMON_METADATA = [
416
+ "description",
417
+ "version",
418
+ "homepage",
419
+ "repository",
420
+ "license",
421
+ "keywords"
422
+ ];
423
+ function assertMarketplaceName(value, label) {
424
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(value)) throw new Error(`error: ${label} "${value}" must contain only letters, digits, dots, underscores, or hyphens`);
425
+ }
426
+ function commonMetadata(plugin) {
427
+ const result = {};
428
+ for (const field of COMMON_METADATA) if (plugin.metadata[field] !== void 0) result[field] = plugin.metadata[field];
429
+ return result;
430
+ }
431
+ function json(value) {
432
+ return `${JSON.stringify(value, null, 2)}\n`;
433
+ }
434
+ function claudeArtifact(metadata, plugins) {
435
+ return {
436
+ path: TARGET_CATALOG_PATHS.claude,
437
+ content: json({
438
+ $schema: CLAUDE_SCHEMA,
439
+ ...metadata,
440
+ plugins: plugins.map((plugin) => ({
441
+ name: plugin.name,
442
+ source: plugin.source,
443
+ ...commonMetadata(plugin)
444
+ }))
445
+ })
446
+ };
447
+ }
448
+ function codexArtifact(metadata, plugins) {
449
+ return {
450
+ path: TARGET_CATALOG_PATHS.codex,
451
+ content: json({
452
+ name: metadata.name,
453
+ interface: { displayName: metadata.name },
454
+ plugins: plugins.map((plugin) => ({
455
+ name: plugin.name,
456
+ version: plugin.metadata.version,
457
+ source: {
458
+ source: "local",
459
+ path: plugin.source
460
+ },
461
+ policy: {
462
+ installation: "AVAILABLE",
463
+ authentication: "ON_INSTALL"
464
+ },
465
+ category: "Productivity"
466
+ }))
467
+ })
468
+ };
469
+ }
470
+ function copilotArtifact(metadata, plugins) {
471
+ return {
472
+ path: TARGET_CATALOG_PATHS.copilot,
473
+ content: json({
474
+ ...metadata,
475
+ metadata: { displayName: metadata.name },
476
+ plugins: plugins.map((plugin) => ({
477
+ name: plugin.name,
478
+ source: plugin.source,
479
+ ...commonMetadata(plugin)
480
+ }))
481
+ })
482
+ };
483
+ }
484
+ /** Cursor reads `.cursor-plugin/marketplace.json` at the repository root, with a shape close to
485
+ * Claude Code's. It is not an install path for the author: a developer tests through
486
+ * `~/.cursor/plugins/local/<name>`, and the catalog reaches users when an admin imports the
487
+ * repository as a team marketplace (`.research/local-marketplaces`, E-CUR-M3, E-CUR-M4). */
488
+ function cursorArtifact(metadata, plugins) {
489
+ return {
490
+ path: TARGET_CATALOG_PATHS.cursor,
491
+ content: json({
492
+ ...metadata,
493
+ plugins: plugins.map((plugin) => ({
494
+ name: plugin.name,
495
+ source: plugin.source,
496
+ ...commonMetadata(plugin)
497
+ }))
498
+ })
499
+ };
500
+ }
501
+ /** The marketplace target each `--vendor` id catalogs into. Every vendor documents a
502
+ * repository-local catalog (`.research/local-marketplaces`). */
503
+ const VENDOR_TARGETS = {
504
+ "claude-code": "claude",
505
+ cursor: "cursor",
506
+ codex: "codex",
507
+ "copilot-cli": "copilot"
508
+ };
509
+ /** Folds one plugin's entry into a catalog that may already exist, and returns the artifact to
510
+ * write. An existing catalog keeps its own top-level fields — its name, its owner, a description
511
+ * someone wrote — and every entry it lists for other plugins, in place. Only this plugin's entry is
512
+ * re-derived.
513
+ *
514
+ * `version` is derived, never authored (ADR-0010 §3): the entry carries whatever the canonical
515
+ * manifest carries, and a version left behind on an entry whose manifest declares none is removed
516
+ * rather than kept. */
517
+ function mergeCatalogEntry(target, metadata, plugin, readExisting) {
518
+ const artifact = serializeTarget(target, metadata, [plugin])[0];
519
+ const existing = readExisting(artifact.path);
520
+ if (existing === void 0) return artifact;
521
+ const previous = parseCatalog(existing, artifact.path);
522
+ const generated = JSON.parse(artifact.content);
523
+ const entry = generated.plugins[0];
524
+ const merged = { ...previous };
525
+ for (const [key, value] of Object.entries(generated)) if (!(key in merged)) merged[key] = value;
526
+ merged.plugins = mergeEntries(previous, entry);
527
+ return {
528
+ path: artifact.path,
529
+ content: json(merged)
530
+ };
531
+ }
532
+ function parseCatalog(content, path) {
533
+ let parsed;
534
+ try {
535
+ parsed = JSON.parse(content);
536
+ } catch {
537
+ throw new Error(`error: existing catalog "${path}" is not valid JSON`);
538
+ }
539
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`error: existing catalog "${path}" is not a JSON object`);
540
+ return parsed;
541
+ }
542
+ function mergeEntries(previous, entry) {
543
+ const entries = Array.isArray(previous.plugins) ? [...previous.plugins] : [];
544
+ const index = entries.findIndex((candidate) => typeof candidate === "object" && candidate !== null && candidate.name === entry.name);
545
+ if (index === -1) return [...entries, entry];
546
+ const merged = {
547
+ ...entries[index],
548
+ ...entry
549
+ };
550
+ if (!("version" in entry)) delete merged.version;
551
+ entries[index] = merged;
552
+ return entries;
553
+ }
554
+ /** The catalog's own top-level identity, read back from the file the repository already carries, so
555
+ * a refresh re-derives one entry without proposing a name or an owner of its own. */
556
+ function existingMetadata(previous) {
557
+ const name = typeof previous.name === "string" ? previous.name : "";
558
+ const owner = previous.owner;
559
+ if (typeof owner === "object" && owner !== null && typeof owner.name === "string") return {
560
+ name,
561
+ owner
562
+ };
563
+ return {
564
+ name,
565
+ owner: { name }
566
+ };
567
+ }
568
+ /** Re-derives one plugin's entry inside a catalog the repository already carries. Update only, in two
569
+ * senses: it never creates a catalog — `plugin init --vendor` and `marketplace init` own that — and
570
+ * it adds nothing to the catalog's top level, not even a `$schema` the file happens to lack. Only
571
+ * the entry changes, so a build can run it unconditionally (ADR-0014). */
572
+ function refreshCatalogEntry(target, plugin, existing) {
573
+ const catalogPath = TARGET_CATALOG_PATHS[target];
574
+ const previous = parseCatalog(existing, catalogPath);
575
+ const entry = JSON.parse(serializeTarget(target, existingMetadata(previous), [plugin])[0]?.content ?? "{}").plugins[0];
576
+ return {
577
+ path: catalogPath,
578
+ content: json({
579
+ ...previous,
580
+ plugins: mergeEntries(previous, entry)
581
+ })
582
+ };
583
+ }
584
+ /** Whether two catalogs say the same thing. Key order and whitespace do not change a catalog's
585
+ * meaning — the repository formats its own JSON — so a refresh compares this rather than bytes and
586
+ * leaves a file it agrees with alone. Array order does count. */
587
+ function sameCatalogContent(a, b) {
588
+ try {
589
+ return JSON.stringify(canonicalJson(JSON.parse(a))) === JSON.stringify(canonicalJson(JSON.parse(b)));
590
+ } catch {
591
+ return false;
592
+ }
593
+ }
594
+ function canonicalJson(value) {
595
+ if (Array.isArray(value)) return value.map(canonicalJson);
596
+ if (typeof value !== "object" || value === null) return value;
597
+ const record = value;
598
+ return Object.fromEntries(Object.keys(record).sort().map((key) => [key, canonicalJson(record[key])]));
599
+ }
600
+ function serializeTarget(target, metadata, plugins) {
601
+ switch (target) {
602
+ case "claude": return [claudeArtifact(metadata, plugins)];
603
+ case "codex": return [codexArtifact(metadata, plugins)];
604
+ case "copilot": return [copilotArtifact(metadata, plugins)];
605
+ case "cursor": return [cursorArtifact(metadata, plugins)];
606
+ }
607
+ }
608
+ //#endregion
609
+ //#region src/marketplace/fs.ts
610
+ const realMarketplaceFs = {
611
+ exists: fs.existsSync,
612
+ isDirectory(file) {
613
+ return fs.statSync(file).isDirectory();
614
+ },
615
+ realpath(file) {
616
+ return fs.realpathSync(file);
617
+ },
618
+ read(file) {
619
+ return fs.readFileSync(file, "utf8");
620
+ },
621
+ listEntries(dir) {
622
+ return fs.readdirSync(dir);
623
+ },
624
+ writeAtomically(file, content) {
625
+ fs.mkdirSync(path.dirname(file), { recursive: true });
626
+ const temporary = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
627
+ fs.writeFileSync(temporary, content);
628
+ fs.renameSync(temporary, file);
629
+ }
630
+ };
631
+ function gitToplevel(root) {
632
+ try {
633
+ const top = execFileSync("git", [
634
+ "-C",
635
+ root,
636
+ "rev-parse",
637
+ "--show-toplevel"
638
+ ], {
639
+ encoding: "utf8",
640
+ stdio: [
641
+ "ignore",
642
+ "pipe",
643
+ "ignore"
644
+ ]
645
+ }).trim();
646
+ return top === "" ? void 0 : path.resolve(top);
647
+ } catch {
648
+ return;
649
+ }
650
+ }
651
+ function gatherCatalogRepo(root) {
652
+ const repoRoot = gitToplevel(root);
653
+ if (!repoRoot) return void 0;
654
+ const relative = path.relative(repoRoot, root);
655
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return void 0;
656
+ const catalogs = {};
657
+ for (const catalog of Object.values(TARGET_CATALOG_PATHS)) {
658
+ const file = path.join(repoRoot, catalog);
659
+ if (fs.existsSync(file)) catalogs[catalog] = fs.readFileSync(file, "utf8");
660
+ }
661
+ return {
662
+ root: repoRoot,
663
+ pluginPath: relative.split(path.sep).join("/"),
664
+ catalogs
665
+ };
666
+ }
667
+ //#endregion
196
668
  //#region src/build/build.ts
197
669
  /** Where each vendor reads its manifest, relative to the project root. Shared with
198
670
  * `plugin init --npm`, which wires exactly these paths into `package.json` `files`. */
@@ -206,6 +678,9 @@ const KNOWN_VENDORS = new Set(Object.keys(VENDOR_OUTPUT));
206
678
  /** Vendors the canonical root manifest serves as-is. The build derives no file for these — writing
207
679
  * one would either be shadowed by root (a lower-precedence path) or clobber root itself. */
208
680
  const CANONICAL_SERVED = new Set(["copilot-cli"]);
681
+ /** Where every vendor looks for a plugin's hooks when the manifest declares none
682
+ * (`.research/hook-event-survey/conclusion.md`). */
683
+ const DEFAULT_HOOKS_PATH = "./hooks/hooks.json";
209
684
  const UP_NAMESPACE$1 = "org.cyberuni.universal-plugin";
210
685
  /** Reads universal-plugin's config block from the canonical manifest's extensions map. */
211
686
  function universalPluginExtension(manifest) {
@@ -213,8 +688,8 @@ function universalPluginExtension(manifest) {
213
688
  }
214
689
  function readManifest(root) {
215
690
  const manifestPath = path.join(root, "plugin.json");
216
- if (!fsNode.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
217
- return JSON.parse(fsNode.readFileSync(manifestPath, "utf8"));
691
+ if (!fs.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
692
+ return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
218
693
  }
219
694
  /** Validates the manifest. Vendor rules apply only to the vendors actually being built: pass
220
695
  * `targets` to scope the check (build passes its selected targets), else it defaults to the
@@ -227,12 +702,13 @@ function validateManifest(manifest, targets) {
227
702
  const codexTargeted = (targets ?? uext.vendors ?? Object.keys(harnesses)).includes("codex") && Boolean(harnesses["codex"]);
228
703
  if (codexTargeted && !manifest.description) errors.push("description is required when targeting codex");
229
704
  if (codexTargeted && !manifest.version) errors.push("version is required when targeting codex");
705
+ errors.push(...validateDependencies(uext.dependencies).errors);
230
706
  return errors;
231
707
  }
232
708
  function buildPlugin(root, opts = {}) {
233
709
  const manifestPath = path.join(root, "plugin.json");
234
- if (!fsNode.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
235
- const indent = detectIndent(fsNode.readFileSync(manifestPath, "utf8"));
710
+ if (!fs.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
711
+ const indent = detectIndent(fs.readFileSync(manifestPath, "utf8"));
236
712
  const manifest = readManifest(root);
237
713
  const warnings = [];
238
714
  const rows = [];
@@ -261,6 +737,7 @@ function buildPlugin(root, opts = {}) {
261
737
  written: [],
262
738
  warnings,
263
739
  rows,
740
+ catalogs: [],
264
741
  summary: summarize(rows)
265
742
  };
266
743
  }
@@ -268,8 +745,10 @@ function buildPlugin(root, opts = {}) {
268
745
  if (errors.length > 0) throw new Error(`plugin.json validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
269
746
  const written = [];
270
747
  const { $schema: _schema, extensions: _extensions, ...metadata } = manifest;
271
- const { vendors: _vendors, packagePath: _packagePath, harnesses: _harnesses, ...componentConfig } = uext;
748
+ const { vendors: _vendors, packagePath: _packagePath, harnesses: _harnesses, dependencies: declaredDependencies, ...componentConfig } = uext;
749
+ warnings.push(...validateDependencies(declaredDependencies).warnings);
272
750
  const skills = readSkills(root, manifest);
751
+ const canonicalHooks = readCanonicalHooks(root, componentConfig["hooks"], warnings);
273
752
  for (const vendor of vendors) {
274
753
  const relPath = VENDOR_OUTPUT[vendor];
275
754
  const outputPath = path.join(root, relPath);
@@ -280,10 +759,15 @@ function buildPlugin(root, opts = {}) {
280
759
  ...componentConfig,
281
760
  ...vendorFields
282
761
  };
762
+ const hooks = canonicalHooks ? translateHooks(canonicalHooks, vendor) : null;
763
+ const dependencies = translateDependencies(declaredDependencies ?? [], vendor);
764
+ warnings.push(...dependencies.warnings);
765
+ if (dependencies.dependencies) vendorManifest["dependencies"] = dependencies.dependencies;
283
766
  if (CANONICAL_SERVED.has(vendor)) {
767
+ for (const drop of dedupeDrops(hooks?.drops ?? [])) warnings.push(`${vendor} cannot run the "${drop.type}" hook handler on ${drop.event} — it is ignored at runtime`);
284
768
  const overrides = Object.keys(vendorFields);
285
769
  if (overrides.length > 0) warnings.push(`harnesses.${vendor} sets ${overrides.join(", ")}, but ${vendor} reads the canonical plugin.json directly — these fields are not delivered`);
286
- writeSkillArtifacts(root, vendor, skills, opts, written, warnings);
770
+ writeSkillArtifacts(vendor, skills, opts, written, warnings);
287
771
  rows.push({
288
772
  vendor,
289
773
  path: relPath,
@@ -291,18 +775,26 @@ function buildPlugin(root, opts = {}) {
291
775
  });
292
776
  continue;
293
777
  }
778
+ for (const drop of dedupeDrops(hooks?.drops ?? [])) warnings.push(`${vendor} cannot run the "${drop.type}" hook handler on ${drop.event} — dropped from the derived hooks file`);
779
+ const derivedHooksPath = path.join(outputDir, "hooks.json");
780
+ if (hooks?.changed) if (hooks.hooks) vendorManifest["hooks"] = `./${path.dirname(relPath).split(path.sep).join("/")}/hooks.json`;
781
+ else delete vendorManifest["hooks"];
294
782
  if (opts.verbose) {
295
783
  console.log(`[${vendor}] → ${outputPath}`);
296
784
  for (const key of Object.keys(vendorFields)) console.log(` + ${key} (from harnesses.${vendor})`);
297
785
  }
298
786
  try {
299
787
  if (!opts.dryRun) {
300
- if (opts.clean && fsNode.existsSync(outputPath)) fsNode.unlinkSync(outputPath);
301
- fsNode.mkdirSync(outputDir, { recursive: true });
302
- fsNode.writeFileSync(outputPath, `${JSON.stringify(vendorManifest, null, indent)}\n`);
788
+ if (opts.clean && fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
789
+ fs.mkdirSync(outputDir, { recursive: true });
790
+ fs.writeFileSync(outputPath, `${JSON.stringify(vendorManifest, null, indent)}\n`);
303
791
  }
304
792
  written.push(outputPath);
305
- writeSkillArtifacts(root, vendor, skills, opts, written, warnings);
793
+ if (hooks?.changed) {
794
+ if (hooks.hooks) writeArtifact(derivedHooksPath, `${JSON.stringify(hooks.hooks, null, indent)}\n`, opts, written);
795
+ else if (!opts.dryRun && fs.existsSync(derivedHooksPath)) fs.unlinkSync(derivedHooksPath);
796
+ }
797
+ writeSkillArtifacts(vendor, skills, opts, written, warnings);
306
798
  rows.push({
307
799
  vendor,
308
800
  path: relPath,
@@ -317,15 +809,74 @@ function buildPlugin(root, opts = {}) {
317
809
  });
318
810
  }
319
811
  }
812
+ const catalogs = refreshCatalogs(root, manifest, vendors, opts, written, warnings);
320
813
  return {
321
814
  vendors,
322
815
  written,
323
816
  warnings,
324
817
  rows,
818
+ catalogs,
325
819
  summary: summarize(rows)
326
820
  };
327
821
  }
328
- function writeSkillArtifacts(root, vendor, skills, opts, written, warnings) {
822
+ /** Keeps the repository's marketplace catalogs true to the manifest just built. A catalog entry's
823
+ * version is copied from the canonical manifest and never authored (ADR-0010 §3), so the build that
824
+ * moves the manifest is what re-derives it — otherwise the entry keeps whatever version it was
825
+ * written with while the plugin moves on.
826
+ *
827
+ * Only a catalog the repository already carries is touched, and only this plugin's entry inside it.
828
+ * Creating one is a choice `plugin init --vendor` and `marketplace init` own; a build makes no new
829
+ * files at the repository root. */
830
+ function refreshCatalogs(root, manifest, vendors, opts, written, warnings) {
831
+ const repo = gatherCatalogRepo(root);
832
+ if (!repo) return [];
833
+ const source = repo.pluginPath === "" ? "./" : `./${repo.pluginPath}`;
834
+ const plugin = {
835
+ name: manifest.name,
836
+ source,
837
+ metadata: manifest
838
+ };
839
+ const rows = [];
840
+ const seen = /* @__PURE__ */ new Set();
841
+ for (const vendor of vendors) {
842
+ const target = VENDOR_TARGETS[vendor];
843
+ if (!target) continue;
844
+ const relative = TARGET_CATALOG_PATHS[target];
845
+ if (seen.has(relative)) continue;
846
+ seen.add(relative);
847
+ const existing = repo.catalogs[relative];
848
+ if (existing === void 0) continue;
849
+ try {
850
+ const artifact = refreshCatalogEntry(target, plugin, existing);
851
+ if (sameCatalogContent(artifact.content, existing)) {
852
+ rows.push({
853
+ path: relative,
854
+ status: "unchanged"
855
+ });
856
+ continue;
857
+ }
858
+ if (opts.dryRun) {
859
+ rows.push({
860
+ path: relative,
861
+ status: "planned"
862
+ });
863
+ continue;
864
+ }
865
+ const file = path.join(repo.root, relative);
866
+ const content = `${JSON.stringify(JSON.parse(artifact.content), null, detectIndent(existing))}\n`;
867
+ fs.writeFileSync(file, content);
868
+ written.push(file);
869
+ rows.push({
870
+ path: relative,
871
+ status: "updated"
872
+ });
873
+ } catch (err) {
874
+ warnings.push(`Failed to refresh "${relative}": ${err instanceof Error ? err.message : String(err)}`);
875
+ }
876
+ }
877
+ return rows;
878
+ }
879
+ function writeSkillArtifacts(vendor, skills, opts, written, warnings) {
329
880
  for (const skill of skills) {
330
881
  if (vendor === "claude-code") {
331
882
  writeClaudeSkill(skill, opts, written);
@@ -344,18 +895,18 @@ function readSkills(root, manifest) {
344
895
  const skillsCfg = universalPluginExtension(manifest).skills;
345
896
  const skillsPath = typeof skillsCfg === "string" ? skillsCfg : "./skills/";
346
897
  const skillsDir = path.resolve(root, skillsPath);
347
- if (!fsNode.existsSync(skillsDir)) return [];
898
+ if (!fs.existsSync(skillsDir)) return [];
348
899
  return listSkillFiles(skillsDir).map((skillPath) => parseSkill(skillPath));
349
900
  }
350
901
  function listSkillFiles(dir) {
351
- return fsNode.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
902
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
352
903
  const entryPath = path.join(dir, entry.name);
353
904
  if (entry.isDirectory()) return listSkillFiles(entryPath);
354
905
  return entry.isFile() && entry.name === "SKILL.md" ? [entryPath] : [];
355
906
  });
356
907
  }
357
908
  function parseSkill(skillPath) {
358
- const content = fsNode.readFileSync(skillPath, "utf8");
909
+ const content = fs.readFileSync(skillPath, "utf8");
359
910
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
360
911
  const frontmatter = match?.[1] ?? "";
361
912
  const rawPolicy = frontmatter.match(/^invocation-policy:\s*['"]?(user|model|both)['"]?\s*(?:#.*)?$/m)?.[1];
@@ -374,7 +925,7 @@ function writeClaudeSkill(skill, opts, written) {
374
925
  if (!skill.hasInvocationPolicy) return;
375
926
  const content = withClaudeInvocationFlags(skill);
376
927
  if (content === skill.content) return;
377
- if (!opts.dryRun) fsNode.writeFileSync(skill.path, content);
928
+ if (!opts.dryRun) fs.writeFileSync(skill.path, content);
378
929
  written.push(skill.path);
379
930
  }
380
931
  function withClaudeInvocationFlags(skill) {
@@ -387,9 +938,9 @@ function withClaudeInvocationFlags(skill) {
387
938
  }
388
939
  function writeArtifact(outputPath, content, opts, written) {
389
940
  if (!opts.dryRun) {
390
- if (opts.clean && fsNode.existsSync(outputPath)) fsNode.unlinkSync(outputPath);
391
- fsNode.mkdirSync(path.dirname(outputPath), { recursive: true });
392
- fsNode.writeFileSync(outputPath, content);
941
+ if (opts.clean && fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
942
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
943
+ fs.writeFileSync(outputPath, content);
393
944
  }
394
945
  written.push(outputPath);
395
946
  }
@@ -401,6 +952,44 @@ function summarize(rows) {
401
952
  canonical: rows.filter((r) => r.status === "canonical").length
402
953
  };
403
954
  }
955
+ /** Resolves the canonical hooks declaration — a path, a list of paths, or an inline block — into one
956
+ * hooks document. Returns null when there is nothing to translate; an unreadable declaration warns
957
+ * and leaves the declaration to pass through untouched. */
958
+ function readCanonicalHooks(root, declaration, warnings) {
959
+ if (declaration && typeof declaration === "object" && "hooks" in declaration) return declaration;
960
+ const paths = (typeof declaration === "string" ? [declaration] : declaration && typeof declaration === "object" && Array.isArray(declaration.paths) ? declaration.paths ?? [] : null) ?? (fs.existsSync(path.resolve(root, DEFAULT_HOOKS_PATH)) ? [DEFAULT_HOOKS_PATH] : []);
961
+ if (paths.length === 0) return null;
962
+ const merged = { hooks: {} };
963
+ let read = 0;
964
+ for (const relPath of paths) {
965
+ const hooksPath = path.resolve(root, relPath);
966
+ if (!fs.existsSync(hooksPath)) {
967
+ warnings.push(`hooks file "${relPath}" not found — left untranslated`);
968
+ continue;
969
+ }
970
+ let parsed;
971
+ try {
972
+ parsed = JSON.parse(fs.readFileSync(hooksPath, "utf8"));
973
+ } catch (err) {
974
+ warnings.push(`hooks file "${relPath}" could not be read — left untranslated: ${err instanceof Error ? err.message : String(err)}`);
975
+ continue;
976
+ }
977
+ read++;
978
+ for (const [event, rules] of Object.entries(parsed.hooks ?? {})) merged.hooks[event] = [...merged.hooks[event] ?? [], ...rules];
979
+ }
980
+ return read === 0 ? null : merged;
981
+ }
982
+ /** One warning per event and handler type — three dropped prompt handlers on one event are one loss
983
+ * to fix, not three. */
984
+ function dedupeDrops(drops) {
985
+ const seen = /* @__PURE__ */ new Set();
986
+ return drops.filter((drop) => {
987
+ const key = `${drop.event}\u0000${drop.type}`;
988
+ if (seen.has(key)) return false;
989
+ seen.add(key);
990
+ return true;
991
+ });
992
+ }
404
993
  //#endregion
405
994
  //#region src/build/cli.ts
406
995
  const NEXT_STEP$2 = "→ universal-plugin plugin validate\n";
@@ -421,17 +1010,23 @@ function buildCommand() {
421
1010
  skipped: result.rows.filter((r) => r.status === "skipped"),
422
1011
  failed: result.rows.filter((r) => r.status === "failed"),
423
1012
  canonical: result.rows.filter((r) => r.status === "canonical"),
1013
+ catalogs: result.catalogs,
424
1014
  summary: result.summary,
425
1015
  warnings: result.warnings
426
1016
  };
427
1017
  const counts = `built ${built}, skipped ${skipped}, failed ${failed}`;
1018
+ const catalogSummary = result.catalogs.length > 0 ? `, catalogs ${result.catalogs.length}` : "";
428
1019
  output(jsonResult, {
429
1020
  vendors: result.rows.map((r) => ({
430
1021
  vendor: r.vendor,
431
1022
  path: r.path,
432
1023
  status: r.status
433
1024
  })),
434
- summary: canonical > 0 ? `${counts}, served by plugin.json ${canonical}` : counts
1025
+ catalogs: result.catalogs.map((r) => ({
1026
+ path: r.path,
1027
+ status: r.status
1028
+ })),
1029
+ summary: (canonical > 0 ? `${counts}, served by plugin.json ${canonical}` : counts) + catalogSummary
435
1030
  });
436
1031
  process.stderr.write(NEXT_STEP$2);
437
1032
  if (failed > 0) process.exitCode = 1;
@@ -459,9 +1054,9 @@ function resolveSkillsDir(root, manifestSkills) {
459
1054
  return path.join(root, manifestSkills ?? "skills/");
460
1055
  }
461
1056
  function walk(dir) {
462
- if (!fsNode.existsSync(dir)) return [];
1057
+ if (!fs.existsSync(dir)) return [];
463
1058
  const files = [];
464
- for (const entry of fsNode.readdirSync(dir, { withFileTypes: true })) {
1059
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
465
1060
  const entryPath = path.join(dir, entry.name);
466
1061
  if (entry.isDirectory()) files.push(...walk(entryPath));
467
1062
  else if (entry.isFile() && TEXT_EXTENSIONS.has(path.extname(entry.name))) files.push(entryPath);
@@ -471,8 +1066,8 @@ function walk(dir) {
471
1066
  function realPinFs(skillsDir) {
472
1067
  return {
473
1068
  listSkillFiles: () => walk(skillsDir),
474
- readFile: (p) => fsNode.readFileSync(p, "utf8"),
475
- writeFile: (p, c) => fsNode.writeFileSync(p, c, "utf8")
1069
+ readFile: (p) => fs.readFileSync(p, "utf8"),
1070
+ writeFile: (p, c) => fs.writeFileSync(p, c, "utf8")
476
1071
  };
477
1072
  }
478
1073
  //#endregion
@@ -606,7 +1201,7 @@ const DEFAULT_GLOBS = ["packages/*"];
606
1201
  function findMonorepoRoot(start) {
607
1202
  let dir = start;
608
1203
  for (;;) {
609
- if (fsNode.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
1204
+ if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
610
1205
  const parent = path.dirname(dir);
611
1206
  if (parent === dir) return start;
612
1207
  dir = parent;
@@ -635,9 +1230,9 @@ function parseWorkspaceGlobs(yamlText) {
635
1230
  }
636
1231
  function readWorkspaceGlobs(monorepoRoot) {
637
1232
  const yamlPath = path.join(monorepoRoot, "pnpm-workspace.yaml");
638
- if (!fsNode.existsSync(yamlPath)) return DEFAULT_GLOBS;
1233
+ if (!fs.existsSync(yamlPath)) return DEFAULT_GLOBS;
639
1234
  try {
640
- const globs = parseWorkspaceGlobs(fsNode.readFileSync(yamlPath, "utf8"));
1235
+ const globs = parseWorkspaceGlobs(fs.readFileSync(yamlPath, "utf8"));
641
1236
  return globs.length > 0 ? globs : DEFAULT_GLOBS;
642
1237
  } catch {
643
1238
  return DEFAULT_GLOBS;
@@ -648,11 +1243,11 @@ function readWorkspaceGlobs(monorepoRoot) {
648
1243
  function expandGlob(monorepoRoot, glob) {
649
1244
  if (glob.endsWith("/*")) {
650
1245
  const base = path.join(monorepoRoot, glob.slice(0, -2));
651
- if (!fsNode.existsSync(base)) return [];
652
- return fsNode.readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(base, entry.name));
1246
+ if (!fs.existsSync(base)) return [];
1247
+ return fs.readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(base, entry.name));
653
1248
  }
654
1249
  const dir = path.join(monorepoRoot, glob);
655
- return fsNode.existsSync(dir) ? [dir] : [];
1250
+ return fs.existsSync(dir) ? [dir] : [];
656
1251
  }
657
1252
  /** Discovers every workspace member's version, keyed by its `package.json` `name` field (falling
658
1253
  * back to the directory name when `package.json` is missing or unreadable — registering the
@@ -666,7 +1261,7 @@ function discoverWorkspace(root) {
666
1261
  let name = path.basename(dir);
667
1262
  let version;
668
1263
  try {
669
- const pkg = JSON.parse(fsNode.readFileSync(path.join(dir, "package.json"), "utf8"));
1264
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
670
1265
  if (typeof pkg.name === "string") name = pkg.name;
671
1266
  if (typeof pkg.version === "string") version = pkg.version;
672
1267
  } catch {}
@@ -684,11 +1279,11 @@ function writePinsMap(root, pins) {
684
1279
  const sorted = {};
685
1280
  for (const key of Object.keys(map).sort((a, b) => a.localeCompare(b))) sorted[key] = map[key];
686
1281
  const dir = path.join(root, ".plugin");
687
- fsNode.mkdirSync(dir, { recursive: true });
1282
+ fs.mkdirSync(dir, { recursive: true });
688
1283
  const pinsPath = path.join(dir, "pins.json");
689
- const existing = fsNode.existsSync(pinsPath) ? fsNode.readFileSync(pinsPath, "utf8") : null;
1284
+ const existing = fs.existsSync(pinsPath) ? fs.readFileSync(pinsPath, "utf8") : null;
690
1285
  const indent = existing ? detectIndent(existing) : " ";
691
- fsNode.writeFileSync(pinsPath, `${JSON.stringify(sorted, null, indent)}\n`);
1286
+ fs.writeFileSync(pinsPath, `${JSON.stringify(sorted, null, indent)}\n`);
692
1287
  }
693
1288
  function realVersionSource(workspace) {
694
1289
  return { resolve(pkg) {
@@ -797,14 +1392,14 @@ function configPath(root) {
797
1392
  const realConfigFs = {
798
1393
  read(root) {
799
1394
  const file = configPath(root);
800
- if (!fsNode.existsSync(file)) return {};
801
- return JSON.parse(fsNode.readFileSync(file, "utf8"));
1395
+ if (!fs.existsSync(file)) return {};
1396
+ return JSON.parse(fs.readFileSync(file, "utf8"));
802
1397
  },
803
1398
  write(root, config) {
804
1399
  const file = configPath(root);
805
- const indent = fsNode.existsSync(file) ? detectIndent(fsNode.readFileSync(file, "utf8")) : " ";
806
- fsNode.mkdirSync(path.dirname(file), { recursive: true });
807
- fsNode.writeFileSync(file, `${JSON.stringify(config, null, indent)}\n`);
1400
+ const indent = fs.existsSync(file) ? detectIndent(fs.readFileSync(file, "utf8")) : " ";
1401
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1402
+ fs.writeFileSync(file, `${JSON.stringify(config, null, indent)}\n`);
808
1403
  }
809
1404
  };
810
1405
  //#endregion
@@ -866,11 +1461,11 @@ function configCommand(fs = realConfigFs) {
866
1461
  //#endregion
867
1462
  //#region src/governance/fs.ts
868
1463
  const realGovernanceFs = {
869
- exists: (p) => fsNode.existsSync(p),
870
- read: (p) => fsNode.readFileSync(p, "utf8"),
1464
+ exists: (p) => fs.existsSync(p),
1465
+ read: (p) => fs.readFileSync(p, "utf8"),
871
1466
  list: (dir) => {
872
- if (!fsNode.existsSync(dir)) return [];
873
- return fsNode.readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => f.slice(0, -3));
1467
+ if (!fs.existsSync(dir)) return [];
1468
+ return fs.readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => f.slice(0, -3));
874
1469
  }
875
1470
  };
876
1471
  //#endregion
@@ -973,7 +1568,7 @@ function listGovernances(root, govFs) {
973
1568
  function readGlobalState() {
974
1569
  const p = path.join(os.homedir(), ".agents", "universal-plugin.json");
975
1570
  try {
976
- return mergeSafeState(JSON.parse(fsNode.readFileSync(p, "utf8")));
1571
+ return mergeSafeState(JSON.parse(fs.readFileSync(p, "utf8")));
977
1572
  } catch {
978
1573
  return emptyState();
979
1574
  }
@@ -1007,6 +1602,45 @@ function governanceCommand() {
1007
1602
  }
1008
1603
  //#endregion
1009
1604
  //#region src/init/fs.ts
1605
+ function git(root, args) {
1606
+ try {
1607
+ return execFileSync("git", [
1608
+ "-C",
1609
+ root,
1610
+ ...args
1611
+ ], {
1612
+ encoding: "utf8",
1613
+ stdio: [
1614
+ "ignore",
1615
+ "pipe",
1616
+ "ignore"
1617
+ ]
1618
+ }).trim();
1619
+ } catch {
1620
+ return null;
1621
+ }
1622
+ }
1623
+ /** Where the plugin sits in its repository, and the catalogs that repository already carries.
1624
+ * `undefined` outside a repository, which is the one case with nowhere to put a catalog. The
1625
+ * repository-and-catalogs half is shared with `plugin build`, which refreshes the same files. */
1626
+ function gatherRepo(root) {
1627
+ const repo = gatherCatalogRepo(root);
1628
+ if (!repo) return void 0;
1629
+ const match = git(root, [
1630
+ "remote",
1631
+ "get-url",
1632
+ "origin"
1633
+ ])?.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?$/);
1634
+ return {
1635
+ pluginPath: repo.pluginPath,
1636
+ dirName: path.basename(repo.root),
1637
+ slug: match ? {
1638
+ owner: match[1],
1639
+ repo: match[2]
1640
+ } : void 0,
1641
+ catalogs: repo.catalogs
1642
+ };
1643
+ }
1010
1644
  function manifestPath(root) {
1011
1645
  return path.join(root, "plugin.json");
1012
1646
  }
@@ -1016,26 +1650,32 @@ function packageJsonPath(root) {
1016
1650
  const realInitFs = {
1017
1651
  gather(root) {
1018
1652
  const pj = packageJsonPath(root);
1019
- const packageJson = fsNode.existsSync(pj) ? JSON.parse(fsNode.readFileSync(pj, "utf8")) : null;
1653
+ const packageJson = fs.existsSync(pj) ? JSON.parse(fs.readFileSync(pj, "utf8")) : null;
1020
1654
  return {
1021
- manifestExists: fsNode.existsSync(manifestPath(root)),
1022
- packageJson
1655
+ manifestExists: fs.existsSync(manifestPath(root)),
1656
+ packageJson,
1657
+ repo: gatherRepo(root)
1023
1658
  };
1024
1659
  },
1025
1660
  apply(root, plan) {
1026
- fsNode.writeFileSync(manifestPath(root), `${JSON.stringify(plan.manifest, null, " ")}\n`);
1027
- for (const dir of plan.dirs) fsNode.mkdirSync(path.join(root, dir), { recursive: true });
1661
+ fs.writeFileSync(manifestPath(root), `${JSON.stringify(plan.manifest, null, " ")}\n`);
1662
+ for (const dir of plan.dirs) fs.mkdirSync(path.join(root, dir), { recursive: true });
1663
+ for (const catalog of plan.catalogs) {
1664
+ const file = path.join(root, catalog.path);
1665
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1666
+ fs.writeFileSync(file, catalog.content);
1667
+ }
1028
1668
  if (plan.packageJson) {
1029
1669
  const pj = packageJsonPath(root);
1030
- const indent = detectIndent(fsNode.readFileSync(pj, "utf8"));
1031
- fsNode.writeFileSync(pj, `${JSON.stringify(plan.packageJson, null, indent)}\n`);
1670
+ const indent = detectIndent(fs.readFileSync(pj, "utf8"));
1671
+ fs.writeFileSync(pj, `${JSON.stringify(plan.packageJson, null, indent)}\n`);
1032
1672
  }
1033
1673
  }
1034
1674
  };
1035
1675
  //#endregion
1036
1676
  //#region src/init/init.ts
1037
- /** Pure domain for `plugin init` — scaffolding the canonical manifest and (with `--npm`)
1038
- * wiring an npm package to ship it.
1677
+ /** Pure domain for `plugin init` — scaffolding the canonical manifest, registering the plugin in the
1678
+ * repository's local marketplace catalogs, and (with `--npm`) wiring an npm package to ship it.
1039
1679
  *
1040
1680
  * No I/O: the caller gathers the current filesystem state, calls `planInit`, and applies the
1041
1681
  * returned plan. `planInit` owns the rules — the guard order, the closed manifest shape, the
@@ -1077,6 +1717,71 @@ function wireFiles(pkg, manifestPaths) {
1077
1717
  files
1078
1718
  };
1079
1719
  }
1720
+ /** The local marketplace a repository carries is named after the repository, not after the plugin:
1721
+ * the catalog sits at the repository root and lists every plugin the repository develops. `-local`
1722
+ * separates it from a published marketplace of the same plugins. */
1723
+ function marketplaceName(repo) {
1724
+ return `${repo.slug ? `${repo.slug.owner}-${repo.slug.repo}` : repo.dirName}-local`;
1725
+ }
1726
+ /** An npm `author` — `"Bea <bea@example.com> (https://example.com)"` or the object form — read as a
1727
+ * catalog owner. */
1728
+ function readAuthor(author) {
1729
+ if (typeof author === "string") {
1730
+ const name = author.replace(/[<(].*$/, "").trim();
1731
+ return name === "" ? void 0 : { name };
1732
+ }
1733
+ if (typeof author !== "object" || author === null) return void 0;
1734
+ const record = author;
1735
+ if (typeof record.name !== "string" || record.name.trim() === "") return void 0;
1736
+ const owner = { name: record.name };
1737
+ if (typeof record.email === "string") owner.email = record.email;
1738
+ if (typeof record.url === "string") owner.url = record.url;
1739
+ return owner;
1740
+ }
1741
+ /** Who the catalog says maintains the marketplace. The canonical manifest first, then the package
1742
+ * that ships it, then the account the repository lives under. Every runtime requires this, so a
1743
+ * repository with none of the three gets no catalog rather than an invented owner. */
1744
+ function catalogOwner(state, repo, manifest) {
1745
+ return readAuthor(manifest.author) ?? readAuthor(state.packageJson?.author) ?? (repo.slug ? { name: repo.slug.owner } : void 0);
1746
+ }
1747
+ /** The plugin's own entry in every selected vendor's catalog, folded into whatever the repository
1748
+ * already carries. Nothing here authors a version: the entry carries the canonical manifest's, and
1749
+ * the manifest `init` writes carries none (ADR-0010). */
1750
+ function planCatalogs(state, opts, manifest, notes) {
1751
+ if (opts.marketplace === false || opts.vendors.length === 0) return [];
1752
+ const repo = state.repo;
1753
+ if (!repo) {
1754
+ notes.push("no local marketplace catalog: the plugin root is not inside a repository");
1755
+ return [];
1756
+ }
1757
+ const owner = catalogOwner(state, repo, manifest);
1758
+ if (!owner) {
1759
+ notes.push("no local marketplace catalog: no owner to name — add an author to plugin.json");
1760
+ return [];
1761
+ }
1762
+ const metadata = {
1763
+ name: marketplaceName(repo),
1764
+ owner
1765
+ };
1766
+ const source = repo.pluginPath === "" ? "./" : `./${repo.pluginPath}`;
1767
+ const plugin = {
1768
+ name: manifest.name,
1769
+ source,
1770
+ metadata: manifest
1771
+ };
1772
+ const toPluginRoot = repo.pluginPath === "" ? "" : `${repo.pluginPath.split("/").map(() => "..").join("/")}/`;
1773
+ const catalogs = [];
1774
+ for (const vendor of opts.vendors) {
1775
+ const target = VENDOR_TARGETS[vendor];
1776
+ if (!target) continue;
1777
+ const artifact = mergeCatalogEntry(target, metadata, plugin, (path) => repo.catalogs[path]);
1778
+ catalogs.push({
1779
+ path: `${toPluginRoot}${artifact.path}`,
1780
+ content: artifact.content
1781
+ });
1782
+ }
1783
+ return catalogs;
1784
+ }
1080
1785
  /** Plans the init run. Throws on a guard failure (an existing manifest without `--force`; `--npm`
1081
1786
  * with no `package.json`) before returning any plan, so the caller writes nothing on a guard trip. */
1082
1787
  function planInit(state, opts, rootDirName, resolveManifestPath) {
@@ -1088,6 +1793,16 @@ function planInit(state, opts, rootDirName, resolveManifestPath) {
1088
1793
  path: "plugin.json",
1089
1794
  action: "created"
1090
1795
  }];
1796
+ const notes = [];
1797
+ const catalogs = planCatalogs(state, opts, manifest, notes);
1798
+ const existing = state.repo?.catalogs ?? {};
1799
+ for (const catalog of catalogs) {
1800
+ const previous = existing[catalog.path.replace(/^(\.\.\/)+/, "")];
1801
+ rows.push({
1802
+ path: catalog.path,
1803
+ action: previous === void 0 ? "created" : previous === catalog.content ? "unchanged" : "updated"
1804
+ });
1805
+ }
1091
1806
  let packageJson = null;
1092
1807
  if (opts.npm) {
1093
1808
  const manifestPaths = (opts.vendors.length > 0 ? opts.vendors : ["claude-code"]).map(resolveManifestPath).filter((p) => Boolean(p));
@@ -1099,26 +1814,30 @@ function planInit(state, opts, rootDirName, resolveManifestPath) {
1099
1814
  }
1100
1815
  const created = rows.filter((r) => r.action === "created").length;
1101
1816
  const updated = rows.filter((r) => r.action === "updated").length;
1817
+ const unchanged = rows.filter((r) => r.action === "unchanged").length;
1102
1818
  return {
1103
1819
  manifest,
1104
1820
  dirs,
1105
1821
  packageJson,
1822
+ catalogs,
1106
1823
  rows,
1824
+ notes,
1107
1825
  summary: {
1108
1826
  created,
1109
- updated
1827
+ updated,
1828
+ unchanged
1110
1829
  }
1111
1830
  };
1112
1831
  }
1113
1832
  //#endregion
1114
1833
  //#region src/init/cli.ts
1115
1834
  const NEXT_STEP = "→ add skills to skills/, then run universal-plugin plugin build\n";
1116
- function collect(value, previous) {
1835
+ function collect$1(value, previous) {
1117
1836
  return [...previous, value];
1118
1837
  }
1119
1838
  function initCommand$1(deps = { fs: realInitFs }) {
1120
1839
  const cmd = new Command("init").description("Scaffold the canonical plugin.json (with --npm, wire an npm package to ship it)");
1121
- cmd.option("--name <name>", "Plugin name (default: the root directory name)").option("--vendor <id>", "Target vendor; repeatable", collect, []).option("--scaffold", "Create the standard skills/ agents/ governances/ commands/ directories").option("--force", "Overwrite an existing plugin.json").option("--yes", "Non-interactive (compatibility no-op; init never prompts)").option("--npm", "Wire package.json 'files' to ship the derived vendor manifests").option("--format <format>", "Output format: json or toon (default: toon)").addOption(new Option("--json").hideHelp()).addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin plugin init --name my-plugin --scaffold\n").action((opts) => {
1840
+ cmd.option("--name <name>", "Plugin name (default: the root directory name)").option("--vendor <id>", "Target vendor; repeatable", collect$1, []).option("--scaffold", "Create the standard skills/ agents/ governances/ commands/ directories").option("--force", "Overwrite an existing plugin.json").option("--yes", "Non-interactive (compatibility no-op; init never prompts)").option("--npm", "Wire package.json 'files' to ship the derived vendor manifests").option("--no-marketplace", "Skip the repository's local marketplace catalogs").option("--format <format>", "Output format: json or toon (default: toon)").addOption(new Option("--json").hideHelp()).addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin plugin init --name my-plugin --scaffold\n").action((opts) => {
1122
1841
  try {
1123
1842
  const root = resolveRoot(opts.root);
1124
1843
  const plan = planInit(deps.fs.gather(root), {
@@ -1126,7 +1845,8 @@ function initCommand$1(deps = { fs: realInitFs }) {
1126
1845
  vendors: opts.vendor ?? [],
1127
1846
  scaffold: Boolean(opts.scaffold),
1128
1847
  force: Boolean(opts.force),
1129
- npm: Boolean(opts.npm)
1848
+ npm: Boolean(opts.npm),
1849
+ marketplace: opts.marketplace !== false
1130
1850
  }, path.basename(root), (vendor) => VENDOR_OUTPUT[vendor]);
1131
1851
  deps.fs.apply(root, plan);
1132
1852
  output({
@@ -1138,8 +1858,9 @@ function initCommand$1(deps = { fs: realInitFs }) {
1138
1858
  path: r.path,
1139
1859
  action: r.action
1140
1860
  })),
1141
- summary: `created ${plan.summary.created}, updated ${plan.summary.updated}`
1861
+ summary: `created ${plan.summary.created}, updated ${plan.summary.updated}, unchanged ${plan.summary.unchanged}`
1142
1862
  });
1863
+ for (const note of plan.notes) process.stderr.write(`${note}\n`);
1143
1864
  process.stderr.write(NEXT_STEP);
1144
1865
  } catch (err) {
1145
1866
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
@@ -1149,123 +1870,380 @@ function initCommand$1(deps = { fs: realInitFs }) {
1149
1870
  return cmd;
1150
1871
  }
1151
1872
  //#endregion
1152
- //#region src/marketplace/fs.ts
1153
- const realMarketplaceFs = {
1154
- exists: fsNode.existsSync,
1155
- isDirectory(file) {
1156
- return fsNode.statSync(file).isDirectory();
1157
- },
1158
- realpath(file) {
1159
- return fsNode.realpathSync(file);
1160
- },
1161
- read(file) {
1162
- return fsNode.readFileSync(file, "utf8");
1163
- },
1164
- listEntries(dir) {
1165
- return fsNode.readdirSync(dir);
1166
- },
1167
- writeAtomically(file, content) {
1168
- fsNode.mkdirSync(path.dirname(file), { recursive: true });
1169
- const temporary = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
1170
- fsNode.writeFileSync(temporary, content);
1171
- fsNode.renameSync(temporary, file);
1873
+ //#region src/vendor-registry/vendor-registry.ts
1874
+ function lookupVendor(registry, vendorId) {
1875
+ return registry[vendorId] ?? null;
1876
+ }
1877
+ function mergeRegistries(base, override) {
1878
+ const result = { ...base };
1879
+ for (const [id, config] of Object.entries(override)) result[id] = {
1880
+ ...base[id] ?? {},
1881
+ ...config
1882
+ };
1883
+ return result;
1884
+ }
1885
+ //#endregion
1886
+ //#region src/vendor-registry/fs.ts
1887
+ function bundledRegistryPath() {
1888
+ const thisFile = fileURLToPath(import.meta.url);
1889
+ return path.join(path.dirname(thisFile), "data", "vendors.json");
1890
+ }
1891
+ function userOverridePath() {
1892
+ return path.join(os.homedir(), ".agents", "universal-plugin-vendors.json");
1893
+ }
1894
+ function loadRegistry() {
1895
+ const bundled = JSON.parse(fs.readFileSync(bundledRegistryPath(), "utf8"));
1896
+ try {
1897
+ return mergeRegistries(bundled, JSON.parse(fs.readFileSync(userOverridePath(), "utf8")));
1898
+ } catch (err) {
1899
+ if (err.code === "ENOENT") return bundled;
1900
+ throw err;
1172
1901
  }
1173
- };
1902
+ }
1174
1903
  //#endregion
1175
- //#region src/marketplace/marketplace.ts
1176
- const COMMON_METADATA = [
1177
- "description",
1178
- "version",
1179
- "homepage",
1180
- "repository",
1181
- "license",
1182
- "keywords"
1183
- ];
1184
- function assertMarketplaceName(value, label) {
1185
- if (!/^[a-z0-9][a-z0-9._-]*$/i.test(value)) throw new Error(`error: ${label} "${value}" must contain only letters, digits, dots, underscores, or hyphens`);
1904
+ //#region src/install/fs.ts
1905
+ function expandHome$1(p) {
1906
+ return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
1186
1907
  }
1187
- function commonMetadata(plugin) {
1188
- const result = {};
1189
- for (const field of COMMON_METADATA) if (plugin.metadata[field] !== void 0) result[field] = plugin.metadata[field];
1190
- return result;
1908
+ /** Resolves a vendor's local plugin directory to an absolute path. */
1909
+ function resolveLocalDir(localPluginDir) {
1910
+ return localPluginDir === null ? null : path.resolve(expandHome$1(localPluginDir));
1191
1911
  }
1192
- function json(value) {
1193
- return `${JSON.stringify(value, null, 2)}\n`;
1912
+ /** Reads what occupies a destination. A symlink is reported with its resolved target so the plan
1913
+ * can tell our own install from someone else's; a directory is reported with the `name` of the
1914
+ * canonical manifest inside it, which is what identifies an earlier `--copy` install. */
1915
+ function readDestination(dest) {
1916
+ let stats;
1917
+ try {
1918
+ stats = fs.lstatSync(dest);
1919
+ } catch (err) {
1920
+ if (err.code === "ENOENT") return { kind: "absent" };
1921
+ throw err;
1922
+ }
1923
+ if (stats.isSymbolicLink()) try {
1924
+ return {
1925
+ kind: "symlink",
1926
+ target: fs.realpathSync(dest)
1927
+ };
1928
+ } catch {
1929
+ return {
1930
+ kind: "symlink",
1931
+ target: fs.readlinkSync(dest)
1932
+ };
1933
+ }
1934
+ if (!stats.isDirectory()) return { kind: "file" };
1935
+ return {
1936
+ kind: "directory",
1937
+ pluginName: readPluginName(path.join(dest, "plugin.json"))
1938
+ };
1194
1939
  }
1195
- function claudeArtifact(metadata, plugins) {
1940
+ function readPluginName(manifestPath) {
1941
+ try {
1942
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
1943
+ return typeof manifest.name === "string" ? manifest.name : null;
1944
+ } catch {
1945
+ return null;
1946
+ }
1947
+ }
1948
+ /** Entries a copy never carries: the working copy's history and its installed dependencies. Neither
1949
+ * is part of the plugin, and both dwarf it. */
1950
+ const COPY_EXCLUDED = new Set([".git", "node_modules"]);
1951
+ /** Applies one planned write. A replace removes the destination first, so a copy never merges into
1952
+ * what was there before. A copy dereferences symlinks — a skill linked into the tree has to travel
1953
+ * as its content, since the destination is read on its own. */
1954
+ function applyWrite(root, write) {
1955
+ if (write.replace) fs.rmSync(write.dest, {
1956
+ recursive: true,
1957
+ force: true
1958
+ });
1959
+ fs.mkdirSync(path.dirname(write.dest), { recursive: true });
1960
+ if (write.mode === "link") {
1961
+ fs.symlinkSync(root, write.dest, "dir");
1962
+ return;
1963
+ }
1964
+ fs.cpSync(root, write.dest, {
1965
+ recursive: true,
1966
+ dereference: true,
1967
+ filter: (source) => !COPY_EXCLUDED.has(path.basename(source))
1968
+ });
1969
+ }
1970
+ function applyRemoval(dest) {
1971
+ fs.rmSync(dest, {
1972
+ recursive: true,
1973
+ force: true
1974
+ });
1975
+ }
1976
+ //#endregion
1977
+ //#region src/install/install.ts
1978
+ /** Pure domain for `plugin install` / `plugin uninstall` — putting the plugin under development
1979
+ * into a runtime, and taking it back out.
1980
+ *
1981
+ * No I/O: the caller resolves each vendor's local plugin directory, reads what currently occupies
1982
+ * the destination, and applies the returned plan. The rules live here — how a mode resolves per
1983
+ * vendor, what counts as our own install, and when a destination is refused. */
1984
+ /** The destination this plugin occupies in a vendor's local plugin directory. */
1985
+ function destinationPath(dir, pluginName) {
1986
+ return path.join(dir, pluginName);
1987
+ }
1988
+ /** Whether a destination holds this plugin already — our symlink back to the root, or a directory
1989
+ * carrying this plugin's canonical manifest. Anything else belongs to someone else. */
1990
+ function isOurs(state, root, pluginName) {
1991
+ if (state.kind === "symlink") return state.target === root;
1992
+ if (state.kind === "directory") return state.pluginName === pluginName;
1993
+ return false;
1994
+ }
1995
+ function occupiedReason(state) {
1996
+ if (state.kind === "symlink") return `a symlink to ${state.target} is already there; --force replaces it`;
1997
+ if (state.kind === "directory") return "another plugin is already installed there; --force replaces it";
1998
+ return "a file is already there; --force replaces it";
1999
+ }
2000
+ /** The mode a vendor actually gets. Returns `null` when `--link` was demanded of a vendor that
2001
+ * rejects an out-of-tree symlink — the caller reports that as blocked rather than silently
2002
+ * copying, since the author asked for a live link and would not get one. */
2003
+ function resolveMode$1(mode, target) {
2004
+ if (mode === "copy") return "copy";
2005
+ if (target.link) return "link";
2006
+ return mode === "link" ? null : "copy";
2007
+ }
2008
+ /** Plans an install. Throws when a targeted vendor's derived manifest is missing — installing a
2009
+ * plugin whose manifests were never built hands the runtime a half-built plugin, and the author
2010
+ * finds out as a load failure rather than as an error here. */
2011
+ function planInstall(input) {
2012
+ const missing = input.targets.filter((t) => t.dir !== null && input.manifests[t.vendor] === false);
2013
+ if (missing.length > 0) {
2014
+ const paths = missing.map((t) => ` - ${t.manifestPath} (${t.vendor})`).join("\n");
2015
+ throw new Error(`No derived manifest for:\n${paths}\nRun universal-plugin plugin build first.`);
2016
+ }
2017
+ const rows = [];
2018
+ const writes = [];
2019
+ for (const target of input.targets) {
2020
+ if (target.dir === null) {
2021
+ rows.push({
2022
+ vendor: target.vendor,
2023
+ path: "-",
2024
+ action: "unsupported",
2025
+ reason: `${target.vendor} has no local plugin directory; install it from a repository-local marketplace instead`
2026
+ });
2027
+ continue;
2028
+ }
2029
+ const dest = destinationPath(target.dir, input.pluginName);
2030
+ const state = input.dest[target.vendor] ?? { kind: "absent" };
2031
+ const mode = resolveMode$1(input.mode, target);
2032
+ if (mode === null) {
2033
+ rows.push({
2034
+ vendor: target.vendor,
2035
+ path: dest,
2036
+ action: "blocked",
2037
+ reason: `${target.vendor} does not load a symlink from outside ${target.dir}; use --copy`
2038
+ });
2039
+ continue;
2040
+ }
2041
+ const ours = isOurs(state, input.root, input.pluginName);
2042
+ if (state.kind !== "absent" && !ours && !input.force) {
2043
+ rows.push({
2044
+ vendor: target.vendor,
2045
+ path: dest,
2046
+ action: "blocked",
2047
+ reason: occupiedReason(state)
2048
+ });
2049
+ continue;
2050
+ }
2051
+ if (ours && mode === "link" && state.kind === "symlink") {
2052
+ rows.push({
2053
+ vendor: target.vendor,
2054
+ path: dest,
2055
+ action: "unchanged"
2056
+ });
2057
+ continue;
2058
+ }
2059
+ writes.push({
2060
+ vendor: target.vendor,
2061
+ dest,
2062
+ mode,
2063
+ replace: state.kind !== "absent"
2064
+ });
2065
+ rows.push({
2066
+ vendor: target.vendor,
2067
+ path: dest,
2068
+ action: mode === "link" ? "linked" : "copied"
2069
+ });
2070
+ }
1196
2071
  return {
1197
- path: ".claude-plugin/marketplace.json",
1198
- content: json({
1199
- ...metadata,
1200
- plugins: plugins.map((plugin) => ({
1201
- name: plugin.name,
1202
- source: plugin.source,
1203
- ...commonMetadata(plugin)
1204
- }))
1205
- })
2072
+ rows,
2073
+ writes,
2074
+ summary: summarizeInstall(rows)
1206
2075
  };
1207
2076
  }
1208
- function codexArtifact(metadata, plugins) {
2077
+ /** Plans an uninstall. Never throws: a destination that was never installed is reported, not an
2078
+ * error, so `uninstall` is safe to run twice and safe to run over a partial install. */
2079
+ function planUninstall(input) {
2080
+ const rows = [];
2081
+ const removals = [];
2082
+ for (const target of input.targets) {
2083
+ if (target.dir === null) {
2084
+ rows.push({
2085
+ vendor: target.vendor,
2086
+ path: "-",
2087
+ action: "unsupported"
2088
+ });
2089
+ continue;
2090
+ }
2091
+ const dest = destinationPath(target.dir, input.pluginName);
2092
+ const state = input.dest[target.vendor] ?? { kind: "absent" };
2093
+ if (state.kind === "absent") {
2094
+ rows.push({
2095
+ vendor: target.vendor,
2096
+ path: dest,
2097
+ action: "missing"
2098
+ });
2099
+ continue;
2100
+ }
2101
+ if (!isOurs(state, input.root, input.pluginName) && !input.force) {
2102
+ rows.push({
2103
+ vendor: target.vendor,
2104
+ path: dest,
2105
+ action: "blocked",
2106
+ reason: "this is not an install of this plugin; --force removes it anyway"
2107
+ });
2108
+ continue;
2109
+ }
2110
+ removals.push(dest);
2111
+ rows.push({
2112
+ vendor: target.vendor,
2113
+ path: dest,
2114
+ action: "removed"
2115
+ });
2116
+ }
1209
2117
  return {
1210
- path: ".agents/plugins/marketplace.json",
1211
- content: json({
1212
- name: metadata.name,
1213
- interface: { displayName: metadata.name },
1214
- plugins: plugins.map((plugin) => ({
1215
- name: plugin.name,
1216
- version: plugin.metadata.version,
1217
- source: {
1218
- source: "local",
1219
- path: plugin.source
1220
- },
1221
- policy: {
1222
- installation: "AVAILABLE",
1223
- authentication: "ON_INSTALL"
1224
- },
1225
- category: "Productivity"
1226
- }))
1227
- })
2118
+ rows,
2119
+ removals,
2120
+ summary: summarizeUninstall(rows)
1228
2121
  };
1229
2122
  }
1230
- function copilotArtifact(metadata, plugins) {
2123
+ function summarizeInstall(rows) {
2124
+ const count = (action) => rows.filter((r) => r.action === action).length;
1231
2125
  return {
1232
- path: ".github/plugin/marketplace.json",
1233
- content: json({
1234
- ...metadata,
1235
- metadata: { displayName: metadata.name },
1236
- plugins: plugins.map((plugin) => ({
1237
- name: plugin.name,
1238
- source: plugin.source,
1239
- ...commonMetadata(plugin)
1240
- }))
1241
- })
2126
+ installed: count("linked") + count("copied"),
2127
+ unchanged: count("unchanged"),
2128
+ blocked: count("blocked"),
2129
+ unsupported: count("unsupported")
1242
2130
  };
1243
2131
  }
1244
- function cursorArtifacts(metadata, plugins) {
1245
- const sources = plugins.map((plugin) => ({
1246
- name: plugin.name,
1247
- source: plugin.source,
1248
- ...commonMetadata(plugin)
1249
- }));
1250
- return [{
1251
- path: ".cursor-plugin/marketplace-submission.json",
1252
- content: json({
1253
- ...metadata,
1254
- plugins: sources,
1255
- dashboard: "https://cursor.com/dashboard"
1256
- })
1257
- }, {
1258
- path: "CURSOR_MARKETPLACE_SUBMISSION.md",
1259
- content: `# Cursor Marketplace Submission\n\nMarketplace: ${metadata.name}\nOwner: ${metadata.owner}\n\nPlugins:\n${sources.map((plugin) => `- ${plugin.name}: ${plugin.source}`).join("\n")}\n\nSubmit this metadata through the [Cursor dashboard](https://cursor.com/dashboard). This command generated local submission metadata only; no publication or provisioning occurred.\n`
1260
- }];
2132
+ function summarizeUninstall(rows) {
2133
+ const count = (action) => rows.filter((r) => r.action === action).length;
2134
+ return {
2135
+ removed: count("removed"),
2136
+ missing: count("missing"),
2137
+ blocked: count("blocked"),
2138
+ unsupported: count("unsupported")
2139
+ };
1261
2140
  }
1262
- function serializeTarget(target, metadata, plugins) {
1263
- switch (target) {
1264
- case "claude": return [claudeArtifact(metadata, plugins)];
1265
- case "codex": return [codexArtifact(metadata, plugins)];
1266
- case "copilot": return [copilotArtifact(metadata, plugins)];
1267
- case "cursor": return cursorArtifacts(metadata, plugins);
2141
+ //#endregion
2142
+ //#region src/install/cli.ts
2143
+ function collect(value, previous) {
2144
+ return [...previous, value];
2145
+ }
2146
+ function resolveMode(opts) {
2147
+ if (opts.link && opts.copy) throw new Error("--link and --copy are mutually exclusive");
2148
+ if (opts.link) return "link";
2149
+ if (opts.copy) return "copy";
2150
+ return "auto";
2151
+ }
2152
+ /** Gathers everything the pure planner needs: the plugin's name, the vendors it declares, each
2153
+ * vendor's local-install facts, and what currently sits at each destination. */
2154
+ function gather(root, opts, mode) {
2155
+ const manifest = readManifest(root);
2156
+ if (!manifest.name) throw new Error(`plugin.json at ${root} declares no name`);
2157
+ const uext = universalPluginExtension(manifest);
2158
+ const declared = uext.vendors ?? Object.keys(uext.harnesses ?? {});
2159
+ const selected = opts.vendor?.length ? opts.vendor : declared;
2160
+ for (const vendor of selected) if (!declared.includes(vendor)) throw new Error(`Vendor "${vendor}" not declared in this plugin's manifest`);
2161
+ if (selected.length === 0) throw new Error("No vendors declared in this plugin's manifest — nothing to install");
2162
+ const registry = loadRegistry();
2163
+ const targets = selected.map((vendor) => {
2164
+ const config = registry[vendor];
2165
+ if (!config) throw new Error(`Unknown vendor: ${vendor}`);
2166
+ return {
2167
+ vendor,
2168
+ dir: resolveLocalDir(config.localPluginDir),
2169
+ link: config.localPluginLink,
2170
+ manifestPath: VENDOR_OUTPUT[vendor] ?? "plugin.json"
2171
+ };
2172
+ });
2173
+ const dest = {};
2174
+ const manifests = {};
2175
+ for (const target of targets) {
2176
+ manifests[target.vendor] = fs.existsSync(path.join(root, target.manifestPath));
2177
+ if (target.dir !== null) dest[target.vendor] = readDestination(destinationPath(target.dir, manifest.name));
1268
2178
  }
2179
+ return {
2180
+ pluginName: manifest.name,
2181
+ root,
2182
+ mode,
2183
+ force: Boolean(opts.force),
2184
+ targets,
2185
+ dest,
2186
+ manifests
2187
+ };
2188
+ }
2189
+ /** The per-vendor step the runtime needs before it sees the change. Printed for every vendor the
2190
+ * run actually touched — an install nothing reloads looks exactly like an install that failed. */
2191
+ function reloadNotes(vendors) {
2192
+ const registry = loadRegistry();
2193
+ return vendors.map((vendor) => {
2194
+ const reload = registry[vendor]?.localReload;
2195
+ return reload ? `→ ${vendor}: ${reload}` : null;
2196
+ }).filter((line) => line !== null);
2197
+ }
2198
+ function installCommand() {
2199
+ const cmd = new Command("install").description("Install this plugin into the runtimes it targets, for local development");
2200
+ cmd.option("--vendor <id>", "Install only the named vendor; repeatable", collect, []).option("--link", "Symlink the plugin root, failing a vendor that will not load one").option("--copy", "Copy the plugin root instead of linking it").option("--force", "Replace a destination this plugin does not own").option("--list", "Print the resolved targets and destinations without writing").option("--format <format>", "Output format: json or toon (default: toon)").addOption(new Option("--json").hideHelp()).addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin plugin install --vendor claude-code\n").action((opts) => {
2201
+ try {
2202
+ const root = resolveRoot(opts.root);
2203
+ const plan = planInstall(gather(root, opts, resolveMode(opts)));
2204
+ if (!opts.list) for (const write of plan.writes) applyWrite(root, write);
2205
+ const { installed, unchanged, blocked, unsupported } = plan.summary;
2206
+ output(plan, {
2207
+ vendors: plan.rows.map((r) => ({
2208
+ vendor: r.vendor,
2209
+ path: r.path,
2210
+ action: r.action
2211
+ })),
2212
+ summary: `installed ${installed}, unchanged ${unchanged}, blocked ${blocked}, unsupported ${unsupported}`
2213
+ });
2214
+ for (const row of plan.rows) if (row.reason) process.stderr.write(`warn: ${row.vendor}: ${row.reason}\n`);
2215
+ if (!opts.list) for (const note of reloadNotes(plan.writes.map((w) => w.vendor))) process.stderr.write(`${note}\n`);
2216
+ if (blocked > 0) process.exitCode = 1;
2217
+ } catch (err) {
2218
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
2219
+ process.exit(1);
2220
+ }
2221
+ });
2222
+ return cmd;
2223
+ }
2224
+ function uninstallCommand() {
2225
+ const cmd = new Command("uninstall").description("Remove this plugin from the runtimes it was installed into");
2226
+ cmd.option("--vendor <id>", "Uninstall only the named vendor; repeatable", collect, []).option("--force", "Remove a destination this plugin does not own").option("--list", "Print what would be removed without removing it").option("--format <format>", "Output format: json or toon (default: toon)").addOption(new Option("--json").hideHelp()).addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin plugin uninstall\n").action((opts) => {
2227
+ try {
2228
+ const plan = planUninstall(gather(resolveRoot(opts.root), opts, "auto"));
2229
+ if (!opts.list) for (const dest of plan.removals) applyRemoval(dest);
2230
+ const { removed, missing, blocked, unsupported } = plan.summary;
2231
+ output(plan, {
2232
+ vendors: plan.rows.map((r) => ({
2233
+ vendor: r.vendor,
2234
+ path: r.path,
2235
+ action: r.action
2236
+ })),
2237
+ summary: `removed ${removed}, missing ${missing}, blocked ${blocked}, unsupported ${unsupported}`
2238
+ });
2239
+ for (const row of plan.rows) if (row.reason) process.stderr.write(`warn: ${row.vendor}: ${row.reason}\n`);
2240
+ if (blocked > 0) process.exitCode = 1;
2241
+ } catch (err) {
2242
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
2243
+ process.exit(1);
2244
+ }
2245
+ });
2246
+ return cmd;
1269
2247
  }
1270
2248
  //#endregion
1271
2249
  //#region src/marketplace/init.ts
@@ -1294,11 +2272,17 @@ function parseManifest(fs, file) {
1294
2272
  throw new Error(`error: manifest "${file}" is not valid JSON`);
1295
2273
  }
1296
2274
  }
2275
+ /** The canonical manifest's `author` — a string or an `{ name, email?, url? }` object — read as the
2276
+ * catalog owner every runtime requires as an object. */
1297
2277
  function manifestOwner(manifest) {
1298
- if (typeof manifest.author === "string") return manifest.author;
2278
+ if (typeof manifest.author === "string") return { name: manifest.author };
1299
2279
  if (typeof manifest.author === "object" && manifest.author !== null) {
1300
2280
  const author = manifest.author;
1301
- if (typeof author.name === "string") return author.name;
2281
+ if (typeof author.name !== "string") return void 0;
2282
+ const owner = { name: author.name };
2283
+ if (typeof author.email === "string") owner.email = author.email;
2284
+ if (typeof author.url === "string") owner.url = author.url;
2285
+ return owner;
1302
2286
  }
1303
2287
  }
1304
2288
  function deriveMetadata(root, fs, opts) {
@@ -1306,9 +2290,9 @@ function deriveMetadata(root, fs, opts) {
1306
2290
  if (fs.exists(rootManifest)) assertContained(root, rootManifest, fs, "root plugin.json");
1307
2291
  const manifest = fs.exists(rootManifest) ? parseManifest(fs, rootManifest) : {};
1308
2292
  const name = opts.name ?? path.basename(root);
1309
- const owner = opts.owner ?? manifestOwner(manifest);
2293
+ const owner = opts.owner !== void 0 ? { name: opts.owner } : manifestOwner(manifest);
1310
2294
  assertMarketplaceName(name, "marketplace name");
1311
- if (!owner || owner.trim() === "") throw new Error("error: marketplace owner is required; set --owner or root plugin.json author");
2295
+ if (!owner || owner.name.trim() === "") throw new Error("error: marketplace owner is required; set --owner or root plugin.json author");
1312
2296
  return {
1313
2297
  name,
1314
2298
  owner
@@ -1357,9 +2341,6 @@ function discoverPlugins(root, fs, scanDirs) {
1357
2341
  }
1358
2342
  return plugins.sort((a, b) => a.name.localeCompare(b.name));
1359
2343
  }
1360
- function assertCodexPluginVersions(plugins) {
1361
- for (const plugin of plugins) if (typeof plugin.metadata.version !== "string" || plugin.metadata.version.trim() === "") throw new Error(`error: canonical plugin manifest for Codex entry "${plugin.name}" requires a version`);
1362
- }
1363
2344
  function writeArtifacts(fs, artifacts, root) {
1364
2345
  const changed = artifacts.filter((artifact) => !sameArtifact(fs, path.join(root, artifact.path), artifact.content));
1365
2346
  for (const artifact of changed) fs.writeAtomically(path.join(root, artifact.path), artifact.content);
@@ -1368,7 +2349,8 @@ function selectedTargets(targets) {
1368
2349
  return targets && targets.length > 0 ? [...new Set(targets)] : [
1369
2350
  "claude",
1370
2351
  "codex",
1371
- "copilot"
2352
+ "copilot",
2353
+ "cursor"
1372
2354
  ];
1373
2355
  }
1374
2356
  function sameArtifact(fs, file, content) {
@@ -1392,9 +2374,7 @@ function initializeMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
1392
2374
  const root = path.resolve(rootInput);
1393
2375
  const metadata = deriveMetadata(root, fs, opts);
1394
2376
  const plugins = discoverPlugins(root, fs, opts.scanDirs);
1395
- const targets = selectedTargets(opts.targets);
1396
- if (targets.includes("codex")) assertCodexPluginVersions(plugins);
1397
- const planned = targets.map((target) => ({
2377
+ const planned = selectedTargets(opts.targets).map((target) => ({
1398
2378
  target,
1399
2379
  artifacts: serializeTarget(target, metadata, plugins)
1400
2380
  }));
@@ -1422,13 +2402,6 @@ function initializeMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
1422
2402
  };
1423
2403
  });
1424
2404
  if (!opts.dryRun && plugins.length > 0) writeArtifacts(fs, planned.flatMap((entry) => entry.artifacts), root);
1425
- if (!opts.targets || opts.targets.length === 0) results.push({
1426
- target: "cursor",
1427
- status: "skipped-default",
1428
- paths: [],
1429
- plugins: [],
1430
- reason: "Cursor requires an explicit submission scaffold"
1431
- });
1432
2405
  return results;
1433
2406
  }
1434
2407
  //#endregion
@@ -1443,7 +2416,7 @@ function targetsFromOptions(opts) {
1443
2416
  return targets.length > 0 ? targets : void 0;
1444
2417
  }
1445
2418
  function initCommand() {
1446
- return new Command("init").description("Generate local marketplace metadata without publishing or provisioning").option("--claude", "Generate the Claude marketplace catalog").option("--codex", "Generate the Codex marketplace catalog").option("--copilot", "Generate the Copilot marketplace catalog").option("--cursor", "Generate the Cursor submission scaffold").option("--plugin-scan-dir <dir>", "Directory beneath --root to scan for root-level plugin.json files (repeatable)", (value, previous = []) => [...previous, value]).option("--name <name>", "Override the marketplace name").option("--owner <name>", "Override the marketplace owner").option("--dry-run", "Preview artifacts without writing them").option("--force", "Replace differing artifacts for selected targets").option("--format <format>", "Output format: toon or json (default: toon)").addOption(new Option("--json").hideHelp()).addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin marketplace init --root .\n").action((opts) => {
2419
+ return new Command("init").description("Generate local marketplace metadata without publishing or provisioning").option("--claude", "Generate the Claude marketplace catalog").option("--codex", "Generate the Codex marketplace catalog").option("--copilot", "Generate the Copilot marketplace catalog").option("--cursor", "Generate the Cursor marketplace catalog").option("--plugin-scan-dir <dir>", "Directory beneath --root to scan for root-level plugin.json files (repeatable)", (value, previous = []) => [...previous, value]).option("--name <name>", "Override the marketplace name").option("--owner <name>", "Override the marketplace owner").option("--dry-run", "Preview artifacts without writing them").option("--force", "Replace differing artifacts for selected targets").option("--format <format>", "Output format: toon or json (default: toon)").addOption(new Option("--json").hideHelp()).addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin marketplace init --root .\n").action((opts) => {
1447
2420
  try {
1448
2421
  if (opts.format !== void 0 && opts.format !== "toon" && opts.format !== "json") throw new Error("error: --format must be \"toon\" or \"json\"");
1449
2422
  const results = initializeMarketplace(resolveRoot(opts.root), {
@@ -1474,37 +2447,6 @@ function marketplaceCommand() {
1474
2447
  return new Command("marketplace").description("Generate repository-local marketplace metadata").addCommand(initCommand());
1475
2448
  }
1476
2449
  //#endregion
1477
- //#region src/vendor-registry/vendor-registry.ts
1478
- function lookupVendor(registry, vendorId) {
1479
- return registry[vendorId] ?? null;
1480
- }
1481
- function mergeRegistries(base, override) {
1482
- const result = { ...base };
1483
- for (const [id, config] of Object.entries(override)) result[id] = {
1484
- ...base[id] ?? {},
1485
- ...config
1486
- };
1487
- return result;
1488
- }
1489
- //#endregion
1490
- //#region src/vendor-registry/fs.ts
1491
- function bundledRegistryPath() {
1492
- const thisFile = fileURLToPath(import.meta.url);
1493
- return path.join(path.dirname(thisFile), "data", "vendors.json");
1494
- }
1495
- function userOverridePath() {
1496
- return path.join(os.homedir(), ".agents", "universal-plugin-vendors.json");
1497
- }
1498
- function loadRegistry() {
1499
- const bundled = JSON.parse(fsNode.readFileSync(bundledRegistryPath(), "utf8"));
1500
- try {
1501
- return mergeRegistries(bundled, JSON.parse(fsNode.readFileSync(userOverridePath(), "utf8")));
1502
- } catch (err) {
1503
- if (err.code === "ENOENT") return bundled;
1504
- throw err;
1505
- }
1506
- }
1507
- //#endregion
1508
2450
  //#region src/prepare/fs.ts
1509
2451
  function expandHome(p) {
1510
2452
  return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
@@ -1517,15 +2459,15 @@ function projectStatePath(root) {
1517
2459
  }
1518
2460
  function readStateFile(filePath) {
1519
2461
  try {
1520
- return mergeSafeState(JSON.parse(fsNode.readFileSync(filePath, "utf8")));
2462
+ return mergeSafeState(JSON.parse(fs.readFileSync(filePath, "utf8")));
1521
2463
  } catch (err) {
1522
2464
  if (err.code === "ENOENT") return null;
1523
2465
  throw err;
1524
2466
  }
1525
2467
  }
1526
2468
  function writeStateFile(filePath, state) {
1527
- fsNode.mkdirSync(path.dirname(filePath), { recursive: true });
1528
- fsNode.writeFileSync(filePath, JSON.stringify(state, null, 2) + "\n", "utf8");
2469
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
2470
+ fs.writeFileSync(filePath, JSON.stringify(state, null, 2) + "\n", "utf8");
1529
2471
  }
1530
2472
  function realPrepareFs(vendor, projectRoot) {
1531
2473
  return {
@@ -1533,7 +2475,7 @@ function realPrepareFs(vendor, projectRoot) {
1533
2475
  if (!vendor.globalManifest) return {};
1534
2476
  const manifestPath = expandHome(vendor.globalManifest);
1535
2477
  try {
1536
- const raw = JSON.parse(fsNode.readFileSync(manifestPath, "utf8"));
2478
+ const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
1537
2479
  return Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, typeof v === "object" && v !== null && "version" in v ? String(v.version) : String(v)]));
1538
2480
  } catch (err) {
1539
2481
  if (err.code === "ENOENT") return {};
@@ -1707,9 +2649,9 @@ function prepareCommand() {
1707
2649
  //#endregion
1708
2650
  //#region src/version/fs.ts
1709
2651
  const realJsonIo = {
1710
- exists: (p) => fsNode.existsSync(p),
1711
- read: (p) => fsNode.readFileSync(p, "utf8"),
1712
- write: (p, content) => fsNode.writeFileSync(p, content)
2652
+ exists: (p) => fs.existsSync(p),
2653
+ read: (p) => fs.readFileSync(p, "utf8"),
2654
+ write: (p, content) => fs.writeFileSync(p, content)
1713
2655
  };
1714
2656
  /** Reads `packagePath` from `.agents/universal-plugin.json`. Absent file, absent key, or a
1715
2657
  * non-string value all mean "this plugin declares no npm package" — the manifest is then the only
@@ -1817,19 +2759,19 @@ function globalStatePath$1() {
1817
2759
  function realSelfUpdateFs(hookFilePaths) {
1818
2760
  return {
1819
2761
  globHookFiles: () => hookFilePaths,
1820
- readFile: (p) => fsNode.readFileSync(p, "utf8"),
1821
- writeFile: (p, c) => fsNode.writeFileSync(p, c, "utf8"),
2762
+ readFile: (p) => fs.readFileSync(p, "utf8"),
2763
+ writeFile: (p, c) => fs.writeFileSync(p, c, "utf8"),
1822
2764
  readGlobalState: () => {
1823
2765
  try {
1824
- return mergeSafeState(JSON.parse(fsNode.readFileSync(globalStatePath$1(), "utf8")));
2766
+ return mergeSafeState(JSON.parse(fs.readFileSync(globalStatePath$1(), "utf8")));
1825
2767
  } catch (err) {
1826
2768
  if (err.code === "ENOENT") return emptyState();
1827
2769
  throw err;
1828
2770
  }
1829
2771
  },
1830
2772
  writeGlobalState: (s) => {
1831
- fsNode.mkdirSync(path.dirname(globalStatePath$1()), { recursive: true });
1832
- fsNode.writeFileSync(globalStatePath$1(), JSON.stringify(s, null, 2) + "\n");
2773
+ fs.mkdirSync(path.dirname(globalStatePath$1()), { recursive: true });
2774
+ fs.writeFileSync(globalStatePath$1(), JSON.stringify(s, null, 2) + "\n");
1833
2775
  }
1834
2776
  };
1835
2777
  }
@@ -1870,7 +2812,7 @@ function selfUpdateCommand() {
1870
2812
  toVersion,
1871
2813
  fs: realSelfUpdateFs(Object.values(registry).map((v) => v.hookGlob).filter((g) => g !== null).map((g) => g.replace("~", os.homedir())).filter((p) => {
1872
2814
  try {
1873
- fsNode.accessSync(p);
2815
+ fs.accessSync(p);
1874
2816
  return true;
1875
2817
  } catch {
1876
2818
  return false;
@@ -1914,15 +2856,15 @@ function realSyncFs() {
1914
2856
  return {
1915
2857
  readGlobalState: () => {
1916
2858
  try {
1917
- return mergeSafeState(JSON.parse(fsNode.readFileSync(globalStatePath(), "utf8")));
2859
+ return mergeSafeState(JSON.parse(fs.readFileSync(globalStatePath(), "utf8")));
1918
2860
  } catch (err) {
1919
2861
  if (err.code === "ENOENT") return emptyState();
1920
2862
  throw err;
1921
2863
  }
1922
2864
  },
1923
2865
  writeGlobalState: (s) => {
1924
- fsNode.mkdirSync(path.dirname(globalStatePath()), { recursive: true });
1925
- fsNode.writeFileSync(globalStatePath(), JSON.stringify(s, null, 2) + "\n");
2866
+ fs.mkdirSync(path.dirname(globalStatePath()), { recursive: true });
2867
+ fs.writeFileSync(globalStatePath(), JSON.stringify(s, null, 2) + "\n");
1926
2868
  },
1927
2869
  shell: (cmd) => childProcess.spawnSync(cmd, {
1928
2870
  shell: true,
@@ -1962,9 +2904,10 @@ function syncCommand() {
1962
2904
  *
1963
2905
  * A version lives in five places, but only two are authored: the canonical `plugin.json` and, when
1964
2906
  * the project declares a `packagePath`, that `package.json`. The per-vendor manifests
1965
- * (`plugin build`), the local marketplace catalogs (`marketplace init`), and the `npx`/`upx`
1966
- * pins in `skills/**` (`plugin bundle`) are all **derived** — re-derived by calling the command
1967
- * that owns them, never written a second time here. */
2907
+ * (`plugin build`), the local marketplace catalog entries (`plugin build` refreshes the catalogs the
2908
+ * repository carries, ADR-0014), and the `npx`/`upx` pins in `skills/**` (`plugin bundle`) are all
2909
+ * **derived** — re-derived by calling the command that owns them, never written a second time
2910
+ * here. */
1968
2911
  const RELEASE_TYPES = [
1969
2912
  "major",
1970
2913
  "minor",
@@ -1985,10 +2928,10 @@ function currentVersion(manifest) {
1985
2928
  /** Resolves the bump argument against the current version. An explicit version is used as given;
1986
2929
  * a release type is applied to the current one, which must therefore exist. */
1987
2930
  function resolveTarget(current, opts) {
1988
- if (semver.valid(opts.bump)) return semver.valid(opts.bump);
2931
+ if (semver$1.valid(opts.bump)) return semver$1.valid(opts.bump);
1989
2932
  if (!isReleaseType(opts.bump)) throw new Error(`Unknown version or release type "${opts.bump}" — expected an explicit version (1.4.0) or one of: ${RELEASE_TYPES.join(", ")}`);
1990
2933
  if (current === null) throw new Error("plugin.json has no version to bump from — pass an explicit version (e.g. 0.1.0) to set the first one");
1991
- const next = opts.preid === void 0 ? semver.inc(current, opts.bump) : semver.inc(current, opts.bump, opts.preid);
2934
+ const next = opts.preid === void 0 ? semver$1.inc(current, opts.bump) : semver$1.inc(current, opts.bump, opts.preid);
1992
2935
  if (next === null) throw new Error(`Current version "${current}" in plugin.json is not valid semver`);
1993
2936
  return next;
1994
2937
  }
@@ -1998,7 +2941,7 @@ function planVersion(state, opts) {
1998
2941
  if (!state.manifestExists || state.manifest === null) throw new Error("No plugin.json found at the project root");
1999
2942
  const from = currentVersion(state.manifest);
2000
2943
  const to = resolveTarget(from, opts);
2001
- if (from !== null && !semver.gt(to, from) && !opts.force) throw new Error(`Target version "${to}" does not advance on the current version "${from}" — pass --force to set it anyway`);
2944
+ if (from !== null && !semver$1.gt(to, from) && !opts.force) throw new Error(`Target version "${to}" does not advance on the current version "${from}" — pass --force to set it anyway`);
2002
2945
  if (state.packagePath !== null && state.packageJson === null) throw new Error(`No package.json found at packagePath "${state.packagePath}"`);
2003
2946
  const rows = [{
2004
2947
  path: "plugin.json",
@@ -2102,10 +3045,12 @@ function nextStep(dryRun, built) {
2102
3045
  const program = new Command();
2103
3046
  program.name("universal-plugin").description("Universal AI agent plugin build tool").version("0.0.0").helpCommand(false);
2104
3047
  function pluginCommand() {
2105
- const cmd = new Command("plugin").description("Author the canonical plugin manifest (build, bundle, init, version; validate planned)");
3048
+ const cmd = new Command("plugin").description("Author the canonical plugin manifest (build, bundle, init, install, uninstall, version; validate planned)");
2106
3049
  cmd.addCommand(buildCommand());
2107
3050
  cmd.addCommand(bundleCommand());
2108
3051
  cmd.addCommand(initCommand$1());
3052
+ cmd.addCommand(installCommand());
3053
+ cmd.addCommand(uninstallCommand());
2109
3054
  cmd.addCommand(versionCommand());
2110
3055
  return cmd;
2111
3056
  }