Plugin Doctor
Root plugin.json is the canonical manifest. Every other manifest a runtime reads is derived from
it, and a derived manifest that is missing, stale, or hand-edited fails silently: the runtime loads
what it finds, or loads nothing, and says nothing either way.
This skill is read-only. It never repairs. Every finding names the skill that owns its repair — hand it over rather than fixing it here, because a repair can rewrite a manifest the user maintains and that judgment belongs to the skill that owns the write.
Diagnose
node scripts/doctor.mjs
Resolve that path against this skill's own directory. It runs the CLI that shipped beside it against
the current working directory, so nothing is downloaded; add --root <path> to diagnose elsewhere.
Add --marketplace-root <path> (repeatable) to also check a separately-cloned shared marketplace
repository (e.g. a local clone of cyberuni/marketplace) — see
Catalogs are checked at the repository root.
It never prompts and never writes, so it is safe to run unattended.
Stdout is one JSON object — that is the contract to read, not the CLI's own terminal output:
{
"root": "…",
"manifest": { "name": "my-plugin", "version": "1.0.0" },
"vendors": [{ "vendor": "claude-code", "path": ".claude-plugin/plugin.json", "status": "built", "exists": true, "stale": false }],
"findings": [{ "code": "unbuilt", "severity": "high", "detail": "…", "repair": "…" }],
"ok": false
}
findings is empty and ok is true when everything resolves — say so outright rather than
reporting an empty list. Exit status is 0 whether or not findings exist; a finding is a result, not
a failure. Add --verbose for a human-readable summary on stderr.
Read vendors[].status literally:
| Status | Means |
|---|---|
built |
the build writes this vendor's output. For copilot-cli that output is the com.github.copilot/ component tree, not a manifest |
canonical |
the vendor reads root plugin.json; no file is written, and that is correct |
skipped |
an unknown vendor id — a typo in vendors |
failed |
the write itself failed; the finding names why |
copilot-cli reporting canonical is a healthy plugin, not a missing build — root plugin.json
serves it, and a plugin declaring no agents, commands, rules, hooks, or LSP servers has nothing else
to derive. Never report it as a fault.
A plugin that does declare those reports copilot-cli as built at com.github.copilot/
instead. Declaring the canonical $schema moves them there: Copilot CLI stops reading them from the
plugin root entirely, so a root-only layout loads none of them and says nothing
(ADR-0015). That is what
copilot-root-components reports.
If node is unavailable, read scripts/doctor.mjs and apply the same checks by hand: it composes
universal-plugin plugin build --dry-run --format json with filesystem facts that build cannot see.
Findings and their repairs
Each code below is what the script emits.
| Finding | What it means | Repair |
|---|---|---|
no-manifest |
no root plugin.json — this is not a plugin yet |
/universal-plugin:init-universal-plugin |
legacy-manifest |
root plugin.json with neither $schema nor extensions — a single-vendor manifest on the canonical path |
/universal-plugin:init-universal-plugin, adopt route |
vendor-only |
a vendor manifest with no canonical manifest above it | /universal-plugin:init-universal-plugin, adopt route |
unbuilt |
a declared vendor whose output path holds no file — that runtime sees no plugin | universal-plugin plugin build |
stale |
a derived manifest older than plugin.json |
universal-plugin plugin build |
hand-edited |
a derived manifest that build would rewrite — the edit is already lost, it just has not been overwritten yet |
move the field to the canonical manifest or to harnesses.<vendor>, then rebuild |
unknown-vendor |
a vendors entry no build target matches; reported as skipped plus a warning |
fix the id in plugin.json |
undeliverable-override |
harnesses["copilot-cli"] sets fields that reach nothing |
/universal-plugin:init-universal-plugin, update route — move them to a vendor that has a derived manifest, or drop them |
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 |
version-drift |
the packagePath package.json and the canonical manifest carry different versions |
/universal-plugin:version |
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 |
copilot-root-components |
agents, commands, rules, hooks, or LSP servers sit at the plugin root with no copy under com.github.copilot/ — Copilot CLI reads them only from there in spec mode, so it loads none of them, silently |
universal-plugin plugin build |
stale-github-plugin |
a leftover .github/plugin/plugin.json from an older build — shadowed by root and no longer generated |
/universal-plugin:remove-plugin |
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 |
no-vendors |
no vendor is declared, so the build writes nothing and no runtime reads the plugin. On a repository still on the pre-0.6 layout the build stops rather than reporting an empty result, and the detail says so — read it beside legacy-manifest and shadowing-manifest, which name the signals |
/universal-plugin:init-universal-plugin, adopt route on the pre-0.6 layout, else update route |
package-path-missing |
packagePath names a directory with no readable package.json |
fix packagePath, or create the package |
package-path-unknown |
the CLI could not report packagePath (a version too old to read it), so version-drift and unreleased-content were skipped rather than guessed |
upgrade universal-plugin |
misplaced-package-path |
plugin.json declares packagePath under extensions["org.cyberuni.universal-plugin"], where the CLI never reads it — the plugin is silently treated as not shipping to npm |
move it to .agents/universal-plugin.json, relative to the plugin root |
unparsable-manifest |
root plugin.json is not valid JSON |
fix the syntax error |
invalid-catalog |
a marketplace catalog — at the repository root, or at a --marketplace-root clone — is not a shape its runtime loads — it is found, read, and refused at install time, in the user's terminal |
/universal-plugin:marketplace |
marketplace-root-missing |
a --marketplace-root path does not exist, so it could not be checked at all |
clone the marketplace repository, or fix the path |
Catalogs are checked at the repository root
The marketplace catalogs sit above the plugin in a monorepo, so the catalog check runs against the
repository root rather than --root. It reports only a catalog that would be refused: a missing
one is not a fault, and nothing here has an opinion on which catalogs a repository ought to carry.
The detail names the key at fault, so hand it to /universal-plugin:marketplace as it stands. An
entry's fields are derived from the plugin's plugin.json, and the catalog's own name and owner
are authored in the catalog — which half is at fault decides where the repair goes.
A shared marketplace repository (e.g. cyberuni/marketplace) is a repository of its own — a bad
entry that reached it by another path (a hand-edited entry, a PR from a different tool, a curator
edit) is invisible to a doctor run inside any plugin's own repo, because that run never sees the
shared repository at all. Clone it separately and name the clone explicitly:
node scripts/doctor.mjs --marketplace-root ../marketplace
Pass --marketplace-root once per clone to check more than one. Each invalid entry it finds is still
reported as invalid-catalog, with the clone's path in the detail so it reads apart from the plugin
repo's own catalogs; a path that does not exist is marketplace-root-missing rather than a silent
skip.
Checking staleness properly
The stale finding is an mtime comparison, which catches the common case and nothing more. It cannot
see a hand-edit made after the last build. The definitive check is to rebuild on a clean tree and read
the diff:
git status --short # must be clean first, or the diff proves nothing
npx universal-plugin plugin build
git diff -- .claude-plugin .cursor-plugin .codex-plugin
An empty diff means the derived manifests match what the canonical manifest says. Any hunk is drift — either a stale build or a hand-edit that the rebuild has now discarded.
That rebuild is a write, so it is not part of the diagnosis. Report the check as a repair the user can run, or ask before running it yourself.
Version drift
Two files carry an authored version: the canonical plugin.json, and the package.json at the
packagePath the CLI reports (config get --key packagePath), resolved from the plugin root. The script compares
them and emits version-drift.
They diverge when someone ran npm version, or when changesets released a number that never flowed
back. Both are /universal-plugin:version's to fix — never patch one file by hand to match the
other.
Unreleased content
A runtime keys its plugin cache on the version, not on content: Claude Code resolves the version, finds it unchanged, and reports "already at the latest version" without re-extracting. So content pushed without a bump reaches nobody who already installed the plugin, and neither side is told (ADR-0010 §6).
The script compares the shipped paths — the canonical manifest, the skills directory, agents/,
governances/, mcp.json — against the commit that set the version the manifest carries now, and
emits unreleased-content for anything committed since. Uncommitted work is not reported; it has not
shipped.
Two cases are deliberately silent. A plugin that declares packagePath is skipped, because there the
release picks the number (ADR-0010 §2) and content waiting ahead of the last released version is the
normal state of a branch. A tree with no git history is skipped rather than guessed at.
The repair is the bump, and it belongs to /universal-plugin:version. Judge first whether the change
is meant to ship — content that is still being worked on is not a finding to act on.
Rules
- Never repair. Report the finding and name the skill that owns it.
- Never hand-edit a derived manifest to make a finding go away. The next build overwrites it and the finding comes back.
- Do not report
copilot-cliwriting no manifest as a fault. It reads the canonical manifest directly. Its components are a separate question — acopilot-root-componentsfinding is a real fault, andcanonicalis only healthy for a plugin that declares none of the moved kinds. - Do not treat repo-private agent configuration (
.claude/skills/,.agents/skills/) as part of the plugin. Diagnosing a repository's own skill wiring isbuddy-agent-harness:doctor.
Related skills
| Task | Skill |
|---|---|
| Create, adopt, or change what the plugin declares | init-universal-plugin |
| Move the plugin's version | version |
| Remove derived manifests, or the plugin itself | remove-plugin |
| Generate the repository's own marketplace catalogs | marketplace |
| Publish it to the shared marketplace repository | publish-plugin |