build
Read root plugin.json, validate it, and write a spec-conformant vendor manifest for each vendor declared in extensions["org.cyberuni.universal-plugin"].vendors.
universal-plugin plugin build [options]Options
Section titled “Options”| Flag | Description |
|---|---|
--vendor <id> | Build only the named vendor |
--dry-run | Print what would be written without writing |
--verbose | Print field-by-field transformation decisions |
--clean | Delete generated manifests before building |
--root <path> | Plugin root directory (default: current directory) |
--format json | Output as JSON |
Vendor identifiers
Section titled “Vendor identifiers”--vendor | Output path |
|---|---|
claude-code | .claude-plugin/plugin.json |
cursor | .cursor-plugin/plugin.json |
codex | .codex-plugin/plugin.json |
copilot-cli | plugin.json (repo root), plus the com.github.copilot/ component tree |
Copilot CLI reads its components from com.github.copilot/
Section titled “Copilot CLI reads its components from com.github.copilot/”Copilot CLI reads the canonical root plugin.json directly, so the build derives no manifest for it.
Its components are a different answer. Declaring the canonical $schema — which every plugin this
CLI builds does — puts the plugin into Copilot CLI’s spec mode, and spec mode reads these only
under com.github.copilot/, no longer from the plugin root:
| Component | Where Copilot CLI reads it |
|---|---|
| agents | com.github.copilot/agents/ |
| commands | com.github.copilot/commands/ |
| rules | com.github.copilot/rules/ |
| hooks | com.github.copilot/hooks/hooks.json |
| LSP servers | com.github.copilot/lsp.json |
| extensions (canvases) | com.github.copilot/extensions/ |
| skills | skills/ — does not move |
| MCP servers | mcp.json — does not move |
The namespace replaces the plugin root for the kinds that move; an explicit component path in the
manifest does not bring the root path back. So you keep authoring at the canonical locations, and
plugin build derives the tree: agents/ is copied to com.github.copilot/agents/ — renamed to
Copilot CLI’s .agent.md convention, since the canonical agents/ is the Claude Code-shaped *.md
and a file under the other name is ignored — the hooks file is translated into
com.github.copilot/hooks/hooks.json, and a declared lspServers path is copied to
com.github.copilot/lsp.json. Commands and rules keep their authored names. An inline lspServers map is not delivered — the file’s top-level
shape is undocumented, so the build warns rather than guessing.
com.github.copilot/extensions/ runs the other way: canvas extensions have no canonical root
location, so you author them there and the build passes them through untouched, including under
--clean.
The vendor reports built at com.github.copilot/ when it derives any of this, and keeps reporting
canonical when the plugin declares none of the moved kinds. If you ship agents at the root and no
namespace copy exists, Copilot CLI loads none of them and says nothing — /universal-plugin:doctor-universal-plugin
reports that as copilot-root-components.
Build steps
Section titled “Build steps”For each vendor in extensions["org.cyberuni.universal-plugin"].vendors:
- Start with all canonical fields from root
plugin.json - Merge
extensions["org.cyberuni.universal-plugin"].harnesses.<vendor>fields (vendor fields win on conflict) - Drop component fields and dependencies unsupported by the vendor (emits a warning)
- Translate hook event names to vendor casing
- Pin any
mcpServersinvocation markedpinToPluginVersionto the manifest’s version - Translate
${PLUGIN_ROOT}/${PLUGIN_DATA}env vars - Enforce required fields (fails build on missing)
- Write to the vendor output path — and for
copilot-cli, derive thecom.github.copilot/component tree instead of a manifest
Then, once per build: each repository-local marketplace catalog the repository already carries
has this plugin’s entry re-derived, for the vendors just built, so a catalog entry’s version follows
the canonical manifest instead of drifting. No catalog is created — that stays with
plugin init --vendor and marketplace init — and nothing else in the file
changes. --dry-run reports the refresh as planned and writes nothing.
Governance copies are gone
Section titled “Governance copies are gone”Earlier releases copied each governance a skill declared into its references/governances/ folder,
and plugin build --check failed CI when a copy drifted. Both are gone. A skill now loads a
reference by name with the reference skill in the buddy-agent-harness plugin, which runs
buddy-agent-harness reference show and falls back to the skill’s own references/<name>.md.
A references/governances/ folder a skill already commits is left as it is. Remove --check from
CI: passing it now fails as an unknown option.
Pinning an MCP server to the plugin version
Section titled “Pinning an MCP server to the plugin version”A plugin whose MCP server is its own npm package launches it unpinned:
{ "command": "npx", "args": ["-y", "my-server", "mcp"] }A consumer on plugin 1.4.0 then gets 1.4.0’s skills alongside whatever npx resolves as latest for
the server, and the two drift further with every release the consumer does not reinstall. The version
is known at build time and nowhere else — mcp.json expands only ${PLUGIN_ROOT} and
${PLUGIN_DATA}, so a ${PLUGIN_VERSION} placeholder would reach the client literally.
Mark the entry and the build stamps the version on:
{ "mcpServers": { "my-server": { "command": "npx", "args": ["-y", "my-server", "mcp"], "pinToPluginVersion": true } }}The derived manifest carries ["-y", "my-server@1.4.0", "mcp"], and pinToPluginVersion is gone —
it is a build directive, not part of any vendor’s schema.
The marker is required. The build never matches on name: a plugin may publish its server under a
package name that is not the plugin’s, and an unrelated npx -y widget-cli sitting in the same
block must never be stamped with this plugin’s version.
- An already-pinned specifier is overwritten with the manifest version, and the build warns naming the version it replaced. Do not hand-pin a marked entry.
- A marked entry the build cannot pin — the
commandis notnpx/upx, the manifest declares noversion, orargscarry no package specifier — is warned about and left alone. The build stays green. - A path declaration (
"mcpServers": "./mcp.json") with a marked entry gets a derived<vendor-dir>/mcp.jsonand the vendor’smcpServersrepointed at it. Your authoredmcp.jsonis never rewritten. With nothing marked, nothing is derived and the declaration passes through. copilot-clireads the canonical manifest directly, so it has no derived manifest to receive the pin — andmcp.jsonis one of the two paths its spec mode leaves at the plugin root, so there is no derived file either. The build warns rather than pretending otherwise.
Validation
Section titled “Validation”The build fails (exit 1) if:
nameis missingversionordescriptionis missing when targetingcodex- root
plugin.jsondoes not exist at the plugin root --vendornames a vendor not inextensions["org.cyberuni.universal-plugin"].vendorsdependenciesis not an array, names a plugin the runtime cannot parse, or carries aversionthat is not a semver range
Unrecognized vendor keys in extensions["org.cyberuni.universal-plugin"].harnesses emit a warning and are skipped.
Plugin dependencies
Section titled “Plugin dependencies”Declare the plugins your plugin needs once, under
extensions["org.cyberuni.universal-plugin"].dependencies:
{ "extensions": { "org.cyberuni.universal-plugin": { "dependencies": [ "cyber-asana", { "name": "cyber-notion", "marketplace": "cyberuni", "version": "^0.9.0" } ] } }}An entry is a plugin name, optionally @marketplace-qualified, or an object carrying that name with a
constraint beside it:
| Key | Notes |
|---|---|
name | Required. |
marketplace | Which marketplace to resolve name in. A bare name resolves against the declaring plugin’s own marketplace. |
version | Semver range, checked against the installed plugin’s version. |
sha | Commit sha to pin a git-sourced dependency to. |
Claude Code is the only runtime that reads a dependency, and it acts on one: it installs a missing dependency, enables it alongside the plugin that needs it, and refuses to load a plugin whose declared range the installed version does not satisfy. Cursor, Codex, and Copilot CLI read no such field, so the build leaves it out of their manifests and warns once per vendor, naming what did not reach it. The build still succeeds — targeting a runtime that ignores dependencies is not an error, but a plugin that loads there without its dependency is worth saying in your README.
Write a range in the object form. "cyber-asana@^0.9.0" is accepted by the runtime, which then
discards the range, so the build warns and names the object to write instead. "cyber-asana@>=1.0.0"
is not a legal name and fails the build, as does an npm-style {"cyber-asana": "^0.9.0"} map.
The build checks the shape of a declaration, not whether the plugin it names exists. Resolving, fetching, and installing a dependency is the runtime’s job.
Examples
Section titled “Examples”# Build all declared vendorsuniversal-plugin plugin build
# Build only Cursoruniversal-plugin plugin build --vendor cursor
# Preview without writinguniversal-plugin plugin build --dry-run --verbose
# Clean rebuilduniversal-plugin plugin build --clean