Claude Code Plugin
Guide for creating, validating, and managing plugin.json manifests for Claude Code plugins.
Per core:anti-fabrication: run the validation scripts and read the manifest before claiming a plugin.json is valid or that a component path exists. The validator names the offending field and value; that output is the authority, not this page.
Manifest schema
A manifest lives at .claude-plugin/plugin.json inside the plugin directory.
{
"name": "plugin-name",
"version": "1.2.0",
"description": "Brief plugin description",
"author": { "name": "Author Name", "email": "author@example.com", "url": "https://github.com/author" },
"homepage": "https://docs.example.com/plugin",
"repository": "https://github.com/author/plugin",
"license": "MIT",
"keywords": ["keyword1", "keyword2"],
"commands": ["./custom/commands/special.md"],
"agents": "./custom/agents/",
"hooks": "./config/hooks.json",
"mcpServers": "./mcp-config.json",
"skills": ["./skills/skill-one", "./skills/skill-two"]
}
name is the only required field. Everything else is optional, though version, description, license, keywords, repository, and author are worth setting: they are what a user sees before installing, and author is how they reach you with a bug report or a contribution.
Field rules
| Field | Rule |
|---|---|
name |
kebab-case, ^[a-z0-9]+(-[a-z0-9]+)*$. Match the directory name, and be specific rather than generic. Valid: my-plugin, core-skills. Invalid: myPlugin, my_plugin, My-Plugin, plugin- |
version |
semver, ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$. Valid: 1.0.0, 1.0.0-beta.1, 1.0.0+build.123. Invalid: 1.0, v1.0.0, 1.0.0.0 |
license |
SPDX identifier — MIT, Apache-2.0, GPL-3.0, BSD-3-Clause, ISC. See https://spdx.org/licenses/ |
keywords |
array of lowercase, specific, domain-bearing strings |
Component paths
Relative to the plugin root, ./-prefixed. Each has a dedicated skill for its own file format:
skills— string or array of directories, each containing aSKILL.md, ADDING to (not replacing) the defaultskills/scan — except for a marketplace entry whosesourceresolves to the marketplace root (e.g."./"), where it REPLACES the default scan instead (seeclaude-skills)commands— string or array of.mdfiles or directories, replacing the defaultcommands/scan (seeclaude-commands)agents— string or array of files, replacing the defaultagents/scan (seeclaude-agents)workflows— string or array of workflow script files or directories, replacing the defaultworkflows/scanhooks— string path to a hooks.json, or an inline hooks object (seeclaude-hooks)mcpServers— string path to an MCP config, or an inline objectoutputStyles— string or array of output-style files/directories, replacing the defaultoutput-styles/scan (seeclaude-output-styles)lspServers— string, array, or inline object of LSP (Language Server Protocol) configs for code intelligence (go-to-definition, find-references). Defaults to a.lsp.jsonfile at the plugin root when the field is absent.
output-styles/ is discovered by convention when outputStyles is unset; setting the field replaces that default scan rather than adding to it (see claude-output-styles).
Metadata and dependency fields
displayName— human-readable name shown in the/pluginpicker and other UI surfaces. Falls back tonamewhen omitted. Unlikename, may contain spaces and any casing; not used for namespacing or lookup.defaultEnabled— boolean, whether the plugin starts enabled when the user has not set a preference. Defaults totrue. Setfalseto ship a plugin that installs disabled (e.g. one that adds cost or connects to an external service) until the user opts in withclaude plugin enable <plugin>.userConfig— object declaring values Claude Code prompts the user for at enable time (type,title,descriptionrequired per key;sensitive,required,default,multiple,min/maxoptional). Substituted as${user_config.KEY}in MCP/LSP configs and hook commands.channels— array of message-channel declarations (Telegram/Slack/Discord-style injection). Each entry'sserverfield must match a key in the plugin'smcpServers.dependencies— array of other plugins this plugin requires. Each entry is either a bare plugin-name string, or an object with a requirednameand optional semverversionconstraint:
{
"dependencies": [
"helper-lib",
{ "name": "secrets-vault", "version": "~2.1.0" }
]
}
This is a plugin.json field, not a marketplace-only one — do not confuse it with category/strict/source/tags below, which upstream documents as marketplace-entry-specific and which this manifest schema does not include. (dependencies may also be echoed inside a marketplace entry, since a marketplace entry can carry any field from the plugin manifest schema — but its home is plugin.json.)
The version field is a range, not an exact version — upstream (see Constrain plugin dependency versions): "The version field accepts any expression supported by Node's semver package, including caret, tilde, hyphen, and comparator ranges." Documented examples: ~2.1.0 (tilde), ^2.0 (caret, partial), >=1.4 (comparator, partial), =2.1.0 (exact pin), 1.2.3 - 2.3.4 (hyphen range), and ||-joined alternatives (e.g. 1.2.7 || >=1.2.9 <2.0.0). validate-plugin.nu validates version against this range grammar, not the exact-version grammar used elsewhere in this manifest (plugin.json's own top-level version field) — a range string like ~2.1.0 is correctly REJECTED by an exact-version check and correctly ACCEPTED by the range check the validator applies here.
The check also accepts every additional form node's own semver.validRange accepts (verified against the actual semver npm package, current published version 7.8.5 — npm view semver version), not just the documented examples verbatim: whitespace between an operator and its version (>= 1.2.3), a LOWERCASE v prefix only (v1.2.3 — uppercase V1.2.3 is REJECTED, node does not case-fold it), ~> as a tilde-range alias, and an empty or whitespace-only string as equivalent to * (any version). An x-range wildcard (x/X/*) is valid only in TRAILING position (1.2.x accepted, x.1.2 and 1.x.3 rejected) — enforced structurally, not just by regex. See the comment above is-version-partial in scripts/validate-plugin.nu for the full list of these decisions and why each one is deliberate rather than accidental.
experimental field
experimental is an object holding components whose manifest schema may still change between releases: experimental.themes (string or array — color theme files/directories, replacing the default themes/ scan) and experimental.monitors (string or array — background monitor configs that start automatically while the plugin is active, replacing the default monitors/monitors.json). Both keys also work unnested at the top level ("themes": ..., "monitors": ...) today, but claude plugin validate already warns on the unnested form, and a future release will require nesting under experimental — use the nested form for new plugins rather than relying on the still-working top-level fallback.
{
"outputStyles": "./styles/",
"lspServers": "./.lsp.json",
"experimental": {
"themes": "./themes/",
"monitors": "./monitors.json"
}
}
Verified against validate-plugin.nu's invalid_fields denylist (category, strict, source, tags): none of outputStyles/lspServers/experimental, nor dependencies/displayName/defaultEnabled/workflows/userConfig/channels, are on it. The complete_manifest_all_documented_fields_recognized case in scripts/validate-plugin.nu's --self-test suite carries all nine fields in one fixture manifest and asserts it passes with zero errors and zero warnings (nu <CLAUDE_SKILL_DIR>/scripts/validate-plugin.nu --self-test).
Both hooks and mcpServers accept either a path or an inline object. Inline, they use each component's own schema — hooks are event-keyed, not lifecycle-keyed:
{
"hooks": {
"PostToolUse": [
{ "matcher": "Write|Edit", "hooks": [{ "type": "command", "command": "<CLAUDE_PLUGIN_ROOT>/scripts/format.sh" }] }
]
},
"mcpServers": { "filesystem": { "command": "mcp-server-filesystem", "args": ["./workspace"] } }
}
CLAUDE_PLUGIN_ROOT is written in angle brackets above, but a real hooks value wraps it as a quoted brace expansion — the harness expands it at hook runtime, which is correct in a JSON config. It is shown angle-bracketed here, not braced, because the braced form expands when this skill loads, replacing it with one machine's absolute path before any reader sees it — the same reason script commands on this page use <CLAUDE_SKILL_DIR> angle-bracketed rather than braced (see /claude-code:claude-skills "Dynamic context and substitutions" for the full notation convention). For the copyable braced form, see /claude-code:claude-hooks, whose reference files are read as raw bytes and can carry it safely.
Fields that must NOT appear in plugin.json
The validator rejects these with Invalid field '<field>' - this belongs in marketplace.json, not plugin.json. Upstream's marketplace-entries schema documents them as marketplace-specific fields, distinct from the plugin manifest schema (see plugin-marketplace):
category— marketplace-level metadatastrict— controls marketplace behavior, not the plugin definitionsource— a plugin's location is declared by the marketplace, not by itselftags— usekeywords
dependencies is NOT on this list — see "Metadata and dependency fields" above. It is a documented plugin.json field, not marketplace-only.
Unrecognized fields warn, not fail
Any other top-level field the validator doesn't recognize produces a warning (Unrecognized field '<field>' - not a known plugin.json field), never a hard failure — matching upstream's own claude plugin validate, which treats unrecognized fields as warnings so a manifest can double as another tool's config (an npm package.json, a VS Code extension manifest) without breaking. A typo'd field name is now visible instead of silently passing.
Pass --strict to promote those warnings to a failing result, mirroring upstream's own claude plugin validate --strict ("Pass --strict to treat warnings as errors. Use it in CI to catch a misspelled field name or a field left over from another tool's manifest before publishing, even though the plugin would load at runtime."). mise run test:plugins runs every local plugin with --strict — all 30 are warning-free as of this change, so a new warning now fails CI instead of only logging. --strict never invents new warnings; a manifest with zero warnings passes identically with or without the flag.
Validation
Scripts are bundled with this skill, under its own directory:
nu <CLAUDE_SKILL_DIR>/scripts/validate-plugin.nu .claude-plugin/plugin.json
nu <CLAUDE_SKILL_DIR>/scripts/validate-plugin.nu .claude-plugin/plugin.json --strict
nu <CLAUDE_SKILL_DIR>/scripts/init-plugin.nu
nu <CLAUDE_SKILL_DIR>/scripts/validate-plugin.nu --self-test # fixture suite for the validator itself
validate-plugin.nu checks JSON syntax (rejecting both unparseable content and valid-but-non-object JSON, such as a bare scalar or array — claude-skills-243), name presence and casing, field types, path accessibility, dependencies shape and version-range syntax, and invalid-field detection, plus a warn-on-unrecognized-field pass (promoted to a failure under --strict). Add --verbose for per-field output.
With --marketplace, it also checks that description and keywords agree with the plugin's marketplace entry, when that entry's source is a local path. plugin.json is authoritative. Entries whose source is a GitHub object are skipped — there is no local manifest to compare. When plugin.json defines a field, the marketplace entry must carry it too: an entry that omits description or keywords while plugin.json defines them is flagged as missing, not treated as agreement (marketplace.json entry is missing '<field>' that plugin.json defines). A field plugin.json itself omits is not compared at all. keywords compares as a sorted list, so reordering the array alone is not a mismatch — only a genuine difference in members is.
Verifying self-test coverage against always-pass mutations
--strict (above) makes every check in validate-plugin.nu load-bearing, which means a check that regresses to always-pass — or has its reject branch quietly removed — stays invisible to mise run test:plugins (that task only catches a check mutated to always FAIL, since it runs the marketplace path against 30 real, currently-valid plugins). This exact shape shipped once already (claude-skills-234/238): the agent model allowlist had zero fixture coverage and only became a CI blocker the moment --strict landed.
Run nu <CLAUDE_SKILL_DIR>/scripts/mutation-probe.nu on demand (not wired into mise test — each probe reruns the full --self-test suite as a subprocess) to verify --self-test still catches a curated set of known-dangerous mutations: neuter a check function to always-pass, or flip one of its exit 1 branches to exit 0, in a temp copy of validate-plugin.nu — the real file is opened read-only and never mutated in place, so an interrupt mid-run cannot leave it changed. An interrupt (or a CRASHED probe, even uninterrupted) can still leave temp directories behind — the probe's own copy dir, plus whatever fixture dirs the killed child --self-test had open — since their cleanup never gets a chance to run; these are OS-purged, not cleaned up by this script. Each probe also asserts self-test's own failure banner (self-test failed:) appears — not just a nonzero exit, which a crash also produces without any fixture having caught anything — AND that the specific expected case name is among the printed failures, so a different, unrelated fixture masking the real gap doesn't read as a pass. A stale probe (its find text no longer present) fails the run rather than silently reporting a hollow "all caught." Add a new probe (a find/replace/expect_case_substring triple in the script's $probes list) whenever a new check ships.
What each message means, and the install-time failures no script detects, is in references/validation-and-troubleshooting.md.
Creating a plugin
mkdir -p my-plugin/.claude-plugin my-plugin/skills- From the plugin directory, run
nu <CLAUDE_SKILL_DIR>/scripts/init-plugin.nu— or write the manifest by hand withname,version,description,author,license,keywords, and an emptyskillsarray. - Add each skill as a directory containing
SKILL.md, and list its path inskills. - Validate, then install through a marketplace that lists the plugin (see
plugin-marketplace):/plugin install <plugin-name>@<marketplace-name>.
Recommended layout:
plugin-name/
├── .claude-plugin/
│ └── plugin.json
├── skills/
│ ├── skill-one/
│ └── skill-two/
├── commands/
├── agents/
└── output-styles/
Versioning
Semver, with the usual major/minor/patch split and pre-release tags such as 1.0.0-beta.1 for betas. Keep a plugin's version in step between its plugin.json and its marketplace entry — a mismatch is what breaks update detection for installed users. The bundled validator does NOT check this; it only checks semver format. Cross-manifest version agreement is enforced by a marketplace's own CI, if at all.
Scripts
Bundled in this skill's scripts/ directory, run as nu <CLAUDE_SKILL_DIR>/scripts/<name>.nu [args]:
| Script | Purpose |
|---|---|
validate-plugin.nu |
Complete plugin.json validation |
init-plugin.nu |
Generate a plugin.json template |
format-plugin.nu |
Format and sort plugin.json |
mutation-probe.nu |
On-demand check that --self-test catches always-pass/reject-neutered mutations of validate-plugin.nu's own checks |
References
references/plugin-schema.md— the complete JSON schema specificationreferences/validation-and-troubleshooting.md— what each validator message means, and runtime failures no script reports