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