Everything you have written since module 4 is already sitting in the right shape. A commands/ directory, a skills/ directory with a SKILL.md inside it, a theme, an output style, an agent, a hooks.json, and an .mcp.json. Those are not eight unrelated exercises. They are the eight directories a Claude Code plugin loads, and the only thing missing is a file that gives them a name.
That file is .claude-plugin/plugin.json. It is one JSON object with one required key, and the name for a file like it, one that names a package and describes what is inside, is a manifest.
The assembled tree
.claude-plugin/plugin.json ships to your plugin Module 12. The only required key is name. Everything else is metadata or a path override.
{
"name": "diff-review",
"displayName": "Diff Review",
"version": "1.0.0",
"description": "Reviews uncommitted changes before you commit them.",
"author": {
"name": "Learn Claude Code",
"url": "https://github.com/learn-claude-code/diff-review"
},
"license": "MIT",
"keywords": ["review", "git", "diff", "pre-commit"],
"experimental": {
"themes": "./themes/"
}
} Read more
The name is kebab-case and becomes the namespace for every component in the plugin, so the skill below is invoked as /diff-review:review-changes. Themes sit under experimental.themes because the docs warn that the top-level themes key still works today, warns under plugin validate, and will be required to move in a future release.
commands/diff-stat.md Module 4. A flat markdown file, still a valid plugin component.
---
description: Print the diffstat for uncommitted work, grouped by directory.
argument-hint: [path]
---
!`git diff --stat -- ${0:-.}`
Read the diffstat above. Report which directories the change touches
and which single file moved the most lines. Read more
The docs describe commands/ as "skills as flat Markdown files" and steer new plugins toward skills/. Keeping one here is a fair way to see the two layouts side by side, and the invocation is namespaced either way.
skills/review-changes/scripts/summarize.sh Executed, never loaded into context. Still reached through ${CLAUDE_SKILL_DIR}, which resolves inside the plugin now.
#!/usr/bin/env bash
set -euo pipefail
path="${1:-.}"
echo "== diffstat =="
git diff --stat -- "$path" || true
echo
echo "== files =="
git diff --name-only -- "$path" | head -100 skills/review-changes/SKILL.md Module 5. Inside a plugin, frontmatter name sets the last segment of the command.
---
name: review-changes
description: Review uncommitted changes and flag anything risky before a commit.
when_to_use: When the user asks what changed, wants a commit message,
or asks whether the diff is safe to commit.
argument-hint: [path]
allowed-tools: Bash(${CLAUDE_SKILL_DIR}/scripts/summarize.sh *)
---
Run `${CLAUDE_SKILL_DIR}/scripts/summarize.sh $0` and read its output.
Summarize the change in two or three bullets, then list anything
risky. The risk categories are in reference.md next to this file. Read more
This is the exception module 5 flagged. For a personal or project skill the directory decides the command name and frontmatter name is a display label. For a plugin skill, name sets the segment after the colon, so name: review-changes gives you /diff-review:review-changes.
themes/review-dusk.json Module 6. The filename minus .json is the slug, exactly as it was in your home directory.
{
"name": "Review Dusk",
"base": "dark",
"overrides": {
"diffAdded": "#2f5d3a",
"diffRemoved": "#6b2f35",
"warning": "#d8a657",
"error": "#e06c75"
}
} Read more
Selecting it stores custom:diff-review:review-dusk rather than the bare custom:review-dusk you got from ~/.claude/themes/. A plugin theme is read-only in the picker; Ctrl+E copies it into your home directory if you want to edit it.
output-styles/review-notes.md Module 7. Same file you wrote into .claude/output-styles/, moved wholesale.
---
name: Review notes
description: Verdict first, then evidence, in the register of a code review
keep-coding-instructions: true
---
Open every response about a diff with a one-line verdict: safe to
commit, safe with edits, or hold. Then give the evidence as findings. agents/risk-reviewer.md Module 8. Invoked as @agent-diff-review:risk-reviewer.
---
name: risk-reviewer
description: Reads a diff in isolated context and reports only the risks.
tools: Read, Grep, Glob, Bash
model: sonnet
color: orange
---
You review a diff and report risk. You do not write code.
For each risk, give the file, the line, the category, and one
sentence on what breaks if the change ships as written. Read more
Three frontmatter fields stop working once an agent ships inside a plugin: permissionMode, mcpServers, and hooks are all ignored for plugin subagents. A project or user agent of the same name overrides this one, which is the precedence inversion module 8 warned about.
hooks/hooks.json ships to your plugin Module 9. Identical format to the hooks object in settings.json, plus an optional description.
{
"description": "Deny reads of secret-shaped files while reviewing a diff.",
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/block_secrets.py\"",
"timeout": 10
}
]
}
]
}
} Read more
Quote the path variable. In shell form the command is one string handed to a shell, so an installation directory containing a space breaks an unquoted ${CLAUDE_PLUGIN_ROOT}. The docs write the quotes explicitly for this reason.
hooks/block_secrets.py The tested hook from the hooks lab, copied in unchanged.
#!/usr/bin/env python3
"""PreToolUse (Read|Bash): deny access to secret files."""
import json, re, sys
SECRET = re.compile(
r"(^|/)(\.env(\.[\w-]+)?|id_rsa|id_ed25519|.*\.pem|credentials\.json)$")
data = json.load(sys.stdin)
# ... emits a permissionDecision of "deny" on a match, then exits 0.
# The JSON carries the decision. Silence is not approval. .mcp.json Module 10. At the plugin root, not inside .claude-plugin/.
{
"mcpServers": {
"repo-files": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"${CLAUDE_PROJECT_DIR}"
]
}
}
} Read more
Tools from this server arrive as mcp__plugin_diff-review_repo-files__<tool>. A hook matcher written against the bare server key never fires, and an mcp_tool handler targeting it needs server: "plugin:diff-review:repo-files".
README.md Not loaded by Claude Code. Written for the person deciding whether to install this.
# diff-review
Reviews uncommitted changes before you commit them, and refuses to
read secret files while doing it.
claude --plugin-dir ./example-plugin The manifest
name is the only required field. A manifest of {"name": "diff-review"} is valid and loads every directory above, because all of them sit at their default locations.
Everything else falls into two groups. Fields that describe the plugin to a person reading about it and change nothing about how it runs, which is what metadata means: displayName, description, version, author, homepage, repository, license, keywords. And path fields, which change where Claude Code looks for your files: skills, commands, agents, hooks, mcpServers, outputStyles, experimental.themes.
The path fields have one asymmetry worth knowing before you reach for them.
| Field | Effect on the default directory |
|---|---|
skills | Adds to the default skills/ scan |
commands | Replaces default commands/ |
agents | Replaces default agents/ |
outputStyles | Replaces default output-styles/ |
experimental.themes | Replaces default themes/ |
Point commands at a second directory and the files in commands/ stop loading. Point skills at one and both directories load. That difference is not a typo in the docs, and it is the kind of thing --strict will not catch for you because both spellings are legal.
Two fields depend on which Claude Code version the user is running. defaultEnabled decides whether the plugin starts enabled on install and needs v2.1.154 or later; older versions ignore it and enable anyway. version pins the plugin to that string, so users receive an update only when you bump it.
Namespacing
Once a component lives in a plugin, meaning any of the skills, commands, agents, themes or servers the tree holds, it answers to a prefixed name. Giving names a prefix so that two sets of them can coexist is namespacing, and the prefix is the namespace. Here it is the manifest’s name, not the directory the plugin happens to be checked out into.
| Component | Outside a plugin | Inside diff-review |
|---|---|---|
| Skill | /review-changes | /diff-review:review-changes |
| Command file | /diff-stat | /diff-review:diff-stat |
| Agent | @agent-risk-reviewer | @agent-diff-review:risk-reviewer |
| Theme | custom:review-dusk | custom:diff-review:review-dusk |
| MCP tool | mcp__repo-files__read_file | mcp__plugin_diff-review_repo-files__read_file |
The docs are explicit that plugin skills are always namespaced, and the reason is collision: two plugins can both ship a skill called deploy and neither has to care. It also means a plugin skill can never be shadowed by a project skill of the same short name, while a plugin agent can be, because project and user .claude/agents/ definitions override same-named plugin agents.
Running it before you package it
There is no install step for local development. Point the CLI at the directory:
claude --plugin-dir ./example-plugin
The plugin loads for that session. You can pass the flag more than once, and you can pass a .zip or a --plugin-url pointing at one. When a --plugin-dir plugin shares a name with an installed marketplace plugin, the local copy wins for that session, which makes this the way to test a change against a version your team already has.
Reload behaviour is uneven and it will confuse you once. Edits to a plugin’s SKILL.md take effect immediately. Edits to hooks/, .mcp.json, agents/, and output-styles/ do not; run /reload-plugins or restart.
There is a third route that skips both the flag and the marketplace. Any folder under a skills directory that contains .claude-plugin/plugin.json loads on the next session as a plugin named <name>@skills-dir. Drop this directory into ~/.claude/skills/ and it is available in every project with no install and no manifest registration anywhere.
Path variables
Two variables exist inside a plugin that you have not had before, and they answer different questions.
${CLAUDE_PLUGIN_ROOT} is the full path, written from the top of the filesystem down, to the directory the plugin was installed into. It changes on every update, so it is where your code lives, never where your data lives. Hook commands, monitor commands, and MCP and LSP server subprocesses all substitute it.
${CLAUDE_PLUGIN_DATA} is a persistent directory under ~/.claude/plugins/data/{id}/ that survives plugin updates. Caches, downloaded dependencies, and anything a user would be annoyed to lose belong there.
${CLAUDE_PROJECT_DIR} still resolves to the project root, and is what the .mcp.json above uses to scope the filesystem server.
Quote them. The docs write the hook command as "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/format-code.sh" because the shell form is a single string and an installation path with a space in it splits into two arguments otherwise.
Distribution
A marketplace is a repository that lists plugins other people can install, and it declares that list in .claude-plugin/marketplace.json at its root. Three required fields: name, owner (an object whose name is required), and plugins (an array). Each entry needs name and source.
{
"name": "learn-claude-code",
"owner": { "name": "Your Team", "email": "[email protected]" },
"plugins": [
{
"name": "diff-review",
"source": "./example-plugin",
"description": "Review uncommitted changes before you commit them",
"version": "1.0.0"
}
]
}
source accepts a relative path, a github object, a git url, a git-subdir, an npm package, an archive with a sha256, or a command that produces the plugin. Your users then add the marketplace once and install by name:
claude plugin marketplace add your-org/claude-plugins
claude plugin install diff-review@learn-claude-code
Or a team pins both in .claude/settings.json under extraKnownMarketplaces and enabledPlugins, and nobody runs a command at all.
Install scope decides who ends up with the plugin, and it is the same layering you met in module 6. user is the default and covers every project. project writes to .claude/settings.json and is shared through git. local stays in this repository and out of the commit. managed is read-only and comes from an administrator.
Validation
claude plugin validate ./example-plugin
claude plugin validate ./example-plugin --strict
Without the flag you get ✔ Validation passed or ✔ Validation passed with warnings, and warnings do not fail the run. --strict turns every warning into an error, and the docs name its purpose directly: catching misspelled field names in continuous integration, the automatic checks a project runs on every push, usually shortened to CI. A descrpition key is not an error to a JSON parser and it is not an error to the loader either. It is a field that silently does nothing until --strict tells you about it.
Run the same command against a marketplace directory and it checks JSON syntax, duplicate plugin names, whether a source path climbs out of the marketplace directory, which the docs call path traversal, the validity of each plugin’s own plugin.json, and version mismatches between a plugin.json and its marketplace entry.
claude plugin evalYou will see this command referenced. It does not appear anywhere in the public documentation: not in the plugins pages, not in the plugins reference, not in the docs index. Treat it as undocumented or early-access, and do not build a CI gate on it.
claude plugin validate --strict is the gate that exists and is documented. Wire that one into CI.
Create .claude-plugin/ at the root of the directory you have been filling since module 4, and write plugin.json with name, description, version, and author. Start there and add nothing else.
Run claude --plugin-dir ./your-plugin. Invoke your skill by its namespaced name and confirm the old bare name no longer resolves. Type /plugin and read the Errors tab even when nothing looks wrong.
Then move your hook’s script into the plugin, rewrite its command to use ${CLAUDE_PLUGIN_ROOT} with the quotes, and run /reload-plugins. Trigger the hook and confirm it still fires from the new path.
Finish with claude plugin validate ./your-plugin --strict and fix whatever it names.
You have a .claude-plugin/ directory holding the manifest, so you put the rest of the plugin in there too. .claude-plugin/commands/, .claude-plugin/skills/, .claude-plugin/hooks/. It reads like the tidy choice.
The plugin loads. Validation passes. No error appears anywhere, and the Errors tab in /plugin is empty. Your skills are not there, and the first sign is a slash command that does not autocomplete.
The docs call this out under the heading “Common mistake”: only plugin.json goes inside .claude-plugin/, and every other directory sits at the plugin root. The root is the directory you pass to --plugin-dir or the one containing .claude-plugin/plugin.json. It is never ~/.claude/.
The reason this bites so hard is that a plugin with zero components is a legal plugin. Nothing in the system can tell the difference between a manifest you meant to be empty and one whose directories are one level too deep.
The plugin as an artifact travels worse than anything else in this course, with one real exception. Five plugin ecosystems launched in the last year and they are mutually incompatible: Cursor’s marketplace reads .cursor-plugin/plugin.json, Codex has codex plugin marketplace add, Gemini CLI has extensions that bundle skills and a hooks/hooks.json, and Cline ships a plugin software development kit, a set of TypeScript tools for writing one. Same concept, incompatible manifests.
The exception is Copilot. Its Agent Plugins system became generally available on 2026-08-12 and it loads .claude-plugin/plugin.json manifests directly, with claude-code-plugins listed as an addable marketplace. That is the only documented cross-vendor plugin bridge in the survey.
What actually transfers is the layer underneath. Your SKILL.md directories are read unmodified by four harnesses, your MCP server is a real program that works in all nine harnesses that support MCP, and the hook contract of lifecycle event to shell command to exit 2 blocks converged across six of them. Package it as a plugin for Claude Code, and unbundle the same directories anywhere else.
What a plugin cannot carry
Look at the tree again and count the modules. Two are missing.
Module 3 wrote CLAUDE.md and .claude/rules/. There is no rules/ directory in a plugin and no manifest field that points at one. Module 11 wrote permission rules and sandbox settings. A plugin can ship a settings.json at its root, and exactly two keys in it are supported: agent and subagentStatusLine. Everything else is silently ignored.
A plugin is a thing you install into someone else’s machine, and the question of what Claude is allowed to do on that machine is not yours to answer. Capabilities are distributable. Guidance and policy are not.
So when you hand this plugin to a teammate, they get your skill, your agent, your hook, and your MCP server. They do not get your CLAUDE.md, and they keep their own deny rules. The hook you shipped still runs, and it still cannot escape their permission settings, for exactly the reason module 11 gave: a hook is not a security boundary.
Check yourself
- Your manifest sets
"commands": "./extra/". What happens to the four files incommands/? - A teammate installs your plugin and has their own
.claude/agents/risk-reviewer.md. Which agent runs when they type@agent-risk-reviewer? - Your CI runs
claude plugin validate ./pluginand it passes. You then renamedescriptiontodescrptionin the manifest. Does CI still pass?