universal-plugin 0.4.0 → 0.6.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/dist/cli.mjs +1534 -247
- package/dist/data/vendors.json +12 -0
- package/dist/run.mjs +4 -4
- package/governances/plugin-design.md +42 -1
- package/package.json +2 -1
- package/plugin.json +1 -1
- package/readme.md +11 -0
- package/skills/doctor/README.md +4 -2
- package/skills/doctor/SKILL.md +33 -1
- package/skills/doctor/scripts/doctor.mjs +109 -4
- package/skills/init/SKILL.md +23 -4
- package/skills/init/references/create.md +13 -2
- package/skills/init/references/standard.md +2 -1
- package/skills/init/references/vendors/claude-code.md +40 -8
- package/skills/init/references/vendors/codex.md +10 -2
- package/skills/init/references/vendors/copilot-cli.md +10 -2
- package/skills/init/references/vendors/cursor.md +9 -2
- package/skills/marketplace/README.md +50 -0
- package/skills/marketplace/SKILL.md +209 -0
- package/skills/marketplace/references/runtimes.md +104 -0
- package/skills/marketplace/scripts/install-docs.mjs +115 -0
- package/skills/marketplace/scripts/marketplace.mjs +11 -0
- package/skills/marketplace/scripts/validate.mjs +11 -0
- package/skills/publish-plugin/SKILL.md +10 -8
- package/skills/publish-plugin/references/vendor-requirements.md +13 -10
- package/skills/version/SKILL.md +2 -1
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
|
|
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
|
|
7
|
+
import * as semver$1 from "semver";
|
|
8
|
+
import semver from "semver";
|
|
8
9
|
import * as childProcess from "node:child_process";
|
|
9
|
-
import
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
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 (!
|
|
119
|
-
|
|
120
|
-
for (const file of
|
|
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
|
-
|
|
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(
|
|
153
|
+
...mergeSafeState(JSON.parse(fs.readFileSync(statePath, "utf8"))),
|
|
138
154
|
plugins: {},
|
|
139
155
|
assets: {}
|
|
140
156
|
};
|
|
141
|
-
|
|
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,517 @@ 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
|
+
/** A manifest field carried into a catalog entry, in the shape the catalog schema states. A manifest
|
|
427
|
+
* written from a `package.json` carries `repository` as `{ type, url }`, and every catalog wants the
|
|
428
|
+
* URL alone — Claude Code rejects the object (`plugins[].repository: expected string`). A value that
|
|
429
|
+
* cannot be reduced to the right type is dropped rather than written: an entry missing an optional
|
|
430
|
+
* field still installs, an entry with the wrong type installs nowhere. */
|
|
431
|
+
function catalogValue(field, value) {
|
|
432
|
+
if (field === "keywords") return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : void 0;
|
|
433
|
+
if (field === "repository" && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
434
|
+
const url = value.url;
|
|
435
|
+
return typeof url === "string" ? url.replace(/^git\+/, "") : void 0;
|
|
436
|
+
}
|
|
437
|
+
return typeof value === "string" ? value : void 0;
|
|
438
|
+
}
|
|
439
|
+
function commonMetadata(plugin) {
|
|
440
|
+
const result = {};
|
|
441
|
+
for (const field of COMMON_METADATA) {
|
|
442
|
+
if (plugin.metadata[field] === void 0) continue;
|
|
443
|
+
const value = catalogValue(field, plugin.metadata[field]);
|
|
444
|
+
if (value !== void 0) result[field] = value;
|
|
445
|
+
}
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
448
|
+
function json(value) {
|
|
449
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
450
|
+
}
|
|
451
|
+
function claudeArtifact(metadata, plugins) {
|
|
452
|
+
return {
|
|
453
|
+
path: TARGET_CATALOG_PATHS.claude,
|
|
454
|
+
content: json({
|
|
455
|
+
$schema: CLAUDE_SCHEMA,
|
|
456
|
+
...metadata,
|
|
457
|
+
plugins: plugins.map((plugin) => ({
|
|
458
|
+
name: plugin.name,
|
|
459
|
+
source: plugin.source,
|
|
460
|
+
...commonMetadata(plugin)
|
|
461
|
+
}))
|
|
462
|
+
})
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function codexArtifact(metadata, plugins) {
|
|
466
|
+
return {
|
|
467
|
+
path: TARGET_CATALOG_PATHS.codex,
|
|
468
|
+
content: json({
|
|
469
|
+
name: metadata.name,
|
|
470
|
+
interface: { displayName: metadata.name },
|
|
471
|
+
plugins: plugins.map((plugin) => ({
|
|
472
|
+
name: plugin.name,
|
|
473
|
+
version: plugin.metadata.version,
|
|
474
|
+
source: {
|
|
475
|
+
source: "local",
|
|
476
|
+
path: plugin.source
|
|
477
|
+
},
|
|
478
|
+
policy: {
|
|
479
|
+
installation: "AVAILABLE",
|
|
480
|
+
authentication: "ON_INSTALL"
|
|
481
|
+
},
|
|
482
|
+
category: "Productivity"
|
|
483
|
+
}))
|
|
484
|
+
})
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
function copilotArtifact(metadata, plugins) {
|
|
488
|
+
return {
|
|
489
|
+
path: TARGET_CATALOG_PATHS.copilot,
|
|
490
|
+
content: json({
|
|
491
|
+
...metadata,
|
|
492
|
+
metadata: { displayName: metadata.name },
|
|
493
|
+
plugins: plugins.map((plugin) => ({
|
|
494
|
+
name: plugin.name,
|
|
495
|
+
source: plugin.source,
|
|
496
|
+
...commonMetadata(plugin)
|
|
497
|
+
}))
|
|
498
|
+
})
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
/** Cursor reads `.cursor-plugin/marketplace.json` at the repository root, with a shape close to
|
|
502
|
+
* Claude Code's. It is not an install path for the author: a developer tests through
|
|
503
|
+
* `~/.cursor/plugins/local/<name>`, and the catalog reaches users when an admin imports the
|
|
504
|
+
* repository as a team marketplace (`.research/local-marketplaces`, E-CUR-M3, E-CUR-M4). */
|
|
505
|
+
function cursorArtifact(metadata, plugins) {
|
|
506
|
+
return {
|
|
507
|
+
path: TARGET_CATALOG_PATHS.cursor,
|
|
508
|
+
content: json({
|
|
509
|
+
...metadata,
|
|
510
|
+
plugins: plugins.map((plugin) => ({
|
|
511
|
+
name: plugin.name,
|
|
512
|
+
source: plugin.source,
|
|
513
|
+
...commonMetadata(plugin)
|
|
514
|
+
}))
|
|
515
|
+
})
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
/** The marketplace target each `--vendor` id catalogs into. Every vendor documents a
|
|
519
|
+
* repository-local catalog (`.research/local-marketplaces`). */
|
|
520
|
+
const VENDOR_TARGETS = {
|
|
521
|
+
"claude-code": "claude",
|
|
522
|
+
cursor: "cursor",
|
|
523
|
+
codex: "codex",
|
|
524
|
+
"copilot-cli": "copilot"
|
|
525
|
+
};
|
|
526
|
+
/** Folds one plugin's entry into a catalog that may already exist, and returns the artifact to
|
|
527
|
+
* write. An existing catalog keeps its own top-level fields — its name, its owner, a description
|
|
528
|
+
* someone wrote — and every entry it lists for other plugins, in place. Only this plugin's entry is
|
|
529
|
+
* re-derived.
|
|
530
|
+
*
|
|
531
|
+
* `version` is derived, never authored (ADR-0010 §3): the entry carries whatever the canonical
|
|
532
|
+
* manifest carries, and a version left behind on an entry whose manifest declares none is removed
|
|
533
|
+
* rather than kept. */
|
|
534
|
+
function mergeCatalogEntry(target, metadata, plugin, readExisting) {
|
|
535
|
+
const artifact = serializeTarget(target, metadata, [plugin])[0];
|
|
536
|
+
const existing = readExisting(artifact.path);
|
|
537
|
+
if (existing === void 0) return artifact;
|
|
538
|
+
const previous = parseCatalog(existing, artifact.path);
|
|
539
|
+
const generated = JSON.parse(artifact.content);
|
|
540
|
+
const entry = generated.plugins[0];
|
|
541
|
+
const merged = { ...previous };
|
|
542
|
+
for (const [key, value] of Object.entries(generated)) if (!(key in merged)) merged[key] = value;
|
|
543
|
+
merged.plugins = mergeEntries(previous, entry);
|
|
544
|
+
return {
|
|
545
|
+
path: artifact.path,
|
|
546
|
+
content: json(merged)
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
function parseCatalog(content, path) {
|
|
550
|
+
let parsed;
|
|
551
|
+
try {
|
|
552
|
+
parsed = JSON.parse(content);
|
|
553
|
+
} catch {
|
|
554
|
+
throw new Error(`error: existing catalog "${path}" is not valid JSON`);
|
|
555
|
+
}
|
|
556
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`error: existing catalog "${path}" is not a JSON object`);
|
|
557
|
+
return parsed;
|
|
558
|
+
}
|
|
559
|
+
function mergeEntries(previous, entry) {
|
|
560
|
+
const entries = Array.isArray(previous.plugins) ? [...previous.plugins] : [];
|
|
561
|
+
const index = entries.findIndex((candidate) => typeof candidate === "object" && candidate !== null && candidate.name === entry.name);
|
|
562
|
+
if (index === -1) return [...entries, entry];
|
|
563
|
+
const merged = {
|
|
564
|
+
...entries[index],
|
|
565
|
+
...entry
|
|
566
|
+
};
|
|
567
|
+
if (!("version" in entry)) delete merged.version;
|
|
568
|
+
entries[index] = merged;
|
|
569
|
+
return entries;
|
|
570
|
+
}
|
|
571
|
+
/** The catalog's own top-level identity, read back from the file the repository already carries, so
|
|
572
|
+
* a refresh re-derives one entry without proposing a name or an owner of its own. */
|
|
573
|
+
function existingMetadata(previous) {
|
|
574
|
+
const name = typeof previous.name === "string" ? previous.name : "";
|
|
575
|
+
const owner = previous.owner;
|
|
576
|
+
if (typeof owner === "object" && owner !== null && typeof owner.name === "string") return {
|
|
577
|
+
name,
|
|
578
|
+
owner
|
|
579
|
+
};
|
|
580
|
+
return {
|
|
581
|
+
name,
|
|
582
|
+
owner: { name }
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
/** Re-derives one plugin's entry inside a catalog the repository already carries. Update only, in two
|
|
586
|
+
* senses: it never creates a catalog — `plugin init --vendor` and `marketplace init` own that — and
|
|
587
|
+
* it adds nothing to the catalog's top level, not even a `$schema` the file happens to lack. Only
|
|
588
|
+
* the entry changes, so a build can run it unconditionally (ADR-0014). */
|
|
589
|
+
function refreshCatalogEntry(target, plugin, existing) {
|
|
590
|
+
const catalogPath = TARGET_CATALOG_PATHS[target];
|
|
591
|
+
const previous = parseCatalog(existing, catalogPath);
|
|
592
|
+
const entry = JSON.parse(serializeTarget(target, existingMetadata(previous), [plugin])[0]?.content ?? "{}").plugins[0];
|
|
593
|
+
return {
|
|
594
|
+
path: catalogPath,
|
|
595
|
+
content: json({
|
|
596
|
+
...previous,
|
|
597
|
+
plugins: mergeEntries(previous, entry)
|
|
598
|
+
})
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
/** Whether two catalogs say the same thing. Key order and whitespace do not change a catalog's
|
|
602
|
+
* meaning — the repository formats its own JSON — so a refresh compares this rather than bytes and
|
|
603
|
+
* leaves a file it agrees with alone. Array order does count. */
|
|
604
|
+
function sameCatalogContent(a, b) {
|
|
605
|
+
try {
|
|
606
|
+
return JSON.stringify(canonicalJson(JSON.parse(a))) === JSON.stringify(canonicalJson(JSON.parse(b)));
|
|
607
|
+
} catch {
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function canonicalJson(value) {
|
|
612
|
+
if (Array.isArray(value)) return value.map(canonicalJson);
|
|
613
|
+
if (typeof value !== "object" || value === null) return value;
|
|
614
|
+
const record = value;
|
|
615
|
+
return Object.fromEntries(Object.keys(record).sort().map((key) => [key, canonicalJson(record[key])]));
|
|
616
|
+
}
|
|
617
|
+
function serializeTarget(target, metadata, plugins) {
|
|
618
|
+
switch (target) {
|
|
619
|
+
case "claude": return [claudeArtifact(metadata, plugins)];
|
|
620
|
+
case "codex": return [codexArtifact(metadata, plugins)];
|
|
621
|
+
case "copilot": return [copilotArtifact(metadata, plugins)];
|
|
622
|
+
case "cursor": return [cursorArtifact(metadata, plugins)];
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region src/marketplace/fs.ts
|
|
627
|
+
const realMarketplaceFs = {
|
|
628
|
+
exists: fs.existsSync,
|
|
629
|
+
isDirectory(file) {
|
|
630
|
+
return fs.statSync(file).isDirectory();
|
|
631
|
+
},
|
|
632
|
+
realpath(file) {
|
|
633
|
+
return fs.realpathSync(file);
|
|
634
|
+
},
|
|
635
|
+
read(file) {
|
|
636
|
+
return fs.readFileSync(file, "utf8");
|
|
637
|
+
},
|
|
638
|
+
listEntries(dir) {
|
|
639
|
+
return fs.readdirSync(dir);
|
|
640
|
+
},
|
|
641
|
+
writeAtomically(file, content) {
|
|
642
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
643
|
+
const temporary = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
644
|
+
fs.writeFileSync(temporary, content);
|
|
645
|
+
fs.renameSync(temporary, file);
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
function gitToplevel(root) {
|
|
649
|
+
try {
|
|
650
|
+
const top = execFileSync("git", [
|
|
651
|
+
"-C",
|
|
652
|
+
root,
|
|
653
|
+
"rev-parse",
|
|
654
|
+
"--show-toplevel"
|
|
655
|
+
], {
|
|
656
|
+
encoding: "utf8",
|
|
657
|
+
stdio: [
|
|
658
|
+
"ignore",
|
|
659
|
+
"pipe",
|
|
660
|
+
"ignore"
|
|
661
|
+
]
|
|
662
|
+
}).trim();
|
|
663
|
+
return top === "" ? void 0 : path.resolve(top);
|
|
664
|
+
} catch {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
function gatherCatalogRepo(root) {
|
|
669
|
+
const repoRoot = gitToplevel(root);
|
|
670
|
+
if (!repoRoot) return void 0;
|
|
671
|
+
const relative = path.relative(repoRoot, root);
|
|
672
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) return void 0;
|
|
673
|
+
const catalogs = {};
|
|
674
|
+
for (const catalog of Object.values(TARGET_CATALOG_PATHS)) {
|
|
675
|
+
const file = path.join(repoRoot, catalog);
|
|
676
|
+
if (fs.existsSync(file)) catalogs[catalog] = fs.readFileSync(file, "utf8");
|
|
677
|
+
}
|
|
678
|
+
return {
|
|
679
|
+
root: repoRoot,
|
|
680
|
+
pluginPath: relative.split(path.sep).join("/"),
|
|
681
|
+
catalogs
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
//#endregion
|
|
685
|
+
//#region src/marketplace/validation.ts
|
|
686
|
+
function isObject(value) {
|
|
687
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
688
|
+
}
|
|
689
|
+
function typeName(value) {
|
|
690
|
+
if (value === null) return "null";
|
|
691
|
+
if (Array.isArray(value)) return "array";
|
|
692
|
+
return typeof value;
|
|
693
|
+
}
|
|
694
|
+
function checkString(issues, path, value, { required = false } = {}) {
|
|
695
|
+
if (value === void 0) {
|
|
696
|
+
if (required) issues.push({
|
|
697
|
+
path,
|
|
698
|
+
message: "is required"
|
|
699
|
+
});
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (typeof value !== "string") {
|
|
703
|
+
issues.push({
|
|
704
|
+
path,
|
|
705
|
+
message: `must be a string, not ${typeName(value)}`
|
|
706
|
+
});
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (required && value.trim() === "") issues.push({
|
|
710
|
+
path,
|
|
711
|
+
message: "must not be empty"
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function checkStringArray(issues, path, value) {
|
|
715
|
+
if (value === void 0) return;
|
|
716
|
+
if (!Array.isArray(value)) {
|
|
717
|
+
issues.push({
|
|
718
|
+
path,
|
|
719
|
+
message: `must be an array of strings, not ${typeName(value)}`
|
|
720
|
+
});
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
value.forEach((item, index) => {
|
|
724
|
+
checkString(issues, `${path}[${index}]`, item);
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
/** `owner` and a plugin's `author` share one shape: an object carrying a required `name`. A string
|
|
728
|
+
* there is the mistake `package.json`'s `"author": "Name <email>"` invites; Claude Code reports
|
|
729
|
+
* `expected object, received string` and refuses the catalog. */
|
|
730
|
+
function checkPerson(issues, path, value, { required = false } = {}) {
|
|
731
|
+
if (value === void 0) {
|
|
732
|
+
if (required) issues.push({
|
|
733
|
+
path,
|
|
734
|
+
message: "is required"
|
|
735
|
+
});
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (!isObject(value)) {
|
|
739
|
+
const remedy = typeof value === "string" ? ` — write { "name": ${JSON.stringify(value)} }` : "";
|
|
740
|
+
issues.push({
|
|
741
|
+
path,
|
|
742
|
+
message: `must be an object with a name, not ${typeName(value)}${remedy}`
|
|
743
|
+
});
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
checkString(issues, `${path}.name`, value.name, { required: true });
|
|
747
|
+
checkString(issues, `${path}.email`, value.email);
|
|
748
|
+
checkString(issues, `${path}.url`, value.url);
|
|
749
|
+
}
|
|
750
|
+
/** The source forms the official schema accepts: a `./`-prefixed repository-relative path, or one of
|
|
751
|
+
* the tagged remote objects. */
|
|
752
|
+
const REMOTE_SOURCE_KEYS = {
|
|
753
|
+
npm: ["package"],
|
|
754
|
+
url: ["url"],
|
|
755
|
+
github: ["repo"],
|
|
756
|
+
"git-subdir": ["url", "path"]
|
|
757
|
+
};
|
|
758
|
+
function checkClaudeSource(issues, path, value) {
|
|
759
|
+
if (value === void 0) {
|
|
760
|
+
issues.push({
|
|
761
|
+
path,
|
|
762
|
+
message: "is required"
|
|
763
|
+
});
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (typeof value === "string") {
|
|
767
|
+
if (!value.startsWith("./")) issues.push({
|
|
768
|
+
path,
|
|
769
|
+
message: `must be a "./"-prefixed repository-relative path, not ${JSON.stringify(value)}`
|
|
770
|
+
});
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (!isObject(value)) {
|
|
774
|
+
issues.push({
|
|
775
|
+
path,
|
|
776
|
+
message: `must be a "./" path or a source object, not ${typeName(value)}`
|
|
777
|
+
});
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const kind = value.source;
|
|
781
|
+
if (typeof kind !== "string" || !(kind in REMOTE_SOURCE_KEYS)) {
|
|
782
|
+
issues.push({
|
|
783
|
+
path: `${path}.source`,
|
|
784
|
+
message: `must be one of ${Object.keys(REMOTE_SOURCE_KEYS).join(", ")}`
|
|
785
|
+
});
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
for (const key of REMOTE_SOURCE_KEYS[kind]) checkString(issues, `${path}.${key}`, value[key], { required: true });
|
|
789
|
+
}
|
|
790
|
+
function checkClaudeEntry(issues, path, entry) {
|
|
791
|
+
if (!isObject(entry)) {
|
|
792
|
+
issues.push({
|
|
793
|
+
path,
|
|
794
|
+
message: `must be an object, not ${typeName(entry)}`
|
|
795
|
+
});
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
checkString(issues, `${path}.name`, entry.name, { required: true });
|
|
799
|
+
checkClaudeSource(issues, `${path}.source`, entry.source);
|
|
800
|
+
checkString(issues, `${path}.version`, entry.version);
|
|
801
|
+
checkString(issues, `${path}.description`, entry.description);
|
|
802
|
+
checkString(issues, `${path}.homepage`, entry.homepage);
|
|
803
|
+
if (isObject(entry.repository) && typeof entry.repository.url === "string") issues.push({
|
|
804
|
+
path: `${path}.repository`,
|
|
805
|
+
message: `must be a string, not object — write ${JSON.stringify(entry.repository.url)}`
|
|
806
|
+
});
|
|
807
|
+
else checkString(issues, `${path}.repository`, entry.repository);
|
|
808
|
+
checkString(issues, `${path}.license`, entry.license);
|
|
809
|
+
checkString(issues, `${path}.category`, entry.category);
|
|
810
|
+
checkPerson(issues, `${path}.author`, entry.author);
|
|
811
|
+
checkStringArray(issues, `${path}.keywords`, entry.keywords);
|
|
812
|
+
checkStringArray(issues, `${path}.tags`, entry.tags);
|
|
813
|
+
}
|
|
814
|
+
function validateClaudeShaped(catalog) {
|
|
815
|
+
const issues = [];
|
|
816
|
+
checkString(issues, "name", catalog.name, { required: true });
|
|
817
|
+
checkPerson(issues, "owner", catalog.owner, { required: true });
|
|
818
|
+
checkString(issues, "description", catalog.description);
|
|
819
|
+
checkString(issues, "version", catalog.version);
|
|
820
|
+
if (catalog.plugins === void 0) {
|
|
821
|
+
issues.push({
|
|
822
|
+
path: "plugins",
|
|
823
|
+
message: "is required"
|
|
824
|
+
});
|
|
825
|
+
return issues;
|
|
826
|
+
}
|
|
827
|
+
if (!Array.isArray(catalog.plugins)) {
|
|
828
|
+
issues.push({
|
|
829
|
+
path: "plugins",
|
|
830
|
+
message: `must be an array, not ${typeName(catalog.plugins)}`
|
|
831
|
+
});
|
|
832
|
+
return issues;
|
|
833
|
+
}
|
|
834
|
+
catalog.plugins.forEach((entry, index) => {
|
|
835
|
+
checkClaudeEntry(issues, `plugins[${index}]`, entry);
|
|
836
|
+
});
|
|
837
|
+
return issues;
|
|
838
|
+
}
|
|
839
|
+
/** Codex reads a document of its own: no `owner`, a display name under `interface`, and an object
|
|
840
|
+
* `source` naming a local path (`.research/local-marketplaces`, E-CODEX-M11). It tolerates extra
|
|
841
|
+
* keys and requires no entry `version`. */
|
|
842
|
+
function validateCodex(catalog) {
|
|
843
|
+
const issues = [];
|
|
844
|
+
checkString(issues, "name", catalog.name, { required: true });
|
|
845
|
+
if (catalog.interface !== void 0 && !isObject(catalog.interface)) issues.push({
|
|
846
|
+
path: "interface",
|
|
847
|
+
message: `must be an object, not ${typeName(catalog.interface)}`
|
|
848
|
+
});
|
|
849
|
+
else if (isObject(catalog.interface)) checkString(issues, "interface.displayName", catalog.interface.displayName);
|
|
850
|
+
if (catalog.plugins === void 0) {
|
|
851
|
+
issues.push({
|
|
852
|
+
path: "plugins",
|
|
853
|
+
message: "is required"
|
|
854
|
+
});
|
|
855
|
+
return issues;
|
|
856
|
+
}
|
|
857
|
+
if (!Array.isArray(catalog.plugins)) {
|
|
858
|
+
issues.push({
|
|
859
|
+
path: "plugins",
|
|
860
|
+
message: `must be an array, not ${typeName(catalog.plugins)}`
|
|
861
|
+
});
|
|
862
|
+
return issues;
|
|
863
|
+
}
|
|
864
|
+
catalog.plugins.forEach((entry, index) => {
|
|
865
|
+
const path = `plugins[${index}]`;
|
|
866
|
+
if (!isObject(entry)) {
|
|
867
|
+
issues.push({
|
|
868
|
+
path,
|
|
869
|
+
message: `must be an object, not ${typeName(entry)}`
|
|
870
|
+
});
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
checkString(issues, `${path}.name`, entry.name, { required: true });
|
|
874
|
+
checkString(issues, `${path}.version`, entry.version);
|
|
875
|
+
checkString(issues, `${path}.category`, entry.category);
|
|
876
|
+
if (typeof entry.source === "string") checkClaudeSource(issues, `${path}.source`, entry.source);
|
|
877
|
+
else if (isObject(entry.source)) {
|
|
878
|
+
checkString(issues, `${path}.source.source`, entry.source.source, { required: true });
|
|
879
|
+
if (entry.source.source === "local") checkClaudeSource(issues, `${path}.source.path`, entry.source.path);
|
|
880
|
+
} else issues.push({
|
|
881
|
+
path: `${path}.source`,
|
|
882
|
+
message: "is required"
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
return issues;
|
|
886
|
+
}
|
|
887
|
+
/** Every issue a target's runtime would raise against this catalog, empty when it loads. */
|
|
888
|
+
function validateCatalog(target, catalog) {
|
|
889
|
+
if (!isObject(catalog)) return [{
|
|
890
|
+
path: "",
|
|
891
|
+
message: `catalog must be a JSON object, not ${typeName(catalog)}`
|
|
892
|
+
}];
|
|
893
|
+
return target === "codex" ? validateCodex(catalog) : validateClaudeShaped(catalog);
|
|
894
|
+
}
|
|
895
|
+
/** The same check against a catalog still in its serialized form. Text that does not parse is one
|
|
896
|
+
* issue rather than a thrown error, so a caller checking four files reports all four. */
|
|
897
|
+
function validateCatalogContent(target, content) {
|
|
898
|
+
let parsed;
|
|
899
|
+
try {
|
|
900
|
+
parsed = JSON.parse(content);
|
|
901
|
+
} catch (err) {
|
|
902
|
+
return [{
|
|
903
|
+
path: "",
|
|
904
|
+
message: `is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
905
|
+
}];
|
|
906
|
+
}
|
|
907
|
+
return validateCatalog(target, parsed);
|
|
908
|
+
}
|
|
909
|
+
/** One error message naming the file and every issue in it, for a caller that fails loud. */
|
|
910
|
+
function formatCatalogIssues(file, issues) {
|
|
911
|
+
return `error: catalog "${file}" does not match the marketplace schema:\n${issues.map((issue) => ` ${issue.path === "" ? file : `${issue.path}`} ${issue.message}`).join("\n")}`;
|
|
912
|
+
}
|
|
913
|
+
//#endregion
|
|
196
914
|
//#region src/build/build.ts
|
|
197
915
|
/** Where each vendor reads its manifest, relative to the project root. Shared with
|
|
198
916
|
* `plugin init --npm`, which wires exactly these paths into `package.json` `files`. */
|
|
@@ -206,6 +924,9 @@ const KNOWN_VENDORS = new Set(Object.keys(VENDOR_OUTPUT));
|
|
|
206
924
|
/** Vendors the canonical root manifest serves as-is. The build derives no file for these — writing
|
|
207
925
|
* one would either be shadowed by root (a lower-precedence path) or clobber root itself. */
|
|
208
926
|
const CANONICAL_SERVED = new Set(["copilot-cli"]);
|
|
927
|
+
/** Where every vendor looks for a plugin's hooks when the manifest declares none
|
|
928
|
+
* (`.research/hook-event-survey/conclusion.md`). */
|
|
929
|
+
const DEFAULT_HOOKS_PATH = "./hooks/hooks.json";
|
|
209
930
|
const UP_NAMESPACE$1 = "org.cyberuni.universal-plugin";
|
|
210
931
|
/** Reads universal-plugin's config block from the canonical manifest's extensions map. */
|
|
211
932
|
function universalPluginExtension(manifest) {
|
|
@@ -213,8 +934,8 @@ function universalPluginExtension(manifest) {
|
|
|
213
934
|
}
|
|
214
935
|
function readManifest(root) {
|
|
215
936
|
const manifestPath = path.join(root, "plugin.json");
|
|
216
|
-
if (!
|
|
217
|
-
return JSON.parse(
|
|
937
|
+
if (!fs.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
|
|
938
|
+
return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
218
939
|
}
|
|
219
940
|
/** Validates the manifest. Vendor rules apply only to the vendors actually being built: pass
|
|
220
941
|
* `targets` to scope the check (build passes its selected targets), else it defaults to the
|
|
@@ -227,12 +948,13 @@ function validateManifest(manifest, targets) {
|
|
|
227
948
|
const codexTargeted = (targets ?? uext.vendors ?? Object.keys(harnesses)).includes("codex") && Boolean(harnesses["codex"]);
|
|
228
949
|
if (codexTargeted && !manifest.description) errors.push("description is required when targeting codex");
|
|
229
950
|
if (codexTargeted && !manifest.version) errors.push("version is required when targeting codex");
|
|
951
|
+
errors.push(...validateDependencies(uext.dependencies).errors);
|
|
230
952
|
return errors;
|
|
231
953
|
}
|
|
232
954
|
function buildPlugin(root, opts = {}) {
|
|
233
955
|
const manifestPath = path.join(root, "plugin.json");
|
|
234
|
-
if (!
|
|
235
|
-
const indent = detectIndent(
|
|
956
|
+
if (!fs.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
|
|
957
|
+
const indent = detectIndent(fs.readFileSync(manifestPath, "utf8"));
|
|
236
958
|
const manifest = readManifest(root);
|
|
237
959
|
const warnings = [];
|
|
238
960
|
const rows = [];
|
|
@@ -261,6 +983,7 @@ function buildPlugin(root, opts = {}) {
|
|
|
261
983
|
written: [],
|
|
262
984
|
warnings,
|
|
263
985
|
rows,
|
|
986
|
+
catalogs: [],
|
|
264
987
|
summary: summarize(rows)
|
|
265
988
|
};
|
|
266
989
|
}
|
|
@@ -268,8 +991,10 @@ function buildPlugin(root, opts = {}) {
|
|
|
268
991
|
if (errors.length > 0) throw new Error(`plugin.json validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
269
992
|
const written = [];
|
|
270
993
|
const { $schema: _schema, extensions: _extensions, ...metadata } = manifest;
|
|
271
|
-
const { vendors: _vendors, packagePath: _packagePath, harnesses: _harnesses, ...componentConfig } = uext;
|
|
994
|
+
const { vendors: _vendors, packagePath: _packagePath, harnesses: _harnesses, dependencies: declaredDependencies, ...componentConfig } = uext;
|
|
995
|
+
warnings.push(...validateDependencies(declaredDependencies).warnings);
|
|
272
996
|
const skills = readSkills(root, manifest);
|
|
997
|
+
const canonicalHooks = readCanonicalHooks(root, componentConfig["hooks"], warnings);
|
|
273
998
|
for (const vendor of vendors) {
|
|
274
999
|
const relPath = VENDOR_OUTPUT[vendor];
|
|
275
1000
|
const outputPath = path.join(root, relPath);
|
|
@@ -280,10 +1005,15 @@ function buildPlugin(root, opts = {}) {
|
|
|
280
1005
|
...componentConfig,
|
|
281
1006
|
...vendorFields
|
|
282
1007
|
};
|
|
1008
|
+
const hooks = canonicalHooks ? translateHooks(canonicalHooks, vendor) : null;
|
|
1009
|
+
const dependencies = translateDependencies(declaredDependencies ?? [], vendor);
|
|
1010
|
+
warnings.push(...dependencies.warnings);
|
|
1011
|
+
if (dependencies.dependencies) vendorManifest["dependencies"] = dependencies.dependencies;
|
|
283
1012
|
if (CANONICAL_SERVED.has(vendor)) {
|
|
1013
|
+
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
1014
|
const overrides = Object.keys(vendorFields);
|
|
285
1015
|
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(
|
|
1016
|
+
writeSkillArtifacts(vendor, skills, opts, written, warnings);
|
|
287
1017
|
rows.push({
|
|
288
1018
|
vendor,
|
|
289
1019
|
path: relPath,
|
|
@@ -291,18 +1021,26 @@ function buildPlugin(root, opts = {}) {
|
|
|
291
1021
|
});
|
|
292
1022
|
continue;
|
|
293
1023
|
}
|
|
1024
|
+
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`);
|
|
1025
|
+
const derivedHooksPath = path.join(outputDir, "hooks.json");
|
|
1026
|
+
if (hooks?.changed) if (hooks.hooks) vendorManifest["hooks"] = `./${path.dirname(relPath).split(path.sep).join("/")}/hooks.json`;
|
|
1027
|
+
else delete vendorManifest["hooks"];
|
|
294
1028
|
if (opts.verbose) {
|
|
295
1029
|
console.log(`[${vendor}] → ${outputPath}`);
|
|
296
1030
|
for (const key of Object.keys(vendorFields)) console.log(` + ${key} (from harnesses.${vendor})`);
|
|
297
1031
|
}
|
|
298
1032
|
try {
|
|
299
1033
|
if (!opts.dryRun) {
|
|
300
|
-
if (opts.clean &&
|
|
301
|
-
|
|
302
|
-
|
|
1034
|
+
if (opts.clean && fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
|
1035
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
1036
|
+
fs.writeFileSync(outputPath, `${JSON.stringify(vendorManifest, null, indent)}\n`);
|
|
303
1037
|
}
|
|
304
1038
|
written.push(outputPath);
|
|
305
|
-
|
|
1039
|
+
if (hooks?.changed) {
|
|
1040
|
+
if (hooks.hooks) writeArtifact(derivedHooksPath, `${JSON.stringify(hooks.hooks, null, indent)}\n`, opts, written);
|
|
1041
|
+
else if (!opts.dryRun && fs.existsSync(derivedHooksPath)) fs.unlinkSync(derivedHooksPath);
|
|
1042
|
+
}
|
|
1043
|
+
writeSkillArtifacts(vendor, skills, opts, written, warnings);
|
|
306
1044
|
rows.push({
|
|
307
1045
|
vendor,
|
|
308
1046
|
path: relPath,
|
|
@@ -317,15 +1055,76 @@ function buildPlugin(root, opts = {}) {
|
|
|
317
1055
|
});
|
|
318
1056
|
}
|
|
319
1057
|
}
|
|
1058
|
+
const catalogs = refreshCatalogs(root, manifest, vendors, opts, written, warnings);
|
|
320
1059
|
return {
|
|
321
1060
|
vendors,
|
|
322
1061
|
written,
|
|
323
1062
|
warnings,
|
|
324
1063
|
rows,
|
|
1064
|
+
catalogs,
|
|
325
1065
|
summary: summarize(rows)
|
|
326
1066
|
};
|
|
327
1067
|
}
|
|
328
|
-
|
|
1068
|
+
/** Keeps the repository's marketplace catalogs true to the manifest just built. A catalog entry's
|
|
1069
|
+
* version is copied from the canonical manifest and never authored (ADR-0010 §3), so the build that
|
|
1070
|
+
* moves the manifest is what re-derives it — otherwise the entry keeps whatever version it was
|
|
1071
|
+
* written with while the plugin moves on.
|
|
1072
|
+
*
|
|
1073
|
+
* Only a catalog the repository already carries is touched, and only this plugin's entry inside it.
|
|
1074
|
+
* Creating one is a choice `plugin init --vendor` and `marketplace init` own; a build makes no new
|
|
1075
|
+
* files at the repository root. */
|
|
1076
|
+
function refreshCatalogs(root, manifest, vendors, opts, written, warnings) {
|
|
1077
|
+
const repo = gatherCatalogRepo(root);
|
|
1078
|
+
if (!repo) return [];
|
|
1079
|
+
const source = repo.pluginPath === "" ? "./" : `./${repo.pluginPath}`;
|
|
1080
|
+
const plugin = {
|
|
1081
|
+
name: manifest.name,
|
|
1082
|
+
source,
|
|
1083
|
+
metadata: manifest
|
|
1084
|
+
};
|
|
1085
|
+
const rows = [];
|
|
1086
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1087
|
+
for (const vendor of vendors) {
|
|
1088
|
+
const target = VENDOR_TARGETS[vendor];
|
|
1089
|
+
if (!target) continue;
|
|
1090
|
+
const relative = TARGET_CATALOG_PATHS[target];
|
|
1091
|
+
if (seen.has(relative)) continue;
|
|
1092
|
+
seen.add(relative);
|
|
1093
|
+
const existing = repo.catalogs[relative];
|
|
1094
|
+
if (existing === void 0) continue;
|
|
1095
|
+
try {
|
|
1096
|
+
const artifact = refreshCatalogEntry(target, plugin, existing);
|
|
1097
|
+
const issues = validateCatalogContent(target, artifact.content);
|
|
1098
|
+
if (issues.length > 0) warnings.push(formatCatalogIssues(relative, issues));
|
|
1099
|
+
if (sameCatalogContent(artifact.content, existing)) {
|
|
1100
|
+
rows.push({
|
|
1101
|
+
path: relative,
|
|
1102
|
+
status: "unchanged"
|
|
1103
|
+
});
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
if (opts.dryRun) {
|
|
1107
|
+
rows.push({
|
|
1108
|
+
path: relative,
|
|
1109
|
+
status: "planned"
|
|
1110
|
+
});
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
const file = path.join(repo.root, relative);
|
|
1114
|
+
const content = `${JSON.stringify(JSON.parse(artifact.content), null, detectIndent(existing))}\n`;
|
|
1115
|
+
fs.writeFileSync(file, content);
|
|
1116
|
+
written.push(file);
|
|
1117
|
+
rows.push({
|
|
1118
|
+
path: relative,
|
|
1119
|
+
status: "updated"
|
|
1120
|
+
});
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
warnings.push(`Failed to refresh "${relative}": ${err instanceof Error ? err.message : String(err)}`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return rows;
|
|
1126
|
+
}
|
|
1127
|
+
function writeSkillArtifacts(vendor, skills, opts, written, warnings) {
|
|
329
1128
|
for (const skill of skills) {
|
|
330
1129
|
if (vendor === "claude-code") {
|
|
331
1130
|
writeClaudeSkill(skill, opts, written);
|
|
@@ -344,18 +1143,18 @@ function readSkills(root, manifest) {
|
|
|
344
1143
|
const skillsCfg = universalPluginExtension(manifest).skills;
|
|
345
1144
|
const skillsPath = typeof skillsCfg === "string" ? skillsCfg : "./skills/";
|
|
346
1145
|
const skillsDir = path.resolve(root, skillsPath);
|
|
347
|
-
if (!
|
|
1146
|
+
if (!fs.existsSync(skillsDir)) return [];
|
|
348
1147
|
return listSkillFiles(skillsDir).map((skillPath) => parseSkill(skillPath));
|
|
349
1148
|
}
|
|
350
1149
|
function listSkillFiles(dir) {
|
|
351
|
-
return
|
|
1150
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
352
1151
|
const entryPath = path.join(dir, entry.name);
|
|
353
1152
|
if (entry.isDirectory()) return listSkillFiles(entryPath);
|
|
354
1153
|
return entry.isFile() && entry.name === "SKILL.md" ? [entryPath] : [];
|
|
355
1154
|
});
|
|
356
1155
|
}
|
|
357
1156
|
function parseSkill(skillPath) {
|
|
358
|
-
const content =
|
|
1157
|
+
const content = fs.readFileSync(skillPath, "utf8");
|
|
359
1158
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
360
1159
|
const frontmatter = match?.[1] ?? "";
|
|
361
1160
|
const rawPolicy = frontmatter.match(/^invocation-policy:\s*['"]?(user|model|both)['"]?\s*(?:#.*)?$/m)?.[1];
|
|
@@ -374,7 +1173,7 @@ function writeClaudeSkill(skill, opts, written) {
|
|
|
374
1173
|
if (!skill.hasInvocationPolicy) return;
|
|
375
1174
|
const content = withClaudeInvocationFlags(skill);
|
|
376
1175
|
if (content === skill.content) return;
|
|
377
|
-
if (!opts.dryRun)
|
|
1176
|
+
if (!opts.dryRun) fs.writeFileSync(skill.path, content);
|
|
378
1177
|
written.push(skill.path);
|
|
379
1178
|
}
|
|
380
1179
|
function withClaudeInvocationFlags(skill) {
|
|
@@ -387,9 +1186,9 @@ function withClaudeInvocationFlags(skill) {
|
|
|
387
1186
|
}
|
|
388
1187
|
function writeArtifact(outputPath, content, opts, written) {
|
|
389
1188
|
if (!opts.dryRun) {
|
|
390
|
-
if (opts.clean &&
|
|
391
|
-
|
|
392
|
-
|
|
1189
|
+
if (opts.clean && fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
|
1190
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
1191
|
+
fs.writeFileSync(outputPath, content);
|
|
393
1192
|
}
|
|
394
1193
|
written.push(outputPath);
|
|
395
1194
|
}
|
|
@@ -401,8 +1200,46 @@ function summarize(rows) {
|
|
|
401
1200
|
canonical: rows.filter((r) => r.status === "canonical").length
|
|
402
1201
|
};
|
|
403
1202
|
}
|
|
404
|
-
|
|
405
|
-
|
|
1203
|
+
/** Resolves the canonical hooks declaration — a path, a list of paths, or an inline block — into one
|
|
1204
|
+
* hooks document. Returns null when there is nothing to translate; an unreadable declaration warns
|
|
1205
|
+
* and leaves the declaration to pass through untouched. */
|
|
1206
|
+
function readCanonicalHooks(root, declaration, warnings) {
|
|
1207
|
+
if (declaration && typeof declaration === "object" && "hooks" in declaration) return declaration;
|
|
1208
|
+
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] : []);
|
|
1209
|
+
if (paths.length === 0) return null;
|
|
1210
|
+
const merged = { hooks: {} };
|
|
1211
|
+
let read = 0;
|
|
1212
|
+
for (const relPath of paths) {
|
|
1213
|
+
const hooksPath = path.resolve(root, relPath);
|
|
1214
|
+
if (!fs.existsSync(hooksPath)) {
|
|
1215
|
+
warnings.push(`hooks file "${relPath}" not found — left untranslated`);
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
let parsed;
|
|
1219
|
+
try {
|
|
1220
|
+
parsed = JSON.parse(fs.readFileSync(hooksPath, "utf8"));
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
warnings.push(`hooks file "${relPath}" could not be read — left untranslated: ${err instanceof Error ? err.message : String(err)}`);
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
read++;
|
|
1226
|
+
for (const [event, rules] of Object.entries(parsed.hooks ?? {})) merged.hooks[event] = [...merged.hooks[event] ?? [], ...rules];
|
|
1227
|
+
}
|
|
1228
|
+
return read === 0 ? null : merged;
|
|
1229
|
+
}
|
|
1230
|
+
/** One warning per event and handler type — three dropped prompt handlers on one event are one loss
|
|
1231
|
+
* to fix, not three. */
|
|
1232
|
+
function dedupeDrops(drops) {
|
|
1233
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1234
|
+
return drops.filter((drop) => {
|
|
1235
|
+
const key = `${drop.event}\u0000${drop.type}`;
|
|
1236
|
+
if (seen.has(key)) return false;
|
|
1237
|
+
seen.add(key);
|
|
1238
|
+
return true;
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
//#endregion
|
|
1242
|
+
//#region src/build/cli.ts
|
|
406
1243
|
const NEXT_STEP$2 = "→ universal-plugin plugin validate\n";
|
|
407
1244
|
function buildCommand() {
|
|
408
1245
|
const cmd = new Command("build").description("Generate vendor manifests from plugin.json");
|
|
@@ -421,17 +1258,23 @@ function buildCommand() {
|
|
|
421
1258
|
skipped: result.rows.filter((r) => r.status === "skipped"),
|
|
422
1259
|
failed: result.rows.filter((r) => r.status === "failed"),
|
|
423
1260
|
canonical: result.rows.filter((r) => r.status === "canonical"),
|
|
1261
|
+
catalogs: result.catalogs,
|
|
424
1262
|
summary: result.summary,
|
|
425
1263
|
warnings: result.warnings
|
|
426
1264
|
};
|
|
427
1265
|
const counts = `built ${built}, skipped ${skipped}, failed ${failed}`;
|
|
1266
|
+
const catalogSummary = result.catalogs.length > 0 ? `, catalogs ${result.catalogs.length}` : "";
|
|
428
1267
|
output(jsonResult, {
|
|
429
1268
|
vendors: result.rows.map((r) => ({
|
|
430
1269
|
vendor: r.vendor,
|
|
431
1270
|
path: r.path,
|
|
432
1271
|
status: r.status
|
|
433
1272
|
})),
|
|
434
|
-
|
|
1273
|
+
catalogs: result.catalogs.map((r) => ({
|
|
1274
|
+
path: r.path,
|
|
1275
|
+
status: r.status
|
|
1276
|
+
})),
|
|
1277
|
+
summary: (canonical > 0 ? `${counts}, served by plugin.json ${canonical}` : counts) + catalogSummary
|
|
435
1278
|
});
|
|
436
1279
|
process.stderr.write(NEXT_STEP$2);
|
|
437
1280
|
if (failed > 0) process.exitCode = 1;
|
|
@@ -459,9 +1302,9 @@ function resolveSkillsDir(root, manifestSkills) {
|
|
|
459
1302
|
return path.join(root, manifestSkills ?? "skills/");
|
|
460
1303
|
}
|
|
461
1304
|
function walk(dir) {
|
|
462
|
-
if (!
|
|
1305
|
+
if (!fs.existsSync(dir)) return [];
|
|
463
1306
|
const files = [];
|
|
464
|
-
for (const entry of
|
|
1307
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
465
1308
|
const entryPath = path.join(dir, entry.name);
|
|
466
1309
|
if (entry.isDirectory()) files.push(...walk(entryPath));
|
|
467
1310
|
else if (entry.isFile() && TEXT_EXTENSIONS.has(path.extname(entry.name))) files.push(entryPath);
|
|
@@ -471,8 +1314,8 @@ function walk(dir) {
|
|
|
471
1314
|
function realPinFs(skillsDir) {
|
|
472
1315
|
return {
|
|
473
1316
|
listSkillFiles: () => walk(skillsDir),
|
|
474
|
-
readFile: (p) =>
|
|
475
|
-
writeFile: (p, c) =>
|
|
1317
|
+
readFile: (p) => fs.readFileSync(p, "utf8"),
|
|
1318
|
+
writeFile: (p, c) => fs.writeFileSync(p, c, "utf8")
|
|
476
1319
|
};
|
|
477
1320
|
}
|
|
478
1321
|
//#endregion
|
|
@@ -606,7 +1449,7 @@ const DEFAULT_GLOBS = ["packages/*"];
|
|
|
606
1449
|
function findMonorepoRoot(start) {
|
|
607
1450
|
let dir = start;
|
|
608
1451
|
for (;;) {
|
|
609
|
-
if (
|
|
1452
|
+
if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
|
|
610
1453
|
const parent = path.dirname(dir);
|
|
611
1454
|
if (parent === dir) return start;
|
|
612
1455
|
dir = parent;
|
|
@@ -635,9 +1478,9 @@ function parseWorkspaceGlobs(yamlText) {
|
|
|
635
1478
|
}
|
|
636
1479
|
function readWorkspaceGlobs(monorepoRoot) {
|
|
637
1480
|
const yamlPath = path.join(monorepoRoot, "pnpm-workspace.yaml");
|
|
638
|
-
if (!
|
|
1481
|
+
if (!fs.existsSync(yamlPath)) return DEFAULT_GLOBS;
|
|
639
1482
|
try {
|
|
640
|
-
const globs = parseWorkspaceGlobs(
|
|
1483
|
+
const globs = parseWorkspaceGlobs(fs.readFileSync(yamlPath, "utf8"));
|
|
641
1484
|
return globs.length > 0 ? globs : DEFAULT_GLOBS;
|
|
642
1485
|
} catch {
|
|
643
1486
|
return DEFAULT_GLOBS;
|
|
@@ -648,11 +1491,11 @@ function readWorkspaceGlobs(monorepoRoot) {
|
|
|
648
1491
|
function expandGlob(monorepoRoot, glob) {
|
|
649
1492
|
if (glob.endsWith("/*")) {
|
|
650
1493
|
const base = path.join(monorepoRoot, glob.slice(0, -2));
|
|
651
|
-
if (!
|
|
652
|
-
return
|
|
1494
|
+
if (!fs.existsSync(base)) return [];
|
|
1495
|
+
return fs.readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(base, entry.name));
|
|
653
1496
|
}
|
|
654
1497
|
const dir = path.join(monorepoRoot, glob);
|
|
655
|
-
return
|
|
1498
|
+
return fs.existsSync(dir) ? [dir] : [];
|
|
656
1499
|
}
|
|
657
1500
|
/** Discovers every workspace member's version, keyed by its `package.json` `name` field (falling
|
|
658
1501
|
* back to the directory name when `package.json` is missing or unreadable — registering the
|
|
@@ -666,7 +1509,7 @@ function discoverWorkspace(root) {
|
|
|
666
1509
|
let name = path.basename(dir);
|
|
667
1510
|
let version;
|
|
668
1511
|
try {
|
|
669
|
-
const pkg = JSON.parse(
|
|
1512
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
670
1513
|
if (typeof pkg.name === "string") name = pkg.name;
|
|
671
1514
|
if (typeof pkg.version === "string") version = pkg.version;
|
|
672
1515
|
} catch {}
|
|
@@ -684,11 +1527,11 @@ function writePinsMap(root, pins) {
|
|
|
684
1527
|
const sorted = {};
|
|
685
1528
|
for (const key of Object.keys(map).sort((a, b) => a.localeCompare(b))) sorted[key] = map[key];
|
|
686
1529
|
const dir = path.join(root, ".plugin");
|
|
687
|
-
|
|
1530
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
688
1531
|
const pinsPath = path.join(dir, "pins.json");
|
|
689
|
-
const existing =
|
|
1532
|
+
const existing = fs.existsSync(pinsPath) ? fs.readFileSync(pinsPath, "utf8") : null;
|
|
690
1533
|
const indent = existing ? detectIndent(existing) : " ";
|
|
691
|
-
|
|
1534
|
+
fs.writeFileSync(pinsPath, `${JSON.stringify(sorted, null, indent)}\n`);
|
|
692
1535
|
}
|
|
693
1536
|
function realVersionSource(workspace) {
|
|
694
1537
|
return { resolve(pkg) {
|
|
@@ -797,14 +1640,14 @@ function configPath(root) {
|
|
|
797
1640
|
const realConfigFs = {
|
|
798
1641
|
read(root) {
|
|
799
1642
|
const file = configPath(root);
|
|
800
|
-
if (!
|
|
801
|
-
return JSON.parse(
|
|
1643
|
+
if (!fs.existsSync(file)) return {};
|
|
1644
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
802
1645
|
},
|
|
803
1646
|
write(root, config) {
|
|
804
1647
|
const file = configPath(root);
|
|
805
|
-
const indent =
|
|
806
|
-
|
|
807
|
-
|
|
1648
|
+
const indent = fs.existsSync(file) ? detectIndent(fs.readFileSync(file, "utf8")) : " ";
|
|
1649
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1650
|
+
fs.writeFileSync(file, `${JSON.stringify(config, null, indent)}\n`);
|
|
808
1651
|
}
|
|
809
1652
|
};
|
|
810
1653
|
//#endregion
|
|
@@ -866,11 +1709,11 @@ function configCommand(fs = realConfigFs) {
|
|
|
866
1709
|
//#endregion
|
|
867
1710
|
//#region src/governance/fs.ts
|
|
868
1711
|
const realGovernanceFs = {
|
|
869
|
-
exists: (p) =>
|
|
870
|
-
read: (p) =>
|
|
1712
|
+
exists: (p) => fs.existsSync(p),
|
|
1713
|
+
read: (p) => fs.readFileSync(p, "utf8"),
|
|
871
1714
|
list: (dir) => {
|
|
872
|
-
if (!
|
|
873
|
-
return
|
|
1715
|
+
if (!fs.existsSync(dir)) return [];
|
|
1716
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => f.slice(0, -3));
|
|
874
1717
|
}
|
|
875
1718
|
};
|
|
876
1719
|
//#endregion
|
|
@@ -973,7 +1816,7 @@ function listGovernances(root, govFs) {
|
|
|
973
1816
|
function readGlobalState() {
|
|
974
1817
|
const p = path.join(os.homedir(), ".agents", "universal-plugin.json");
|
|
975
1818
|
try {
|
|
976
|
-
return mergeSafeState(JSON.parse(
|
|
1819
|
+
return mergeSafeState(JSON.parse(fs.readFileSync(p, "utf8")));
|
|
977
1820
|
} catch {
|
|
978
1821
|
return emptyState();
|
|
979
1822
|
}
|
|
@@ -1007,6 +1850,45 @@ function governanceCommand() {
|
|
|
1007
1850
|
}
|
|
1008
1851
|
//#endregion
|
|
1009
1852
|
//#region src/init/fs.ts
|
|
1853
|
+
function git(root, args) {
|
|
1854
|
+
try {
|
|
1855
|
+
return execFileSync("git", [
|
|
1856
|
+
"-C",
|
|
1857
|
+
root,
|
|
1858
|
+
...args
|
|
1859
|
+
], {
|
|
1860
|
+
encoding: "utf8",
|
|
1861
|
+
stdio: [
|
|
1862
|
+
"ignore",
|
|
1863
|
+
"pipe",
|
|
1864
|
+
"ignore"
|
|
1865
|
+
]
|
|
1866
|
+
}).trim();
|
|
1867
|
+
} catch {
|
|
1868
|
+
return null;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
/** Where the plugin sits in its repository, and the catalogs that repository already carries.
|
|
1872
|
+
* `undefined` outside a repository, which is the one case with nowhere to put a catalog. The
|
|
1873
|
+
* repository-and-catalogs half is shared with `plugin build`, which refreshes the same files. */
|
|
1874
|
+
function gatherRepo(root) {
|
|
1875
|
+
const repo = gatherCatalogRepo(root);
|
|
1876
|
+
if (!repo) return void 0;
|
|
1877
|
+
const match = git(root, [
|
|
1878
|
+
"remote",
|
|
1879
|
+
"get-url",
|
|
1880
|
+
"origin"
|
|
1881
|
+
])?.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?$/);
|
|
1882
|
+
return {
|
|
1883
|
+
pluginPath: repo.pluginPath,
|
|
1884
|
+
dirName: path.basename(repo.root),
|
|
1885
|
+
slug: match ? {
|
|
1886
|
+
owner: match[1],
|
|
1887
|
+
repo: match[2]
|
|
1888
|
+
} : void 0,
|
|
1889
|
+
catalogs: repo.catalogs
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1010
1892
|
function manifestPath(root) {
|
|
1011
1893
|
return path.join(root, "plugin.json");
|
|
1012
1894
|
}
|
|
@@ -1016,26 +1898,32 @@ function packageJsonPath(root) {
|
|
|
1016
1898
|
const realInitFs = {
|
|
1017
1899
|
gather(root) {
|
|
1018
1900
|
const pj = packageJsonPath(root);
|
|
1019
|
-
const packageJson =
|
|
1901
|
+
const packageJson = fs.existsSync(pj) ? JSON.parse(fs.readFileSync(pj, "utf8")) : null;
|
|
1020
1902
|
return {
|
|
1021
|
-
manifestExists:
|
|
1022
|
-
packageJson
|
|
1903
|
+
manifestExists: fs.existsSync(manifestPath(root)),
|
|
1904
|
+
packageJson,
|
|
1905
|
+
repo: gatherRepo(root)
|
|
1023
1906
|
};
|
|
1024
1907
|
},
|
|
1025
1908
|
apply(root, plan) {
|
|
1026
|
-
|
|
1027
|
-
for (const dir of plan.dirs)
|
|
1909
|
+
fs.writeFileSync(manifestPath(root), `${JSON.stringify(plan.manifest, null, " ")}\n`);
|
|
1910
|
+
for (const dir of plan.dirs) fs.mkdirSync(path.join(root, dir), { recursive: true });
|
|
1911
|
+
for (const catalog of plan.catalogs) {
|
|
1912
|
+
const file = path.join(root, catalog.path);
|
|
1913
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1914
|
+
fs.writeFileSync(file, catalog.content);
|
|
1915
|
+
}
|
|
1028
1916
|
if (plan.packageJson) {
|
|
1029
1917
|
const pj = packageJsonPath(root);
|
|
1030
|
-
const indent = detectIndent(
|
|
1031
|
-
|
|
1918
|
+
const indent = detectIndent(fs.readFileSync(pj, "utf8"));
|
|
1919
|
+
fs.writeFileSync(pj, `${JSON.stringify(plan.packageJson, null, indent)}\n`);
|
|
1032
1920
|
}
|
|
1033
1921
|
}
|
|
1034
1922
|
};
|
|
1035
1923
|
//#endregion
|
|
1036
1924
|
//#region src/init/init.ts
|
|
1037
|
-
/** Pure domain for `plugin init` — scaffolding the canonical manifest
|
|
1038
|
-
* wiring an npm package to ship it.
|
|
1925
|
+
/** Pure domain for `plugin init` — scaffolding the canonical manifest, registering the plugin in the
|
|
1926
|
+
* repository's local marketplace catalogs, and (with `--npm`) wiring an npm package to ship it.
|
|
1039
1927
|
*
|
|
1040
1928
|
* No I/O: the caller gathers the current filesystem state, calls `planInit`, and applies the
|
|
1041
1929
|
* returned plan. `planInit` owns the rules — the guard order, the closed manifest shape, the
|
|
@@ -1077,6 +1965,73 @@ function wireFiles(pkg, manifestPaths) {
|
|
|
1077
1965
|
files
|
|
1078
1966
|
};
|
|
1079
1967
|
}
|
|
1968
|
+
/** The local marketplace a repository carries is named after the repository, not after the plugin:
|
|
1969
|
+
* the catalog sits at the repository root and lists every plugin the repository develops. `-local`
|
|
1970
|
+
* separates it from a published marketplace of the same plugins. */
|
|
1971
|
+
function marketplaceName(repo) {
|
|
1972
|
+
return `${repo.slug ? `${repo.slug.owner}-${repo.slug.repo}` : repo.dirName}-local`;
|
|
1973
|
+
}
|
|
1974
|
+
/** An npm `author` — `"Bea <bea@example.com> (https://example.com)"` or the object form — read as a
|
|
1975
|
+
* catalog owner. */
|
|
1976
|
+
function readAuthor(author) {
|
|
1977
|
+
if (typeof author === "string") {
|
|
1978
|
+
const name = author.replace(/[<(].*$/, "").trim();
|
|
1979
|
+
return name === "" ? void 0 : { name };
|
|
1980
|
+
}
|
|
1981
|
+
if (typeof author !== "object" || author === null) return void 0;
|
|
1982
|
+
const record = author;
|
|
1983
|
+
if (typeof record.name !== "string" || record.name.trim() === "") return void 0;
|
|
1984
|
+
const owner = { name: record.name };
|
|
1985
|
+
if (typeof record.email === "string") owner.email = record.email;
|
|
1986
|
+
if (typeof record.url === "string") owner.url = record.url;
|
|
1987
|
+
return owner;
|
|
1988
|
+
}
|
|
1989
|
+
/** Who the catalog says maintains the marketplace. The canonical manifest first, then the package
|
|
1990
|
+
* that ships it, then the account the repository lives under. Every runtime requires this, so a
|
|
1991
|
+
* repository with none of the three gets no catalog rather than an invented owner. */
|
|
1992
|
+
function catalogOwner(state, repo, manifest) {
|
|
1993
|
+
return readAuthor(manifest.author) ?? readAuthor(state.packageJson?.author) ?? (repo.slug ? { name: repo.slug.owner } : void 0);
|
|
1994
|
+
}
|
|
1995
|
+
/** The plugin's own entry in every selected vendor's catalog, folded into whatever the repository
|
|
1996
|
+
* already carries. Nothing here authors a version: the entry carries the canonical manifest's, and
|
|
1997
|
+
* the manifest `init` writes carries none (ADR-0010). */
|
|
1998
|
+
function planCatalogs(state, opts, manifest, notes) {
|
|
1999
|
+
if (opts.marketplace === false || opts.vendors.length === 0) return [];
|
|
2000
|
+
const repo = state.repo;
|
|
2001
|
+
if (!repo) {
|
|
2002
|
+
notes.push("no local marketplace catalog: the plugin root is not inside a repository");
|
|
2003
|
+
return [];
|
|
2004
|
+
}
|
|
2005
|
+
const owner = catalogOwner(state, repo, manifest);
|
|
2006
|
+
if (!owner) {
|
|
2007
|
+
notes.push("no local marketplace catalog: no owner to name — add an author to plugin.json");
|
|
2008
|
+
return [];
|
|
2009
|
+
}
|
|
2010
|
+
const metadata = {
|
|
2011
|
+
name: marketplaceName(repo),
|
|
2012
|
+
owner
|
|
2013
|
+
};
|
|
2014
|
+
const source = repo.pluginPath === "" ? "./" : `./${repo.pluginPath}`;
|
|
2015
|
+
const plugin = {
|
|
2016
|
+
name: manifest.name,
|
|
2017
|
+
source,
|
|
2018
|
+
metadata: manifest
|
|
2019
|
+
};
|
|
2020
|
+
const toPluginRoot = repo.pluginPath === "" ? "" : `${repo.pluginPath.split("/").map(() => "..").join("/")}/`;
|
|
2021
|
+
const catalogs = [];
|
|
2022
|
+
for (const vendor of opts.vendors) {
|
|
2023
|
+
const target = VENDOR_TARGETS[vendor];
|
|
2024
|
+
if (!target) continue;
|
|
2025
|
+
const artifact = mergeCatalogEntry(target, metadata, plugin, (path) => repo.catalogs[path]);
|
|
2026
|
+
const issues = validateCatalogContent(target, artifact.content);
|
|
2027
|
+
if (issues.length > 0) notes.push(formatCatalogIssues(artifact.path, issues));
|
|
2028
|
+
catalogs.push({
|
|
2029
|
+
path: `${toPluginRoot}${artifact.path}`,
|
|
2030
|
+
content: artifact.content
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
return catalogs;
|
|
2034
|
+
}
|
|
1080
2035
|
/** Plans the init run. Throws on a guard failure (an existing manifest without `--force`; `--npm`
|
|
1081
2036
|
* with no `package.json`) before returning any plan, so the caller writes nothing on a guard trip. */
|
|
1082
2037
|
function planInit(state, opts, rootDirName, resolveManifestPath) {
|
|
@@ -1088,6 +2043,16 @@ function planInit(state, opts, rootDirName, resolveManifestPath) {
|
|
|
1088
2043
|
path: "plugin.json",
|
|
1089
2044
|
action: "created"
|
|
1090
2045
|
}];
|
|
2046
|
+
const notes = [];
|
|
2047
|
+
const catalogs = planCatalogs(state, opts, manifest, notes);
|
|
2048
|
+
const existing = state.repo?.catalogs ?? {};
|
|
2049
|
+
for (const catalog of catalogs) {
|
|
2050
|
+
const previous = existing[catalog.path.replace(/^(\.\.\/)+/, "")];
|
|
2051
|
+
rows.push({
|
|
2052
|
+
path: catalog.path,
|
|
2053
|
+
action: previous === void 0 ? "created" : previous === catalog.content ? "unchanged" : "updated"
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
1091
2056
|
let packageJson = null;
|
|
1092
2057
|
if (opts.npm) {
|
|
1093
2058
|
const manifestPaths = (opts.vendors.length > 0 ? opts.vendors : ["claude-code"]).map(resolveManifestPath).filter((p) => Boolean(p));
|
|
@@ -1099,26 +2064,30 @@ function planInit(state, opts, rootDirName, resolveManifestPath) {
|
|
|
1099
2064
|
}
|
|
1100
2065
|
const created = rows.filter((r) => r.action === "created").length;
|
|
1101
2066
|
const updated = rows.filter((r) => r.action === "updated").length;
|
|
2067
|
+
const unchanged = rows.filter((r) => r.action === "unchanged").length;
|
|
1102
2068
|
return {
|
|
1103
2069
|
manifest,
|
|
1104
2070
|
dirs,
|
|
1105
2071
|
packageJson,
|
|
2072
|
+
catalogs,
|
|
1106
2073
|
rows,
|
|
2074
|
+
notes,
|
|
1107
2075
|
summary: {
|
|
1108
2076
|
created,
|
|
1109
|
-
updated
|
|
2077
|
+
updated,
|
|
2078
|
+
unchanged
|
|
1110
2079
|
}
|
|
1111
2080
|
};
|
|
1112
2081
|
}
|
|
1113
2082
|
//#endregion
|
|
1114
2083
|
//#region src/init/cli.ts
|
|
1115
2084
|
const NEXT_STEP = "→ add skills to skills/, then run universal-plugin plugin build\n";
|
|
1116
|
-
function collect(value, previous) {
|
|
2085
|
+
function collect$1(value, previous) {
|
|
1117
2086
|
return [...previous, value];
|
|
1118
2087
|
}
|
|
1119
2088
|
function initCommand$1(deps = { fs: realInitFs }) {
|
|
1120
2089
|
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) => {
|
|
2090
|
+
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
2091
|
try {
|
|
1123
2092
|
const root = resolveRoot(opts.root);
|
|
1124
2093
|
const plan = planInit(deps.fs.gather(root), {
|
|
@@ -1126,7 +2095,8 @@ function initCommand$1(deps = { fs: realInitFs }) {
|
|
|
1126
2095
|
vendors: opts.vendor ?? [],
|
|
1127
2096
|
scaffold: Boolean(opts.scaffold),
|
|
1128
2097
|
force: Boolean(opts.force),
|
|
1129
|
-
npm: Boolean(opts.npm)
|
|
2098
|
+
npm: Boolean(opts.npm),
|
|
2099
|
+
marketplace: opts.marketplace !== false
|
|
1130
2100
|
}, path.basename(root), (vendor) => VENDOR_OUTPUT[vendor]);
|
|
1131
2101
|
deps.fs.apply(root, plan);
|
|
1132
2102
|
output({
|
|
@@ -1138,8 +2108,9 @@ function initCommand$1(deps = { fs: realInitFs }) {
|
|
|
1138
2108
|
path: r.path,
|
|
1139
2109
|
action: r.action
|
|
1140
2110
|
})),
|
|
1141
|
-
summary: `created ${plan.summary.created}, updated ${plan.summary.updated}`
|
|
2111
|
+
summary: `created ${plan.summary.created}, updated ${plan.summary.updated}, unchanged ${plan.summary.unchanged}`
|
|
1142
2112
|
});
|
|
2113
|
+
for (const note of plan.notes) process.stderr.write(`${note}\n`);
|
|
1143
2114
|
process.stderr.write(NEXT_STEP);
|
|
1144
2115
|
} catch (err) {
|
|
1145
2116
|
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
@@ -1149,123 +2120,380 @@ function initCommand$1(deps = { fs: realInitFs }) {
|
|
|
1149
2120
|
return cmd;
|
|
1150
2121
|
}
|
|
1151
2122
|
//#endregion
|
|
1152
|
-
//#region src/
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
2123
|
+
//#region src/vendor-registry/vendor-registry.ts
|
|
2124
|
+
function lookupVendor(registry, vendorId) {
|
|
2125
|
+
return registry[vendorId] ?? null;
|
|
2126
|
+
}
|
|
2127
|
+
function mergeRegistries(base, override) {
|
|
2128
|
+
const result = { ...base };
|
|
2129
|
+
for (const [id, config] of Object.entries(override)) result[id] = {
|
|
2130
|
+
...base[id] ?? {},
|
|
2131
|
+
...config
|
|
2132
|
+
};
|
|
2133
|
+
return result;
|
|
2134
|
+
}
|
|
2135
|
+
//#endregion
|
|
2136
|
+
//#region src/vendor-registry/fs.ts
|
|
2137
|
+
function bundledRegistryPath() {
|
|
2138
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
2139
|
+
return path.join(path.dirname(thisFile), "data", "vendors.json");
|
|
2140
|
+
}
|
|
2141
|
+
function userOverridePath() {
|
|
2142
|
+
return path.join(os.homedir(), ".agents", "universal-plugin-vendors.json");
|
|
2143
|
+
}
|
|
2144
|
+
function loadRegistry() {
|
|
2145
|
+
const bundled = JSON.parse(fs.readFileSync(bundledRegistryPath(), "utf8"));
|
|
2146
|
+
try {
|
|
2147
|
+
return mergeRegistries(bundled, JSON.parse(fs.readFileSync(userOverridePath(), "utf8")));
|
|
2148
|
+
} catch (err) {
|
|
2149
|
+
if (err.code === "ENOENT") return bundled;
|
|
2150
|
+
throw err;
|
|
1172
2151
|
}
|
|
1173
|
-
}
|
|
2152
|
+
}
|
|
1174
2153
|
//#endregion
|
|
1175
|
-
//#region src/
|
|
1176
|
-
|
|
1177
|
-
"
|
|
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`);
|
|
2154
|
+
//#region src/install/fs.ts
|
|
2155
|
+
function expandHome$1(p) {
|
|
2156
|
+
return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
|
|
1186
2157
|
}
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
return result;
|
|
2158
|
+
/** Resolves a vendor's local plugin directory to an absolute path. */
|
|
2159
|
+
function resolveLocalDir(localPluginDir) {
|
|
2160
|
+
return localPluginDir === null ? null : path.resolve(expandHome$1(localPluginDir));
|
|
1191
2161
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
2162
|
+
/** Reads what occupies a destination. A symlink is reported with its resolved target so the plan
|
|
2163
|
+
* can tell our own install from someone else's; a directory is reported with the `name` of the
|
|
2164
|
+
* canonical manifest inside it, which is what identifies an earlier `--copy` install. */
|
|
2165
|
+
function readDestination(dest) {
|
|
2166
|
+
let stats;
|
|
2167
|
+
try {
|
|
2168
|
+
stats = fs.lstatSync(dest);
|
|
2169
|
+
} catch (err) {
|
|
2170
|
+
if (err.code === "ENOENT") return { kind: "absent" };
|
|
2171
|
+
throw err;
|
|
2172
|
+
}
|
|
2173
|
+
if (stats.isSymbolicLink()) try {
|
|
2174
|
+
return {
|
|
2175
|
+
kind: "symlink",
|
|
2176
|
+
target: fs.realpathSync(dest)
|
|
2177
|
+
};
|
|
2178
|
+
} catch {
|
|
2179
|
+
return {
|
|
2180
|
+
kind: "symlink",
|
|
2181
|
+
target: fs.readlinkSync(dest)
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
if (!stats.isDirectory()) return { kind: "file" };
|
|
2185
|
+
return {
|
|
2186
|
+
kind: "directory",
|
|
2187
|
+
pluginName: readPluginName(path.join(dest, "plugin.json"))
|
|
2188
|
+
};
|
|
1194
2189
|
}
|
|
1195
|
-
function
|
|
2190
|
+
function readPluginName(manifestPath) {
|
|
2191
|
+
try {
|
|
2192
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
2193
|
+
return typeof manifest.name === "string" ? manifest.name : null;
|
|
2194
|
+
} catch {
|
|
2195
|
+
return null;
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
/** Entries a copy never carries: the working copy's history and its installed dependencies. Neither
|
|
2199
|
+
* is part of the plugin, and both dwarf it. */
|
|
2200
|
+
const COPY_EXCLUDED = new Set([".git", "node_modules"]);
|
|
2201
|
+
/** Applies one planned write. A replace removes the destination first, so a copy never merges into
|
|
2202
|
+
* what was there before. A copy dereferences symlinks — a skill linked into the tree has to travel
|
|
2203
|
+
* as its content, since the destination is read on its own. */
|
|
2204
|
+
function applyWrite(root, write) {
|
|
2205
|
+
if (write.replace) fs.rmSync(write.dest, {
|
|
2206
|
+
recursive: true,
|
|
2207
|
+
force: true
|
|
2208
|
+
});
|
|
2209
|
+
fs.mkdirSync(path.dirname(write.dest), { recursive: true });
|
|
2210
|
+
if (write.mode === "link") {
|
|
2211
|
+
fs.symlinkSync(root, write.dest, "dir");
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
fs.cpSync(root, write.dest, {
|
|
2215
|
+
recursive: true,
|
|
2216
|
+
dereference: true,
|
|
2217
|
+
filter: (source) => !COPY_EXCLUDED.has(path.basename(source))
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
function applyRemoval(dest) {
|
|
2221
|
+
fs.rmSync(dest, {
|
|
2222
|
+
recursive: true,
|
|
2223
|
+
force: true
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
//#endregion
|
|
2227
|
+
//#region src/install/install.ts
|
|
2228
|
+
/** Pure domain for `plugin install` / `plugin uninstall` — putting the plugin under development
|
|
2229
|
+
* into a runtime, and taking it back out.
|
|
2230
|
+
*
|
|
2231
|
+
* No I/O: the caller resolves each vendor's local plugin directory, reads what currently occupies
|
|
2232
|
+
* the destination, and applies the returned plan. The rules live here — how a mode resolves per
|
|
2233
|
+
* vendor, what counts as our own install, and when a destination is refused. */
|
|
2234
|
+
/** The destination this plugin occupies in a vendor's local plugin directory. */
|
|
2235
|
+
function destinationPath(dir, pluginName) {
|
|
2236
|
+
return path.join(dir, pluginName);
|
|
2237
|
+
}
|
|
2238
|
+
/** Whether a destination holds this plugin already — our symlink back to the root, or a directory
|
|
2239
|
+
* carrying this plugin's canonical manifest. Anything else belongs to someone else. */
|
|
2240
|
+
function isOurs(state, root, pluginName) {
|
|
2241
|
+
if (state.kind === "symlink") return state.target === root;
|
|
2242
|
+
if (state.kind === "directory") return state.pluginName === pluginName;
|
|
2243
|
+
return false;
|
|
2244
|
+
}
|
|
2245
|
+
function occupiedReason(state) {
|
|
2246
|
+
if (state.kind === "symlink") return `a symlink to ${state.target} is already there; --force replaces it`;
|
|
2247
|
+
if (state.kind === "directory") return "another plugin is already installed there; --force replaces it";
|
|
2248
|
+
return "a file is already there; --force replaces it";
|
|
2249
|
+
}
|
|
2250
|
+
/** The mode a vendor actually gets. Returns `null` when `--link` was demanded of a vendor that
|
|
2251
|
+
* rejects an out-of-tree symlink — the caller reports that as blocked rather than silently
|
|
2252
|
+
* copying, since the author asked for a live link and would not get one. */
|
|
2253
|
+
function resolveMode$1(mode, target) {
|
|
2254
|
+
if (mode === "copy") return "copy";
|
|
2255
|
+
if (target.link) return "link";
|
|
2256
|
+
return mode === "link" ? null : "copy";
|
|
2257
|
+
}
|
|
2258
|
+
/** Plans an install. Throws when a targeted vendor's derived manifest is missing — installing a
|
|
2259
|
+
* plugin whose manifests were never built hands the runtime a half-built plugin, and the author
|
|
2260
|
+
* finds out as a load failure rather than as an error here. */
|
|
2261
|
+
function planInstall(input) {
|
|
2262
|
+
const missing = input.targets.filter((t) => t.dir !== null && input.manifests[t.vendor] === false);
|
|
2263
|
+
if (missing.length > 0) {
|
|
2264
|
+
const paths = missing.map((t) => ` - ${t.manifestPath} (${t.vendor})`).join("\n");
|
|
2265
|
+
throw new Error(`No derived manifest for:\n${paths}\nRun universal-plugin plugin build first.`);
|
|
2266
|
+
}
|
|
2267
|
+
const rows = [];
|
|
2268
|
+
const writes = [];
|
|
2269
|
+
for (const target of input.targets) {
|
|
2270
|
+
if (target.dir === null) {
|
|
2271
|
+
rows.push({
|
|
2272
|
+
vendor: target.vendor,
|
|
2273
|
+
path: "-",
|
|
2274
|
+
action: "unsupported",
|
|
2275
|
+
reason: `${target.vendor} has no local plugin directory; install it from a repository-local marketplace instead`
|
|
2276
|
+
});
|
|
2277
|
+
continue;
|
|
2278
|
+
}
|
|
2279
|
+
const dest = destinationPath(target.dir, input.pluginName);
|
|
2280
|
+
const state = input.dest[target.vendor] ?? { kind: "absent" };
|
|
2281
|
+
const mode = resolveMode$1(input.mode, target);
|
|
2282
|
+
if (mode === null) {
|
|
2283
|
+
rows.push({
|
|
2284
|
+
vendor: target.vendor,
|
|
2285
|
+
path: dest,
|
|
2286
|
+
action: "blocked",
|
|
2287
|
+
reason: `${target.vendor} does not load a symlink from outside ${target.dir}; use --copy`
|
|
2288
|
+
});
|
|
2289
|
+
continue;
|
|
2290
|
+
}
|
|
2291
|
+
const ours = isOurs(state, input.root, input.pluginName);
|
|
2292
|
+
if (state.kind !== "absent" && !ours && !input.force) {
|
|
2293
|
+
rows.push({
|
|
2294
|
+
vendor: target.vendor,
|
|
2295
|
+
path: dest,
|
|
2296
|
+
action: "blocked",
|
|
2297
|
+
reason: occupiedReason(state)
|
|
2298
|
+
});
|
|
2299
|
+
continue;
|
|
2300
|
+
}
|
|
2301
|
+
if (ours && mode === "link" && state.kind === "symlink") {
|
|
2302
|
+
rows.push({
|
|
2303
|
+
vendor: target.vendor,
|
|
2304
|
+
path: dest,
|
|
2305
|
+
action: "unchanged"
|
|
2306
|
+
});
|
|
2307
|
+
continue;
|
|
2308
|
+
}
|
|
2309
|
+
writes.push({
|
|
2310
|
+
vendor: target.vendor,
|
|
2311
|
+
dest,
|
|
2312
|
+
mode,
|
|
2313
|
+
replace: state.kind !== "absent"
|
|
2314
|
+
});
|
|
2315
|
+
rows.push({
|
|
2316
|
+
vendor: target.vendor,
|
|
2317
|
+
path: dest,
|
|
2318
|
+
action: mode === "link" ? "linked" : "copied"
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
1196
2321
|
return {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
plugins: plugins.map((plugin) => ({
|
|
1201
|
-
name: plugin.name,
|
|
1202
|
-
source: plugin.source,
|
|
1203
|
-
...commonMetadata(plugin)
|
|
1204
|
-
}))
|
|
1205
|
-
})
|
|
2322
|
+
rows,
|
|
2323
|
+
writes,
|
|
2324
|
+
summary: summarizeInstall(rows)
|
|
1206
2325
|
};
|
|
1207
2326
|
}
|
|
1208
|
-
|
|
2327
|
+
/** Plans an uninstall. Never throws: a destination that was never installed is reported, not an
|
|
2328
|
+
* error, so `uninstall` is safe to run twice and safe to run over a partial install. */
|
|
2329
|
+
function planUninstall(input) {
|
|
2330
|
+
const rows = [];
|
|
2331
|
+
const removals = [];
|
|
2332
|
+
for (const target of input.targets) {
|
|
2333
|
+
if (target.dir === null) {
|
|
2334
|
+
rows.push({
|
|
2335
|
+
vendor: target.vendor,
|
|
2336
|
+
path: "-",
|
|
2337
|
+
action: "unsupported"
|
|
2338
|
+
});
|
|
2339
|
+
continue;
|
|
2340
|
+
}
|
|
2341
|
+
const dest = destinationPath(target.dir, input.pluginName);
|
|
2342
|
+
const state = input.dest[target.vendor] ?? { kind: "absent" };
|
|
2343
|
+
if (state.kind === "absent") {
|
|
2344
|
+
rows.push({
|
|
2345
|
+
vendor: target.vendor,
|
|
2346
|
+
path: dest,
|
|
2347
|
+
action: "missing"
|
|
2348
|
+
});
|
|
2349
|
+
continue;
|
|
2350
|
+
}
|
|
2351
|
+
if (!isOurs(state, input.root, input.pluginName) && !input.force) {
|
|
2352
|
+
rows.push({
|
|
2353
|
+
vendor: target.vendor,
|
|
2354
|
+
path: dest,
|
|
2355
|
+
action: "blocked",
|
|
2356
|
+
reason: "this is not an install of this plugin; --force removes it anyway"
|
|
2357
|
+
});
|
|
2358
|
+
continue;
|
|
2359
|
+
}
|
|
2360
|
+
removals.push(dest);
|
|
2361
|
+
rows.push({
|
|
2362
|
+
vendor: target.vendor,
|
|
2363
|
+
path: dest,
|
|
2364
|
+
action: "removed"
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
1209
2367
|
return {
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
-
})
|
|
2368
|
+
rows,
|
|
2369
|
+
removals,
|
|
2370
|
+
summary: summarizeUninstall(rows)
|
|
1228
2371
|
};
|
|
1229
2372
|
}
|
|
1230
|
-
function
|
|
2373
|
+
function summarizeInstall(rows) {
|
|
2374
|
+
const count = (action) => rows.filter((r) => r.action === action).length;
|
|
1231
2375
|
return {
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
plugins: plugins.map((plugin) => ({
|
|
1237
|
-
name: plugin.name,
|
|
1238
|
-
source: plugin.source,
|
|
1239
|
-
...commonMetadata(plugin)
|
|
1240
|
-
}))
|
|
1241
|
-
})
|
|
2376
|
+
installed: count("linked") + count("copied"),
|
|
2377
|
+
unchanged: count("unchanged"),
|
|
2378
|
+
blocked: count("blocked"),
|
|
2379
|
+
unsupported: count("unsupported")
|
|
1242
2380
|
};
|
|
1243
2381
|
}
|
|
1244
|
-
function
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
-
}];
|
|
2382
|
+
function summarizeUninstall(rows) {
|
|
2383
|
+
const count = (action) => rows.filter((r) => r.action === action).length;
|
|
2384
|
+
return {
|
|
2385
|
+
removed: count("removed"),
|
|
2386
|
+
missing: count("missing"),
|
|
2387
|
+
blocked: count("blocked"),
|
|
2388
|
+
unsupported: count("unsupported")
|
|
2389
|
+
};
|
|
1261
2390
|
}
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
2391
|
+
//#endregion
|
|
2392
|
+
//#region src/install/cli.ts
|
|
2393
|
+
function collect(value, previous) {
|
|
2394
|
+
return [...previous, value];
|
|
2395
|
+
}
|
|
2396
|
+
function resolveMode(opts) {
|
|
2397
|
+
if (opts.link && opts.copy) throw new Error("--link and --copy are mutually exclusive");
|
|
2398
|
+
if (opts.link) return "link";
|
|
2399
|
+
if (opts.copy) return "copy";
|
|
2400
|
+
return "auto";
|
|
2401
|
+
}
|
|
2402
|
+
/** Gathers everything the pure planner needs: the plugin's name, the vendors it declares, each
|
|
2403
|
+
* vendor's local-install facts, and what currently sits at each destination. */
|
|
2404
|
+
function gather(root, opts, mode) {
|
|
2405
|
+
const manifest = readManifest(root);
|
|
2406
|
+
if (!manifest.name) throw new Error(`plugin.json at ${root} declares no name`);
|
|
2407
|
+
const uext = universalPluginExtension(manifest);
|
|
2408
|
+
const declared = uext.vendors ?? Object.keys(uext.harnesses ?? {});
|
|
2409
|
+
const selected = opts.vendor?.length ? opts.vendor : declared;
|
|
2410
|
+
for (const vendor of selected) if (!declared.includes(vendor)) throw new Error(`Vendor "${vendor}" not declared in this plugin's manifest`);
|
|
2411
|
+
if (selected.length === 0) throw new Error("No vendors declared in this plugin's manifest — nothing to install");
|
|
2412
|
+
const registry = loadRegistry();
|
|
2413
|
+
const targets = selected.map((vendor) => {
|
|
2414
|
+
const config = registry[vendor];
|
|
2415
|
+
if (!config) throw new Error(`Unknown vendor: ${vendor}`);
|
|
2416
|
+
return {
|
|
2417
|
+
vendor,
|
|
2418
|
+
dir: resolveLocalDir(config.localPluginDir),
|
|
2419
|
+
link: config.localPluginLink,
|
|
2420
|
+
manifestPath: VENDOR_OUTPUT[vendor] ?? "plugin.json"
|
|
2421
|
+
};
|
|
2422
|
+
});
|
|
2423
|
+
const dest = {};
|
|
2424
|
+
const manifests = {};
|
|
2425
|
+
for (const target of targets) {
|
|
2426
|
+
manifests[target.vendor] = fs.existsSync(path.join(root, target.manifestPath));
|
|
2427
|
+
if (target.dir !== null) dest[target.vendor] = readDestination(destinationPath(target.dir, manifest.name));
|
|
1268
2428
|
}
|
|
2429
|
+
return {
|
|
2430
|
+
pluginName: manifest.name,
|
|
2431
|
+
root,
|
|
2432
|
+
mode,
|
|
2433
|
+
force: Boolean(opts.force),
|
|
2434
|
+
targets,
|
|
2435
|
+
dest,
|
|
2436
|
+
manifests
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
/** The per-vendor step the runtime needs before it sees the change. Printed for every vendor the
|
|
2440
|
+
* run actually touched — an install nothing reloads looks exactly like an install that failed. */
|
|
2441
|
+
function reloadNotes(vendors) {
|
|
2442
|
+
const registry = loadRegistry();
|
|
2443
|
+
return vendors.map((vendor) => {
|
|
2444
|
+
const reload = registry[vendor]?.localReload;
|
|
2445
|
+
return reload ? `→ ${vendor}: ${reload}` : null;
|
|
2446
|
+
}).filter((line) => line !== null);
|
|
2447
|
+
}
|
|
2448
|
+
function installCommand() {
|
|
2449
|
+
const cmd = new Command("install").description("Install this plugin into the runtimes it targets, for local development");
|
|
2450
|
+
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) => {
|
|
2451
|
+
try {
|
|
2452
|
+
const root = resolveRoot(opts.root);
|
|
2453
|
+
const plan = planInstall(gather(root, opts, resolveMode(opts)));
|
|
2454
|
+
if (!opts.list) for (const write of plan.writes) applyWrite(root, write);
|
|
2455
|
+
const { installed, unchanged, blocked, unsupported } = plan.summary;
|
|
2456
|
+
output(plan, {
|
|
2457
|
+
vendors: plan.rows.map((r) => ({
|
|
2458
|
+
vendor: r.vendor,
|
|
2459
|
+
path: r.path,
|
|
2460
|
+
action: r.action
|
|
2461
|
+
})),
|
|
2462
|
+
summary: `installed ${installed}, unchanged ${unchanged}, blocked ${blocked}, unsupported ${unsupported}`
|
|
2463
|
+
});
|
|
2464
|
+
for (const row of plan.rows) if (row.reason) process.stderr.write(`warn: ${row.vendor}: ${row.reason}\n`);
|
|
2465
|
+
if (!opts.list) for (const note of reloadNotes(plan.writes.map((w) => w.vendor))) process.stderr.write(`${note}\n`);
|
|
2466
|
+
if (blocked > 0) process.exitCode = 1;
|
|
2467
|
+
} catch (err) {
|
|
2468
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
2469
|
+
process.exit(1);
|
|
2470
|
+
}
|
|
2471
|
+
});
|
|
2472
|
+
return cmd;
|
|
2473
|
+
}
|
|
2474
|
+
function uninstallCommand() {
|
|
2475
|
+
const cmd = new Command("uninstall").description("Remove this plugin from the runtimes it was installed into");
|
|
2476
|
+
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) => {
|
|
2477
|
+
try {
|
|
2478
|
+
const plan = planUninstall(gather(resolveRoot(opts.root), opts, "auto"));
|
|
2479
|
+
if (!opts.list) for (const dest of plan.removals) applyRemoval(dest);
|
|
2480
|
+
const { removed, missing, blocked, unsupported } = plan.summary;
|
|
2481
|
+
output(plan, {
|
|
2482
|
+
vendors: plan.rows.map((r) => ({
|
|
2483
|
+
vendor: r.vendor,
|
|
2484
|
+
path: r.path,
|
|
2485
|
+
action: r.action
|
|
2486
|
+
})),
|
|
2487
|
+
summary: `removed ${removed}, missing ${missing}, blocked ${blocked}, unsupported ${unsupported}`
|
|
2488
|
+
});
|
|
2489
|
+
for (const row of plan.rows) if (row.reason) process.stderr.write(`warn: ${row.vendor}: ${row.reason}\n`);
|
|
2490
|
+
if (blocked > 0) process.exitCode = 1;
|
|
2491
|
+
} catch (err) {
|
|
2492
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
2493
|
+
process.exit(1);
|
|
2494
|
+
}
|
|
2495
|
+
});
|
|
2496
|
+
return cmd;
|
|
1269
2497
|
}
|
|
1270
2498
|
//#endregion
|
|
1271
2499
|
//#region src/marketplace/init.ts
|
|
@@ -1294,11 +2522,17 @@ function parseManifest(fs, file) {
|
|
|
1294
2522
|
throw new Error(`error: manifest "${file}" is not valid JSON`);
|
|
1295
2523
|
}
|
|
1296
2524
|
}
|
|
2525
|
+
/** The canonical manifest's `author` — a string or an `{ name, email?, url? }` object — read as the
|
|
2526
|
+
* catalog owner every runtime requires as an object. */
|
|
1297
2527
|
function manifestOwner(manifest) {
|
|
1298
|
-
if (typeof manifest.author === "string") return manifest.author;
|
|
2528
|
+
if (typeof manifest.author === "string") return { name: manifest.author };
|
|
1299
2529
|
if (typeof manifest.author === "object" && manifest.author !== null) {
|
|
1300
2530
|
const author = manifest.author;
|
|
1301
|
-
if (typeof author.name
|
|
2531
|
+
if (typeof author.name !== "string") return void 0;
|
|
2532
|
+
const owner = { name: author.name };
|
|
2533
|
+
if (typeof author.email === "string") owner.email = author.email;
|
|
2534
|
+
if (typeof author.url === "string") owner.url = author.url;
|
|
2535
|
+
return owner;
|
|
1302
2536
|
}
|
|
1303
2537
|
}
|
|
1304
2538
|
function deriveMetadata(root, fs, opts) {
|
|
@@ -1306,9 +2540,9 @@ function deriveMetadata(root, fs, opts) {
|
|
|
1306
2540
|
if (fs.exists(rootManifest)) assertContained(root, rootManifest, fs, "root plugin.json");
|
|
1307
2541
|
const manifest = fs.exists(rootManifest) ? parseManifest(fs, rootManifest) : {};
|
|
1308
2542
|
const name = opts.name ?? path.basename(root);
|
|
1309
|
-
const owner = opts.owner
|
|
2543
|
+
const owner = opts.owner !== void 0 ? { name: opts.owner } : manifestOwner(manifest);
|
|
1310
2544
|
assertMarketplaceName(name, "marketplace name");
|
|
1311
|
-
if (!owner || owner.trim() === "") throw new Error("error: marketplace owner is required; set --owner or root plugin.json author");
|
|
2545
|
+
if (!owner || owner.name.trim() === "") throw new Error("error: marketplace owner is required; set --owner or root plugin.json author");
|
|
1312
2546
|
return {
|
|
1313
2547
|
name,
|
|
1314
2548
|
owner
|
|
@@ -1357,9 +2591,6 @@ function discoverPlugins(root, fs, scanDirs) {
|
|
|
1357
2591
|
}
|
|
1358
2592
|
return plugins.sort((a, b) => a.name.localeCompare(b.name));
|
|
1359
2593
|
}
|
|
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
2594
|
function writeArtifacts(fs, artifacts, root) {
|
|
1364
2595
|
const changed = artifacts.filter((artifact) => !sameArtifact(fs, path.join(root, artifact.path), artifact.content));
|
|
1365
2596
|
for (const artifact of changed) fs.writeAtomically(path.join(root, artifact.path), artifact.content);
|
|
@@ -1368,7 +2599,8 @@ function selectedTargets(targets) {
|
|
|
1368
2599
|
return targets && targets.length > 0 ? [...new Set(targets)] : [
|
|
1369
2600
|
"claude",
|
|
1370
2601
|
"codex",
|
|
1371
|
-
"copilot"
|
|
2602
|
+
"copilot",
|
|
2603
|
+
"cursor"
|
|
1372
2604
|
];
|
|
1373
2605
|
}
|
|
1374
2606
|
function sameArtifact(fs, file, content) {
|
|
@@ -1392,13 +2624,15 @@ function initializeMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
|
|
|
1392
2624
|
const root = path.resolve(rootInput);
|
|
1393
2625
|
const metadata = deriveMetadata(root, fs, opts);
|
|
1394
2626
|
const plugins = discoverPlugins(root, fs, opts.scanDirs);
|
|
1395
|
-
const
|
|
1396
|
-
if (targets.includes("codex")) assertCodexPluginVersions(plugins);
|
|
1397
|
-
const planned = targets.map((target) => ({
|
|
2627
|
+
const planned = selectedTargets(opts.targets).map((target) => ({
|
|
1398
2628
|
target,
|
|
1399
2629
|
artifacts: serializeTarget(target, metadata, plugins)
|
|
1400
2630
|
}));
|
|
1401
|
-
for (const { artifacts } of planned) for (const artifact of artifacts)
|
|
2631
|
+
for (const { target, artifacts } of planned) for (const artifact of artifacts) {
|
|
2632
|
+
assertContained(root, path.join(root, artifact.path), fs, "selected artifact");
|
|
2633
|
+
const issues = validateCatalogContent(target, artifact.content);
|
|
2634
|
+
if (issues.length > 0) throw new Error(formatCatalogIssues(artifact.path, issues));
|
|
2635
|
+
}
|
|
1402
2636
|
const conflicts = [];
|
|
1403
2637
|
for (const entry of planned) for (const artifact of entry.artifacts) {
|
|
1404
2638
|
const output = path.join(root, artifact.path);
|
|
@@ -1422,16 +2656,69 @@ function initializeMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
|
|
|
1422
2656
|
};
|
|
1423
2657
|
});
|
|
1424
2658
|
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
2659
|
return results;
|
|
1433
2660
|
}
|
|
1434
2661
|
//#endregion
|
|
2662
|
+
//#region src/marketplace/validate.ts
|
|
2663
|
+
/** A `./`-prefixed source names a directory inside the repository, and Claude Code resolves it
|
|
2664
|
+
* against the directory holding `.claude-plugin/`. A source pointing nowhere passes every schema
|
|
2665
|
+
* check and still installs nothing, so the on-disk check belongs here rather than in the rules. */
|
|
2666
|
+
function checkSources(root, fs, catalog) {
|
|
2667
|
+
if (typeof catalog !== "object" || catalog === null) return [];
|
|
2668
|
+
const plugins = catalog.plugins;
|
|
2669
|
+
if (!Array.isArray(plugins)) return [];
|
|
2670
|
+
const issues = [];
|
|
2671
|
+
plugins.forEach((entry, index) => {
|
|
2672
|
+
if (typeof entry !== "object" || entry === null) return;
|
|
2673
|
+
const source = entry.source;
|
|
2674
|
+
const location = typeof source === "string" ? source : typeof source === "object" && source !== null ? source.path : void 0;
|
|
2675
|
+
if (typeof location !== "string" || !location.startsWith("./")) return;
|
|
2676
|
+
if (!fs.exists(path.join(root, location))) issues.push({
|
|
2677
|
+
path: `plugins[${index}].source`,
|
|
2678
|
+
message: `points at "${location}", which does not exist`
|
|
2679
|
+
});
|
|
2680
|
+
});
|
|
2681
|
+
return issues;
|
|
2682
|
+
}
|
|
2683
|
+
/** Checks the catalogs a repository carries against the shape each runtime loads. Reads only; it
|
|
2684
|
+
* repairs nothing, because a catalog someone hand-edited is theirs to correct. */
|
|
2685
|
+
function validateMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
|
|
2686
|
+
const root = path.resolve(rootInput);
|
|
2687
|
+
return (opts.targets && opts.targets.length > 0 ? [...new Set(opts.targets)] : [
|
|
2688
|
+
"claude",
|
|
2689
|
+
"codex",
|
|
2690
|
+
"copilot",
|
|
2691
|
+
"cursor"
|
|
2692
|
+
]).map((target) => {
|
|
2693
|
+
const relative = TARGET_CATALOG_PATHS[target];
|
|
2694
|
+
const file = path.join(root, relative);
|
|
2695
|
+
if (!fs.exists(file)) return {
|
|
2696
|
+
target,
|
|
2697
|
+
path: relative,
|
|
2698
|
+
status: opts.required ? "invalid" : "missing",
|
|
2699
|
+
issues: opts.required ? [{
|
|
2700
|
+
path: "",
|
|
2701
|
+
message: "no catalog at this path"
|
|
2702
|
+
}] : []
|
|
2703
|
+
};
|
|
2704
|
+
const content = fs.read(file);
|
|
2705
|
+
const issues = [...validateCatalogContent(target, content), ...parseAndCheckSources(root, fs, content)];
|
|
2706
|
+
return {
|
|
2707
|
+
target,
|
|
2708
|
+
path: relative,
|
|
2709
|
+
status: issues.length === 0 ? "valid" : "invalid",
|
|
2710
|
+
issues
|
|
2711
|
+
};
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
function parseAndCheckSources(root, fs, content) {
|
|
2715
|
+
try {
|
|
2716
|
+
return checkSources(root, fs, JSON.parse(content));
|
|
2717
|
+
} catch {
|
|
2718
|
+
return [];
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
//#endregion
|
|
1435
2722
|
//#region src/marketplace/cli.ts
|
|
1436
2723
|
function targetsFromOptions(opts) {
|
|
1437
2724
|
const targets = [
|
|
@@ -1443,7 +2730,7 @@ function targetsFromOptions(opts) {
|
|
|
1443
2730
|
return targets.length > 0 ? targets : void 0;
|
|
1444
2731
|
}
|
|
1445
2732
|
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
|
|
2733
|
+
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
2734
|
try {
|
|
1448
2735
|
if (opts.format !== void 0 && opts.format !== "toon" && opts.format !== "json") throw new Error("error: --format must be \"toon\" or \"json\"");
|
|
1449
2736
|
const results = initializeMarketplace(resolveRoot(opts.root), {
|
|
@@ -1470,39 +2757,36 @@ function initCommand() {
|
|
|
1470
2757
|
}
|
|
1471
2758
|
});
|
|
1472
2759
|
}
|
|
1473
|
-
function
|
|
1474
|
-
return new Command("
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
2760
|
+
function validateCommand() {
|
|
2761
|
+
return new Command("validate").description("Check the repository-local marketplace catalogs against the schema each runtime loads").option("--claude", "Validate the Claude marketplace catalog").option("--codex", "Validate the Codex marketplace catalog").option("--copilot", "Validate the Copilot marketplace catalog").option("--cursor", "Validate the Cursor marketplace catalog").option("--required", "Treat a selected target with no catalog as a failure").option("--format <format>", "Output format: toon or json (default: toon)").addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin marketplace validate --claude\n").action((opts) => {
|
|
2762
|
+
try {
|
|
2763
|
+
if (opts.format !== void 0 && opts.format !== "toon" && opts.format !== "json") throw new Error("error: --format must be \"toon\" or \"json\"");
|
|
2764
|
+
const results = validateMarketplace(resolveRoot(opts.root), {
|
|
2765
|
+
targets: targetsFromOptions(opts),
|
|
2766
|
+
required: opts.required
|
|
2767
|
+
});
|
|
2768
|
+
output(results, {
|
|
2769
|
+
targets: results.map((row) => ({
|
|
2770
|
+
target: row.target,
|
|
2771
|
+
status: row.status,
|
|
2772
|
+
path: row.path,
|
|
2773
|
+
issues: row.issues.length
|
|
2774
|
+
})),
|
|
2775
|
+
summary: `${results.filter((row) => row.status === "invalid").length} invalid of ${results.length}`
|
|
2776
|
+
});
|
|
2777
|
+
for (const row of results.filter((row) => row.status === "invalid")) {
|
|
2778
|
+
process.stderr.write(formatCatalogIssues(row.path, row.issues));
|
|
2779
|
+
process.stderr.write("\n");
|
|
2780
|
+
}
|
|
2781
|
+
if (results.some((row) => row.status === "invalid")) process.exitCode = 1;
|
|
2782
|
+
} catch (err) {
|
|
2783
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
2784
|
+
process.exitCode = 1;
|
|
2785
|
+
}
|
|
2786
|
+
});
|
|
1497
2787
|
}
|
|
1498
|
-
function
|
|
1499
|
-
|
|
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
|
-
}
|
|
2788
|
+
function marketplaceCommand() {
|
|
2789
|
+
return new Command("marketplace").description("Generate repository-local marketplace metadata").addCommand(initCommand()).addCommand(validateCommand());
|
|
1506
2790
|
}
|
|
1507
2791
|
//#endregion
|
|
1508
2792
|
//#region src/prepare/fs.ts
|
|
@@ -1517,15 +2801,15 @@ function projectStatePath(root) {
|
|
|
1517
2801
|
}
|
|
1518
2802
|
function readStateFile(filePath) {
|
|
1519
2803
|
try {
|
|
1520
|
-
return mergeSafeState(JSON.parse(
|
|
2804
|
+
return mergeSafeState(JSON.parse(fs.readFileSync(filePath, "utf8")));
|
|
1521
2805
|
} catch (err) {
|
|
1522
2806
|
if (err.code === "ENOENT") return null;
|
|
1523
2807
|
throw err;
|
|
1524
2808
|
}
|
|
1525
2809
|
}
|
|
1526
2810
|
function writeStateFile(filePath, state) {
|
|
1527
|
-
|
|
1528
|
-
|
|
2811
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
2812
|
+
fs.writeFileSync(filePath, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
1529
2813
|
}
|
|
1530
2814
|
function realPrepareFs(vendor, projectRoot) {
|
|
1531
2815
|
return {
|
|
@@ -1533,7 +2817,7 @@ function realPrepareFs(vendor, projectRoot) {
|
|
|
1533
2817
|
if (!vendor.globalManifest) return {};
|
|
1534
2818
|
const manifestPath = expandHome(vendor.globalManifest);
|
|
1535
2819
|
try {
|
|
1536
|
-
const raw = JSON.parse(
|
|
2820
|
+
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
1537
2821
|
return Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, typeof v === "object" && v !== null && "version" in v ? String(v.version) : String(v)]));
|
|
1538
2822
|
} catch (err) {
|
|
1539
2823
|
if (err.code === "ENOENT") return {};
|
|
@@ -1707,9 +2991,9 @@ function prepareCommand() {
|
|
|
1707
2991
|
//#endregion
|
|
1708
2992
|
//#region src/version/fs.ts
|
|
1709
2993
|
const realJsonIo = {
|
|
1710
|
-
exists: (p) =>
|
|
1711
|
-
read: (p) =>
|
|
1712
|
-
write: (p, content) =>
|
|
2994
|
+
exists: (p) => fs.existsSync(p),
|
|
2995
|
+
read: (p) => fs.readFileSync(p, "utf8"),
|
|
2996
|
+
write: (p, content) => fs.writeFileSync(p, content)
|
|
1713
2997
|
};
|
|
1714
2998
|
/** Reads `packagePath` from `.agents/universal-plugin.json`. Absent file, absent key, or a
|
|
1715
2999
|
* non-string value all mean "this plugin declares no npm package" — the manifest is then the only
|
|
@@ -1817,19 +3101,19 @@ function globalStatePath$1() {
|
|
|
1817
3101
|
function realSelfUpdateFs(hookFilePaths) {
|
|
1818
3102
|
return {
|
|
1819
3103
|
globHookFiles: () => hookFilePaths,
|
|
1820
|
-
readFile: (p) =>
|
|
1821
|
-
writeFile: (p, c) =>
|
|
3104
|
+
readFile: (p) => fs.readFileSync(p, "utf8"),
|
|
3105
|
+
writeFile: (p, c) => fs.writeFileSync(p, c, "utf8"),
|
|
1822
3106
|
readGlobalState: () => {
|
|
1823
3107
|
try {
|
|
1824
|
-
return mergeSafeState(JSON.parse(
|
|
3108
|
+
return mergeSafeState(JSON.parse(fs.readFileSync(globalStatePath$1(), "utf8")));
|
|
1825
3109
|
} catch (err) {
|
|
1826
3110
|
if (err.code === "ENOENT") return emptyState();
|
|
1827
3111
|
throw err;
|
|
1828
3112
|
}
|
|
1829
3113
|
},
|
|
1830
3114
|
writeGlobalState: (s) => {
|
|
1831
|
-
|
|
1832
|
-
|
|
3115
|
+
fs.mkdirSync(path.dirname(globalStatePath$1()), { recursive: true });
|
|
3116
|
+
fs.writeFileSync(globalStatePath$1(), JSON.stringify(s, null, 2) + "\n");
|
|
1833
3117
|
}
|
|
1834
3118
|
};
|
|
1835
3119
|
}
|
|
@@ -1870,7 +3154,7 @@ function selfUpdateCommand() {
|
|
|
1870
3154
|
toVersion,
|
|
1871
3155
|
fs: realSelfUpdateFs(Object.values(registry).map((v) => v.hookGlob).filter((g) => g !== null).map((g) => g.replace("~", os.homedir())).filter((p) => {
|
|
1872
3156
|
try {
|
|
1873
|
-
|
|
3157
|
+
fs.accessSync(p);
|
|
1874
3158
|
return true;
|
|
1875
3159
|
} catch {
|
|
1876
3160
|
return false;
|
|
@@ -1914,15 +3198,15 @@ function realSyncFs() {
|
|
|
1914
3198
|
return {
|
|
1915
3199
|
readGlobalState: () => {
|
|
1916
3200
|
try {
|
|
1917
|
-
return mergeSafeState(JSON.parse(
|
|
3201
|
+
return mergeSafeState(JSON.parse(fs.readFileSync(globalStatePath(), "utf8")));
|
|
1918
3202
|
} catch (err) {
|
|
1919
3203
|
if (err.code === "ENOENT") return emptyState();
|
|
1920
3204
|
throw err;
|
|
1921
3205
|
}
|
|
1922
3206
|
},
|
|
1923
3207
|
writeGlobalState: (s) => {
|
|
1924
|
-
|
|
1925
|
-
|
|
3208
|
+
fs.mkdirSync(path.dirname(globalStatePath()), { recursive: true });
|
|
3209
|
+
fs.writeFileSync(globalStatePath(), JSON.stringify(s, null, 2) + "\n");
|
|
1926
3210
|
},
|
|
1927
3211
|
shell: (cmd) => childProcess.spawnSync(cmd, {
|
|
1928
3212
|
shell: true,
|
|
@@ -1962,9 +3246,10 @@ function syncCommand() {
|
|
|
1962
3246
|
*
|
|
1963
3247
|
* A version lives in five places, but only two are authored: the canonical `plugin.json` and, when
|
|
1964
3248
|
* the project declares a `packagePath`, that `package.json`. The per-vendor manifests
|
|
1965
|
-
* (`plugin build`), the local marketplace
|
|
1966
|
-
* pins in `skills/**` (`plugin bundle`) are all
|
|
1967
|
-
* that owns them, never written a second time
|
|
3249
|
+
* (`plugin build`), the local marketplace catalog entries (`plugin build` refreshes the catalogs the
|
|
3250
|
+
* repository carries, ADR-0014), and the `npx`/`upx` pins in `skills/**` (`plugin bundle`) are all
|
|
3251
|
+
* **derived** — re-derived by calling the command that owns them, never written a second time
|
|
3252
|
+
* here. */
|
|
1968
3253
|
const RELEASE_TYPES = [
|
|
1969
3254
|
"major",
|
|
1970
3255
|
"minor",
|
|
@@ -1985,10 +3270,10 @@ function currentVersion(manifest) {
|
|
|
1985
3270
|
/** Resolves the bump argument against the current version. An explicit version is used as given;
|
|
1986
3271
|
* a release type is applied to the current one, which must therefore exist. */
|
|
1987
3272
|
function resolveTarget(current, opts) {
|
|
1988
|
-
if (semver.valid(opts.bump)) return semver.valid(opts.bump);
|
|
3273
|
+
if (semver$1.valid(opts.bump)) return semver$1.valid(opts.bump);
|
|
1989
3274
|
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
3275
|
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);
|
|
3276
|
+
const next = opts.preid === void 0 ? semver$1.inc(current, opts.bump) : semver$1.inc(current, opts.bump, opts.preid);
|
|
1992
3277
|
if (next === null) throw new Error(`Current version "${current}" in plugin.json is not valid semver`);
|
|
1993
3278
|
return next;
|
|
1994
3279
|
}
|
|
@@ -1998,7 +3283,7 @@ function planVersion(state, opts) {
|
|
|
1998
3283
|
if (!state.manifestExists || state.manifest === null) throw new Error("No plugin.json found at the project root");
|
|
1999
3284
|
const from = currentVersion(state.manifest);
|
|
2000
3285
|
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`);
|
|
3286
|
+
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
3287
|
if (state.packagePath !== null && state.packageJson === null) throw new Error(`No package.json found at packagePath "${state.packagePath}"`);
|
|
2003
3288
|
const rows = [{
|
|
2004
3289
|
path: "plugin.json",
|
|
@@ -2102,10 +3387,12 @@ function nextStep(dryRun, built) {
|
|
|
2102
3387
|
const program = new Command();
|
|
2103
3388
|
program.name("universal-plugin").description("Universal AI agent plugin build tool").version("0.0.0").helpCommand(false);
|
|
2104
3389
|
function pluginCommand() {
|
|
2105
|
-
const cmd = new Command("plugin").description("Author the canonical plugin manifest (build, bundle, init, version; validate planned)");
|
|
3390
|
+
const cmd = new Command("plugin").description("Author the canonical plugin manifest (build, bundle, init, install, uninstall, version; validate planned)");
|
|
2106
3391
|
cmd.addCommand(buildCommand());
|
|
2107
3392
|
cmd.addCommand(bundleCommand());
|
|
2108
3393
|
cmd.addCommand(initCommand$1());
|
|
3394
|
+
cmd.addCommand(installCommand());
|
|
3395
|
+
cmd.addCommand(uninstallCommand());
|
|
2109
3396
|
cmd.addCommand(versionCommand());
|
|
2110
3397
|
return cmd;
|
|
2111
3398
|
}
|