universal-plugin 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,9 @@
6
6
  "hookGlob": "~/.claude/plugins/universal-plugin/hooks/hooks.json",
7
7
  "globalPluginDir": "~/.claude/plugins/",
8
8
  "pluginRootSuffix": ".claude-plugin/plugin.json",
9
+ "localPluginDir": "~/.claude/skills/",
10
+ "localPluginLink": true,
11
+ "localReload": "restart Claude Code — it loads as <name>@skills-dir",
9
12
  "installCommand": "claude plugin install {name}",
10
13
  "removeCommand": "claude plugin remove {name}",
11
14
  "updateCommand": "claude plugin update {name}@{version}"
@@ -17,6 +20,9 @@
17
20
  "hookGlob": null,
18
21
  "globalPluginDir": null,
19
22
  "pluginRootSuffix": ".cursor-plugin/plugin.json",
23
+ "localPluginDir": "~/.cursor/plugins/local/",
24
+ "localPluginLink": false,
25
+ "localReload": "run Developer: Reload Window in Cursor",
20
26
  "installCommand": null,
21
27
  "removeCommand": null,
22
28
  "updateCommand": null
@@ -28,6 +34,9 @@
28
34
  "hookGlob": null,
29
35
  "globalPluginDir": null,
30
36
  "pluginRootSuffix": ".codex-plugin/plugin.json",
37
+ "localPluginDir": null,
38
+ "localPluginLink": false,
39
+ "localReload": null,
31
40
  "installCommand": null,
32
41
  "removeCommand": null,
33
42
  "updateCommand": null
@@ -39,6 +48,9 @@
39
48
  "hookGlob": null,
40
49
  "globalPluginDir": null,
41
50
  "pluginRootSuffix": "plugin.json",
51
+ "localPluginDir": null,
52
+ "localPluginLink": false,
53
+ "localReload": null,
42
54
  "installCommand": null,
43
55
  "removeCommand": null,
44
56
  "updateCommand": null
package/dist/run.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import * as fsNode from "node:fs";
3
3
  import * as path from "node:path";
4
+ import * as semver$1 from "semver";
4
5
  import { spawnSync } from "node:child_process";
5
- import * as semver from "semver";
6
6
  //#region src/run/fs.ts
7
7
  function readInstall(dir) {
8
8
  try {
@@ -121,13 +121,13 @@ function parseSpec(spec) {
121
121
  /** A valid semver range drives local-first matching; a non-empty non-semver spec (`next`,
122
122
  * `latest`) is a dist-tag that can't be matched against an installed `package.json` version. */
123
123
  function isSemverRange(range) {
124
- return semver.validRange(range) !== null;
124
+ return semver$1.validRange(range) !== null;
125
125
  }
126
126
  /** Nearest-local → global, first install whose version satisfies `range`. `locals` must already be
127
127
  * nearest-first. */
128
128
  function selectInstall(range, locals, globalInstall) {
129
- for (const install of locals) if (semver.satisfies(install.version, range)) return install;
130
- if (globalInstall && semver.satisfies(globalInstall.version, range)) return globalInstall;
129
+ for (const install of locals) if (semver$1.satisfies(install.version, range)) return install;
130
+ if (globalInstall && semver$1.satisfies(globalInstall.version, range)) return globalInstall;
131
131
  }
132
132
  /** Resolves the executable from a `package.json` `bin` field: a string bin, an object entry keyed
133
133
  * by the package's unscoped name (even among several bins), or a single-entry object. A
@@ -49,6 +49,9 @@ All component paths and build config live under `extensions["org.cyberuni.univer
49
49
  | `lspServers` | `.lsp.json` path | Extended | Claude Code only |
50
50
  | `outputStyles` | Output style resources directory | Extended | Claude Code only |
51
51
 
52
+ One field under this namespace is not a component path: `dependencies`. See
53
+ [Plugin Dependencies](#plugin-dependencies).
54
+
52
55
  A conformant host must support at least one core component (`skills` or `mcpServers`). Extended types are silently ignored on non-supporting hosts — do not rely on them for core plugin functionality.
53
56
 
54
57
  ### `extensions["org.cyberuni.universal-plugin"]` — `vendors` and `harnesses`
@@ -70,7 +73,7 @@ Vendor-specific extension fields:
70
73
  | `defaultEnabled` | ✓ (bool) | — | — | — |
71
74
  | `userConfig` | ✓ (prompted at enable) | — | — | — |
72
75
  | `channels` | ✓ | — | — | — |
73
- | `dependencies` | ✓ (inter-plugin) | — | — | — |
76
+ | `dependencies` | ✓ (inter-plugin — declared canonically, see [Plugin Dependencies](#plugin-dependencies)) | — | — | — |
74
77
  | `themes` | ✓ | — | — | — |
75
78
  | `monitors` | ✓ | — | — | — |
76
79
  | `logo` | — | ✓ | — | — |
@@ -145,6 +148,44 @@ Build reads root `plugin.json`, applies the rules below, writes each vendor's ou
145
148
  | `hooks` | adapt → PascalCase, `${CLAUDE_PLUGIN_ROOT}` | adapt → camelCase, pass-through env | ✓ → PascalCase, `${PLUGIN_ROOT}` native | adapt → camelCase, pass-through env |
146
149
  | `lspServers` | ✓ | **omit** | **omit** | **omit** |
147
150
  | `outputStyles` | ✓ | **omit** | **omit** | **omit** |
151
+ | `dependencies` | ✓ | **drop + warn** | **drop + warn** | **drop + warn** |
152
+
153
+ ## Plugin Dependencies
154
+
155
+ A plugin declares the plugins it needs under
156
+ `extensions["org.cyberuni.universal-plugin"].dependencies`, once, whatever it targets. Claude Code is
157
+ the only runtime that reads a dependency; build drops the declaration for the rest and warns
158
+ (ADR-0013). The build stays green.
159
+
160
+ ```json
161
+ {
162
+ "extensions": {
163
+ "org.cyberuni.universal-plugin": {
164
+ "dependencies": [
165
+ "cyber-asana",
166
+ { "name": "cyber-notion", "marketplace": "cyberuni", "version": "^0.9.0" }
167
+ ]
168
+ }
169
+ }
170
+ }
171
+ ```
172
+
173
+ An entry is a plugin name, optionally `@marketplace`-qualified, or an object carrying that name plus a
174
+ constraint:
175
+
176
+ | Key | Type | Notes |
177
+ | --- | --- | --- |
178
+ | `name` | string | Required. |
179
+ | `marketplace` | string | Which marketplace to resolve `name` in. A bare name resolves against the declaring plugin's own marketplace. |
180
+ | `version` | string | Semver range, checked against the installed plugin's version. |
181
+ | `sha` | string | Commit sha to pin a git-sourced dependency to. |
182
+
183
+ Write a range in the object form. `"cyber-asana@^0.9.0"` validates and the runtime then discards the
184
+ range, so build warns and names the object to write instead. `"cyber-asana@>=1.0.0"` is not a legal
185
+ name at all and fails the build, as does an npm-style object map.
186
+
187
+ Build validates shape only. Whether a declared plugin exists is a question for a resolver, and
188
+ resolving, fetching, and installing dependencies is out of scope (ADR-0013).
148
189
 
149
190
  ## Hook Event Name Mapping
150
191
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Universal AI agent plugin build tool",
5
5
  "keywords": [
6
6
  "agent-plugin",
@@ -44,6 +44,7 @@
44
44
  "devDependencies": {
45
45
  "@types/node": "^24.10.1",
46
46
  "@types/semver": "^7.7.1",
47
+ "ajv": "^8.20.0",
47
48
  "knip": "^6.14.1",
48
49
  "tsdown": "^0.22.0",
49
50
  "tsx": "^4.22.3",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "universal-plugin",
4
- "version": "0.4.0",
4
+ "version": "0.6.0",
5
5
  "description": "Research and design toolkit for building universal AI coding agent plugins that work across Claude Code, Cursor, Codex, and GitHub Copilot CLI.",
6
6
  "author": {
7
7
  "name": "unional"
package/readme.md CHANGED
@@ -37,6 +37,8 @@ Author the canonical manifest and derive everything from it.
37
37
  npx universal-plugin plugin init # scaffold plugin.json
38
38
  npx universal-plugin plugin init --npm # also wire an npm package to ship it
39
39
  npx universal-plugin plugin build # generate vendor manifests
40
+ npx universal-plugin plugin install # install the working copy into the runtimes it targets
41
+ npx universal-plugin plugin uninstall # take it back out
40
42
  npx universal-plugin plugin version <bump> # move the version across every file carrying one
41
43
  npx universal-plugin plugin bundle # pin skill npx references to workspace versions
42
44
  ```
@@ -45,6 +47,11 @@ npx universal-plugin plugin bundle # pin skill npx references to w
45
47
  `.codex-plugin/plugin.json`. Copilot CLI reads the canonical root `plugin.json` directly, so no
46
48
  fourth file is derived.
47
49
 
50
+ `install` puts the plugin you are editing into each runtime's local plugin directory, so you can use
51
+ it before publishing anything. It links where the runtime follows a symlink out of the tree, copies
52
+ where it does not, refuses a destination another plugin owns, and prints the reload each runtime now
53
+ needs. Installing a *published* plugin by name stays the runtime's own job.
54
+
48
55
  Each command writes JSON with `JSON.stringify`. Your repository decides how JSON looks, so run your
49
56
  formatter after any command that writes a manifest.
50
57
 
@@ -72,8 +79,12 @@ keep their previous version.
72
79
 
73
80
  ```sh
74
81
  npx universal-plugin marketplace init --codex --root .
82
+ npx universal-plugin marketplace validate --root .
75
83
  ```
76
84
 
85
+ `validate` checks each catalog against the schema its runtime loads and names the key at fault, so a
86
+ catalog that would be refused at install time is caught in the repository.
87
+
77
88
  Codex caches a local plugin install by its marketplace entry version. After you change packaged
78
89
  plugin files: update the canonical `plugin.json` version, regenerate the catalog (add `--force` to
79
90
  replace an existing one), reinstall the plugin, then start a new Codex session. The installed copy
@@ -7,8 +7,10 @@ disk still matches it for Claude Code, Cursor, Codex, and GitHub Copilot CLI.
7
7
 
8
8
  `scripts/doctor.mjs` composes the shipped CLI's `plugin build --dry-run --format json` with the
9
9
  filesystem facts that build cannot see — whether each derived manifest exists, whether it predates
10
- the canonical manifest, whether a stale or shadowing manifest is lying around, and whether the two
11
- authored version numbers still agree. It emits one JSON object: `vendors`, `findings`, `ok`.
10
+ the canonical manifest, whether a stale or shadowing manifest is lying around, whether the two
11
+ authored version numbers still agree, whether shipped content has moved since the version did, and
12
+ whether every marketplace catalog at the repository root is a shape its runtime loads. It emits one
13
+ JSON object: `vendors`, `findings`, `ok`.
12
14
 
13
15
  The skill supplies the judgment around it: which finding matters, and which skill owns its repair.
14
16
 
@@ -70,11 +70,23 @@ Each `code` below is what the script emits.
70
70
  | `undeliverable-override` | `harnesses["copilot-cli"]` sets fields that reach nothing | `/universal-plugin:init`, update route — move them to a vendor that has a derived manifest, or drop them |
71
71
  | `codex-fields-missing` | Codex is targeted without `version` or `description`; the build fails and writes **nothing at all**, including for the other vendors | add both to the canonical top level |
72
72
  | `version-drift` | the `packagePath` `package.json` and the canonical manifest carry different versions | `/universal-plugin:version` |
73
+ | `unreleased-content` | shipped content was committed after the commit that set the current version — a consumer keyed on that version never re-extracts it | `/universal-plugin:version` |
73
74
  | `stale-github-plugin` | a leftover `.github/plugin/plugin.json` from an older build — shadowed by root and no longer generated | `/universal-plugin:remove-plugin` |
74
75
  | `shadowing-manifest` | a `.plugin/plugin.json` exists — it outranks root in Copilot CLI's search order and silently shadows the canonical manifest | `/universal-plugin:remove-plugin` |
75
76
  | `no-vendors` | no vendor is declared, so the build writes nothing and no runtime reads the plugin | `/universal-plugin:init`, update route |
76
77
  | `package-path-missing` | `packagePath` names a directory with no readable `package.json` | fix `packagePath`, or create the package |
77
78
  | `unparsable-manifest` | root `plugin.json` is not valid JSON | fix the syntax error |
79
+ | `invalid-catalog` | a marketplace catalog at the repository root is not a shape its runtime loads — it is found, read, and refused at install time, in the user's terminal | `/universal-plugin:marketplace` |
80
+
81
+ ## Catalogs are checked at the repository root
82
+
83
+ The marketplace catalogs sit above the plugin in a monorepo, so the catalog check runs against the
84
+ repository root rather than `--root`. It reports only a catalog that would be **refused**: a missing
85
+ one is not a fault, and nothing here has an opinion on which catalogs a repository ought to carry.
86
+
87
+ The detail names the key at fault, so hand it to `/universal-plugin:marketplace` as it stands. An
88
+ entry's fields are derived from the plugin's `plugin.json`, and the catalog's own `name` and `owner`
89
+ are authored in the catalog — which half is at fault decides where the repair goes.
78
90
 
79
91
  ## Checking staleness properly
80
92
 
@@ -104,6 +116,25 @@ They diverge when someone ran `npm version`, or when changesets released a numbe
104
116
  back. Both are `/universal-plugin:version`'s to fix — never patch one file by hand to match the
105
117
  other.
106
118
 
119
+ ## Unreleased content
120
+
121
+ A runtime keys its plugin cache on the version, not on content: Claude Code resolves the version,
122
+ finds it unchanged, and reports *"already at the latest version"* without re-extracting. So content
123
+ pushed without a bump reaches nobody who already installed the plugin, and neither side is told
124
+ ([ADR-0010](../../.agents/spec/design/decisions/0010-version-policy.md) §6).
125
+
126
+ The script compares the shipped paths — the canonical manifest, the skills directory, `agents/`,
127
+ `governances/`, `mcp.json` — against the commit that set the version the manifest carries now, and
128
+ emits `unreleased-content` for anything committed since. Uncommitted work is not reported; it has not
129
+ shipped.
130
+
131
+ Two cases are deliberately silent. A plugin that declares `packagePath` is skipped, because there the
132
+ release picks the number (ADR-0010 §2) and content waiting ahead of the last released version is the
133
+ normal state of a branch. A tree with no git history is skipped rather than guessed at.
134
+
135
+ The repair is the bump, and it belongs to `/universal-plugin:version`. Judge first whether the change
136
+ is meant to ship — content that is still being worked on is not a finding to act on.
137
+
107
138
  ## Rules
108
139
 
109
140
  - **Never repair.** Report the finding and name the skill that owns it.
@@ -120,4 +151,5 @@ other.
120
151
  | Create, adopt, or change what the plugin declares | `init` |
121
152
  | Move the plugin's version | `version` |
122
153
  | Remove derived manifests, or the plugin itself | `remove-plugin` |
123
- | Publish it to a marketplace | `publish-plugin` |
154
+ | Generate the repository's own marketplace catalogs | `marketplace` |
155
+ | Publish it to the shared marketplace repository | `publish-plugin` |
@@ -51,6 +51,12 @@ if (manifest === null) {
51
51
  }
52
52
 
53
53
  const ext = manifest.extensions?.[UP_NAMESPACE] ?? null
54
+
55
+ // `packagePath` is the CLI's own config, and the CLI reads it from `.agents/universal-plugin.json`
56
+ // (src/version/fs.ts). It is read here from the same file, so a plugin the CLI treats as npm-shipping
57
+ // is one this script treats the same way. The manifest extension is accepted as a fallback for a
58
+ // repository that put it there.
59
+ const packagePath = readPackagePath()
54
60
  if (!manifest.$schema?.includes('agent-plugins.org') || ext === null) {
55
61
  add(
56
62
  'legacy-manifest',
@@ -148,28 +154,120 @@ if (build === null) {
148
154
  }
149
155
 
150
156
  // Version drift between the two authored numbers.
151
- if (ext?.packagePath) {
152
- const pkgPath = path.join(root, ext.packagePath, 'package.json')
157
+ if (packagePath !== null) {
158
+ const pkgPath = path.join(root, packagePath, 'package.json')
153
159
  const pkg = readJson(pkgPath)
154
160
  if (pkg === null) {
155
161
  add(
156
162
  'package-path-missing',
157
163
  'medium',
158
- `packagePath names ${ext.packagePath}, which holds no readable package.json`,
164
+ `packagePath names ${packagePath}, which holds no readable package.json`,
159
165
  'fix packagePath, or create the package',
160
166
  )
161
167
  } else if (manifest.version !== undefined && pkg.version !== manifest.version) {
162
168
  add(
163
169
  'version-drift',
164
170
  'high',
165
- `plugin.json is ${manifest.version}, ${ext.packagePath}/package.json is ${pkg.version}`,
171
+ `plugin.json is ${manifest.version}, ${packagePath}/package.json is ${pkg.version}`,
166
172
  '/universal-plugin:version',
167
173
  )
168
174
  }
169
175
  }
170
176
 
177
+ // Content shipped since the version last moved (ADR-0010 §6). A runtime keys its plugin cache on the
178
+ // version, so anything committed after the commit that set the current one is invisible to a consumer
179
+ // who already installed the plugin. Read-only: the comparison is git's, and it is skipped wherever
180
+ // git cannot answer.
181
+ //
182
+ // Not run where the release picks the number. ADR-0010 §2 makes `packagePath` the switch: a plugin
183
+ // that ships to npm gets its version from the release, so content sitting ahead of the last released
184
+ // one is the normal state there, not a defect. Only the author-picks model can forget the bump.
185
+ if (manifest.version !== undefined && packagePath === null) {
186
+ const introduced = commitThatSetVersion(manifest.version)
187
+ if (introduced !== null) {
188
+ const changed = git('diff', '--name-only', `${introduced}..HEAD`, '--', ...shippedPaths())
189
+ const files = (changed ?? '').split('\n').filter(Boolean)
190
+ if (files.length > 0) {
191
+ const sample = files.slice(0, 3).join(', ')
192
+ add(
193
+ 'unreleased-content',
194
+ 'medium',
195
+ `${files.length} shipped file(s) changed since ${manifest.version} was set (${sample}${files.length > 3 ? ', …' : ''}) — a consumer keyed on that version never re-extracts them`,
196
+ '/universal-plugin:version',
197
+ )
198
+ }
199
+ }
200
+ }
201
+
202
+ // The marketplace catalogs a user installs from. They sit at the *repository* root, above a plugin in
203
+ // a monorepo, and each is read by its runtime at install time — a catalog whose shape that runtime
204
+ // refuses fails in the user's terminal, not here. The shipped CLI owns the rules; this only asks.
205
+ for (const row of invalidCatalogs()) {
206
+ add(
207
+ 'invalid-catalog',
208
+ 'high',
209
+ `${row.path} is not a shape its runtime loads: ${row.issues.map((issue) => `${issue.path} ${issue.message}`).join('; ')}`,
210
+ '/universal-plugin:marketplace',
211
+ )
212
+ }
213
+
171
214
  report({ vendors })
172
215
 
216
+ /** Every catalog the repository carries that its runtime would refuse. Empty when there is nothing to
217
+ * read, when the CLI is too old to answer, or when every catalog is fine — a missing catalog is not a
218
+ * fault, and this reports no opinion on which ones a repository ought to carry. */
219
+ function invalidCatalogs() {
220
+ const catalogRoot = git('rev-parse', '--show-toplevel') ?? root
221
+ const result = fs.existsSync(bin)
222
+ ? spawnSync(process.execPath, [bin, 'marketplace', 'validate', '--format', 'json', '--root', catalogRoot], {
223
+ encoding: 'utf8',
224
+ })
225
+ : spawnSync('npx', ['universal-plugin', 'marketplace', 'validate', '--format', 'json', '--root', catalogRoot], {
226
+ encoding: 'utf8',
227
+ })
228
+ const rows = readJson_stdout(result.stdout)
229
+ return Array.isArray(rows) ? rows.filter((row) => row.status === 'invalid') : []
230
+ }
231
+
232
+ /** Runs git inside `root`, returning its stdout or `null` — a non-zero status, a missing git, and a
233
+ * directory outside any repository are all the same answer here: no history to read. */
234
+ function git(...args) {
235
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' })
236
+ return result.status === 0 ? result.stdout.trim() : null
237
+ }
238
+
239
+ /** The commit that introduced the version the manifest carries now, walking `plugin.json`'s history
240
+ * newest-first until the version changes. `null` when there is no history, or when the newest
241
+ * committed manifest already disagrees — that version is uncommitted, so nothing shipped under it. */
242
+ function commitThatSetVersion(current) {
243
+ const log = git('log', '--format=%H', '-100', '--', 'plugin.json')
244
+ if (log === null || log === '') return null
245
+
246
+ let introduced = null
247
+ for (const sha of log.split('\n').filter(Boolean)) {
248
+ const blob = git('show', `${sha}:./plugin.json`)
249
+ if (blob === null) break
250
+ let version
251
+ try {
252
+ version = JSON.parse(blob).version
253
+ } catch {
254
+ break
255
+ }
256
+ if (version !== current) break
257
+ introduced = sha
258
+ }
259
+ return introduced
260
+ }
261
+
262
+ /** What a consumer installs, as pathspecs. The derived vendor manifests are deliberately absent —
263
+ * they only ever change because the canonical manifest did, and counting both would report one
264
+ * change twice. */
265
+ function shippedPaths() {
266
+ const skills = typeof ext?.skills === 'string' ? ext.skills : './skills/'
267
+ const paths = ['plugin.json', skills, 'agents', 'governances', 'mcp.json']
268
+ return paths.filter((rel) => fs.existsSync(path.join(root, rel)))
269
+ }
270
+
173
271
  function readJson_stdout(stdout) {
174
272
  if (!stdout) return null
175
273
  try {
@@ -194,3 +292,10 @@ function report({ vendors }) {
194
292
  }
195
293
  process.exit(0)
196
294
  }
295
+
296
+ /** Where the npm package that ships this plugin lives, or `null` when the plugin ships to no
297
+ * package. `null` is the author-picks release model of ADR-0010 §2. */
298
+ function readPackagePath() {
299
+ const declared = readJson(path.join(root, '.agents', 'universal-plugin.json'))?.packagePath ?? ext?.packagePath
300
+ return typeof declared === 'string' && declared.length > 0 ? declared : null
301
+ }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: init
3
3
  description: Use this skill to create or change a universal agent plugin — scaffold a new one, adopt an existing vendor-specific plugin or already-shipped skills onto the open Agent Plugins Specification, or add and remove vendors and components on the canonical plugin.json that drives Claude Code, Cursor, Codex, and GitHub Copilot CLI. Trigger on "init a plugin here", "make my Claude Code plugin work in Cursor", "convert this to the open plugin standard", "turn these skills into a plugin", "add Codex support", or "add a hooks component".
4
- argument-hint: '[--name <name>] [--vendor <id>] [--scaffold] [--npm] [--force]'
4
+ argument-hint: '[--name <name>] [--vendor <id>] [--scaffold] [--npm] [--no-marketplace] [--force]'
5
5
  ---
6
6
 
7
7
  # Plugin Init
@@ -133,6 +133,23 @@ Then derive the vendor manifests:
133
133
  npx universal-plugin plugin build
134
134
  ```
135
135
 
136
+ To try the result before publishing, `npx universal-plugin plugin install` puts the working copy
137
+ into every runtime the manifest declares and names the reload each one needs. Never hand-write a
138
+ symlink for this — `references/create.md` records why the recipe that circulated for it does not
139
+ work.
140
+
141
+ With `--vendor`, `plugin init` also registers the plugin in the repository's local marketplace: it
142
+ writes each selected vendor's catalog at the **repository** root and folds an entry for this plugin
143
+ into it, so users can add the repository as a marketplace and install from it. Say what it wrote,
144
+ and read back any line it printed on stderr — a repository with no author, no package author, and no
145
+ remote gets no catalog, because every runtime requires an owner. `--no-marketplace` skips the step.
146
+
147
+ The catalog is named after the repository, `<owner>-<repo>-local`, not after the plugin: it lists
148
+ every plugin the repository develops. Re-running `init` folds the entry back in and leaves the
149
+ marketplace name, the owner, and every other entry alone, so it is safe over a catalog someone
150
+ edited. Generating catalogs for a repository that already holds several plugins, and writing the
151
+ README install section, is the `marketplace` skill's job.
152
+
136
153
  `plugin init` writes a **minimal** manifest — `$schema`, `name`, and the vendor list. It never reads
137
154
  an existing vendor manifest, so shared metadata and per-vendor overrides are carried in by hand
138
155
  afterwards, per the reference you routed to.
@@ -172,9 +189,10 @@ This skill is not a formatter. If the project has one, run it over the written f
172
189
  the Phase 5 diff is the check that proves it.
173
190
  - **Do not package repo-private agent configuration.** A `.claude/skills/` directory is the project's
174
191
  own tooling; offering to publish it is wrong.
175
- - **Do not convert vendor settings without a documented mapping.** Hook event names diverge by case
176
- across runtimes and the build does not translate them today — see
177
- `references/vendors/claude-code.md`. Unmapped settings stay where they are, reported.
192
+ - **Do not convert vendor settings without a documented mapping.** Hooks have one: author them in
193
+ canonical PascalCase and `plugin build` derives each vendor's form, dropping handler types a vendor
194
+ cannot run — see `references/vendors/claude-code.md`. Unmapped settings stay where they are,
195
+ reported.
178
196
  - **Offer adoption once.** If the user declines, or asked for something unrelated, drop it and do
179
197
  what they asked.
180
198
  - Plugin authoring only. Do not change CI workflows, repository settings, or unrelated project files.
@@ -184,6 +202,7 @@ This skill is not a formatter. If the project has one, run it over the written f
184
202
  | Task | Skill |
185
203
  |------|-------|
186
204
  | Diagnose a plugin — what is declared, built, stale, or drifting | `doctor` |
205
+ | Make the repository installable, and document how | `marketplace` |
187
206
  | Move the plugin's version, or reconcile one that drifted | `version` |
188
207
  | Delete derived manifests, or the whole plugin | `remove-plugin` |
189
208
  | Move a repo-root plugin into its npm package | `migrate-plugin` |
@@ -101,10 +101,21 @@ write all surface there rather than as errors.
101
101
  ## Step 8 — Install locally to test
102
102
 
103
103
  ```bash
104
- ln -sf "$(pwd)" ~/.claude/plugins/local/<plugin-name> # Claude Code
105
- ln -sf "$(pwd)" ~/.cursor/plugins/local/<plugin-name> # Cursor → Developer: Reload Window
104
+ npx universal-plugin plugin install
106
105
  ```
107
106
 
107
+ It installs into every runtime the manifest declares, linking where the runtime follows a symlink
108
+ out of the tree and copying where it does not, and it prints the reload each one now needs — a
109
+ restart for Claude Code, **Developer: Reload Window** for Cursor. `--list` shows where it would go
110
+ without writing; `--vendor <id>` narrows it; `plugin uninstall` removes it again.
111
+
112
+ Codex and Copilot CLI scan no local plugin directory, so they report as `unsupported`. Reach those
113
+ through a repository-local marketplace — `publish-plugin`.
114
+
115
+ Do not hand-write a symlink for this. The recipe that circulated for it named
116
+ `~/.claude/plugins/local/`, which does not exist, and a symlink into Cursor's local directory is
117
+ rejected by Cursor's own scan.
118
+
108
119
  ## Next
109
120
 
110
121
  Shipping it on npm → `migrate-plugin`. Listing it in a marketplace → `publish-plugin`. Releasing a
@@ -38,6 +38,7 @@ lives under one namespaced key, `extensions["org.cyberuni.universal-plugin"]`:
38
38
  | `harnesses` | per-vendor overrides, keyed by vendor id. `{}` opts in with no overrides |
39
39
  | `packagePath` | the npm package whose `package.json` carries the same version |
40
40
  | component paths (`skills`, `commands`, `agents`, `hooks`, …) | where each component lives |
41
+ | `dependencies` | the plugins this plugin needs. Only Claude Code reads them; see [`vendors/claude-code.md`](./vendors/claude-code.md) |
41
42
 
42
43
  `vendors` and `harnesses` are separate on purpose: `vendors` says what to build, `harnesses` says
43
44
  what each build gets. A vendor listed in `vendors` with no `harnesses` entry still builds; a
@@ -68,7 +69,7 @@ directories. Create the rest only when the plugin has content for them.
68
69
  | MCP servers | `mcpServers` | `.mcp.json` | every vendor |
69
70
  | Commands | `commands` | `commands/<name>.md` | Claude Code, Cursor, Copilot CLI |
70
71
  | Agents | `agents` | `agents/<name>.md` | Claude Code, Cursor, Copilot CLI |
71
- | Hooks | `hooks` | `hooks/hooks.json` | partial — event names differ by case, and the build does not translate them |
72
+ | Hooks | `hooks` | `hooks/hooks.json` | every vendor — authored PascalCase, translated per vendor by the build; handler types vary |
72
73
  | LSP servers | `lspServers` | `.lsp.json` | Claude Code, Cursor |
73
74
  | Rules | `rules` | `rules/<name>.mdc` | Cursor only |
74
75
  | Output styles | `outputStyles` | `output-styles/` | Claude Code only |
@@ -27,16 +27,48 @@ true` for `user`, `user-invocable: false` for `model`. See [`../frontmatter.md`]
27
27
 
28
28
  ## Hooks
29
29
 
30
- Claude Code hook events are **PascalCase** (`SessionStart`, `PreToolUse`, `PostToolUse`, `Stop`,
31
- `UserPromptSubmit`). Cursor and Copilot CLI use camelCase; Codex is PascalCase like Claude Code.
30
+ Author hooks once, in canonical form: **PascalCase** event names (`SessionStart`, `PreToolUse`,
31
+ `PostToolUse`, `Stop`, `UserPromptSubmit`) over Claude Code's matcher-group shape. That is what the
32
+ canonical schema admits, and `plugin build` derives the rest (ADR-0011).
32
33
 
33
- **The build does not translate event names.** It copies the `hooks` path through to every derived
34
- manifest as declared, so one `hooks/hooks.json` cannot currently satisfy both casings. Author hooks
35
- for the runtimes that share a casing, and say plainly which runtimes a hooks block does not reach
36
- rather than implying portability the build does not deliver.
34
+ Claude Code and Codex read that form as authored. Copilot CLI accepts it too — PascalCase selects its
35
+ Claude-compatible payload format. Cursor is the one vendor translated: it gets
36
+ `.cursor-plugin/hooks.json` with camelCase events, `"version": 1`, and each matcher group flattened
37
+ into one entry per handler, and its derived manifest points there.
37
38
 
38
- Source: `.research/hook-event-survey/conclusion.md` (June 2026) — re-verify against vendor docs
39
- before relying on it.
39
+ **A handler type the vendor cannot run is dropped, and the build warns.** Claude Code runs `command`,
40
+ `http`, `prompt`, and `agent`; Codex runs `command` only; Cursor runs `command` and `prompt`; Copilot
41
+ CLI runs `command`, `http`, and `prompt`. Read the warnings — a plugin whose only `SessionStart`
42
+ handler is `http` reaches Claude Code and Copilot CLI and nothing else. Copilot CLI reads the
43
+ canonical file directly, so its unsupported handlers are reported as ignored at runtime rather than
44
+ dropped from a derived file.
45
+
46
+ Source: `.research/hook-event-survey/conclusion.md` (re-verified August 2026) — re-verify against
47
+ vendor docs before relying on it.
48
+
49
+ ## Dependencies
50
+
51
+ Claude Code is the only runtime that reads a plugin dependency, and it acts on one: it installs a
52
+ missing dependency, enables it alongside the plugin that needs it, prunes it once nothing needs it,
53
+ and refuses to load a plugin whose declared range the installed version does not satisfy. Declare it
54
+ once, canonically, under `extensions["org.cyberuni.universal-plugin"].dependencies` — not under
55
+ `harnesses["claude-code"]` (ADR-0013):
56
+
57
+ ```json
58
+ "dependencies": ["cyber-asana", { "name": "cyber-notion", "marketplace": "cyberuni", "version": "^0.9.0" }]
59
+ ```
60
+
61
+ A bare name resolves against the declaring plugin's own marketplace; `marketplace` picks another one,
62
+ which the root marketplace must have allowed. Put a range in the object form — a range written as
63
+ `"cyber-asana@^0.9.0"` is accepted and then discarded by the runtime, and the build warns and names
64
+ the object to write instead.
65
+
66
+ Cursor, Codex, and Copilot CLI read no such field. The build leaves it out of their manifests and
67
+ warns; the build stays green. A plugin that loads there without its dependency is worth a line in
68
+ your README.
69
+
70
+ Source: `.research/plugin-schema/` (re-verified August 2026 against Claude Code 2.1.235) — re-verify
71
+ against vendor docs before relying on it.
40
72
 
41
73
  ## Leave alone
42
74
 
@@ -44,5 +44,13 @@ so it is not part of the plugin's tracked output and does not travel with a clon
44
44
 
45
45
  ## Hooks
46
46
 
47
- Codex hook events are **PascalCase**, like Claude Code's. The build does not translate event names —
48
- see [`claude-code.md`](./claude-code.md).
47
+ Codex hook events are **PascalCase**, like Claude Code's, so the canonical file reaches Codex as
48
+ authored. Codex runs `command` handlers only — an `http`, `prompt`, or `agent` handler is dropped
49
+ from `.codex-plugin/hooks.json` with a warning. See [`claude-code.md`](./claude-code.md).
50
+
51
+ ## Dependencies
52
+
53
+ Codex reads no plugin dependency. A declaration is left out of `.codex-plugin/plugin.json` with a
54
+ build warning — deliberately, because the validator Codex ships for its plugin ingestion contract
55
+ rejects any field outside its allowlist, and one unaccepted key fails the whole manifest. See
56
+ [`claude-code.md`](./claude-code.md).
@@ -41,5 +41,13 @@ derived manifest, or it does not ship. Do not invent a path for it.
41
41
 
42
42
  ## Hooks
43
43
 
44
- Copilot CLI hook events are **camelCase**. The build does not translate event names — see
45
- [`claude-code.md`](./claude-code.md).
44
+ Copilot CLI accepts **either casing**, and the casing selects the payload format: PascalCase gets the
45
+ Claude-compatible format, so the canonical file reaches Copilot CLI unchanged. Because Copilot CLI
46
+ reads that file directly, the build derives nothing for it — an `agent` handler is reported as ignored
47
+ at runtime rather than dropped. See [`claude-code.md`](./claude-code.md).
48
+
49
+ ## Dependencies
50
+
51
+ Copilot CLI reads no plugin dependency. Because it reads the canonical manifest directly, there is no
52
+ derived file to leave the declaration out of — it sits under `extensions`, which Copilot CLI ignores,
53
+ and the build reports it as ignored at runtime. See [`claude-code.md`](./claude-code.md).
@@ -41,5 +41,12 @@ never generate rules from a skill or a skill from a rule.
41
41
 
42
42
  ## Hooks
43
43
 
44
- Cursor hook events are **camelCase** (`sessionStart`). The build does not translate event names — see
45
- [`claude-code.md`](./claude-code.md).
44
+ Cursor hook events are **camelCase** (`sessionStart`), and Cursor's hooks file differs in shape as
45
+ well as casing. The build derives `.cursor-plugin/hooks.json` from the canonical file — never author
46
+ it by hand. Cursor runs `command` and `prompt` handlers; an `http` or `agent` handler is dropped with
47
+ a warning. See [`claude-code.md`](./claude-code.md).
48
+
49
+ ## Dependencies
50
+
51
+ Cursor reads no plugin dependency. A declaration is left out of `.cursor-plugin/plugin.json` with a
52
+ build warning. See [`claude-code.md`](./claude-code.md).