A Claude Code plugin is a directory with a .claude-plugin/plugin.json
manifest that bundles the things you would otherwise drop into ~/.claude/ by
hand: slash commands, subagents, hooks, skills, and MCP servers. Wrapping them in
a plugin turns a pile of personal config into one versioned artifact that a
teammate installs with a single command, namespaced so it never collides with
their own setup.
This tutorial builds one plugin that uses three of those surfaces at once, then distributes it through GitHub:
- a slash command that drafts a pull-request description from your branch diff,
- a subagent that reviews the diff for the things a human reviewer would flag,
- a hook that blocks an accidental force-push to
mainbefore the command ever runs.
You will validate the manifests with the claude CLI, test the plugin locally,
install it through a local marketplace exactly the way your users will, and push
the repository so anyone can add it with /plugin marketplace add.
If you only need to ship a single skill, the companion article Package and Distribute Skills for Claude Code covers the skills-only path. This one focuses on the other plugin surfaces — commands, agents, and hooks — and how they travel together in one plugin.
What you will build
A plugin named pr-prep, listed in a marketplace named dev-workflow-tools. The
repository doubles as the marketplace, so the same git repo both defines the
plugin and distributes it:
pr-prep-marketplace/
├── .claude-plugin/
│ └── marketplace.json # Catalog: lists the plugin and its source
├── plugins/
│ └── pr-prep/
│ ├── .claude-plugin/
│ │ └── plugin.json # Plugin manifest: name, version, metadata
│ ├── commands/
│ │ └── summary.md # Slash command: /pr-prep:summary
│ ├── agents/
│ │ └── pr-reviewer.md # Subagent: pr-reviewer
│ ├── hooks/
│ │ ├── hooks.json # Hook config: PreToolUse on Bash
│ │ └── guard-protected-push.sh
│ └── scripts/
│ └── diff-summary.sh # Helper the command shells out to
├── Makefile # validate / test / pack targets
└── .gitignore
Prerequisites
- macOS (the commands are macOS-first; they work on Linux unchanged).
- Claude Code v2.1.128 or later. Namespaced plugin components,
claude plugin validate, and zip-based--plugin-dirall need a recent build. This tutorial was validated on v2.1.210. Check yours withclaude --version; upgrade withclaude updateif needed. - git — the marketplace is distributed as a git repository, and both the command’s helper script and the review agent operate on git history.
- jq — the force-push hook parses its JSON input with
jq. Install withbrew install jqifwhich jqcomes up empty. - make and zip — preinstalled on macOS; used by the Makefile.
- A GitHub account, for the final distribution step.
No language runtime is required. The plugin is Markdown, JSON, and two POSIX
shell scripts, so consumers install it with zero dependencies beyond jq.
How the pieces fit
A plugin is discovered by convention. Claude Code looks at fixed directories under the plugin root and loads whatever it finds:
commands/*.mdbecome slash commands, invoked as/<plugin>:<command>.agents/*.mdbecome subagents you can hand a task to.hooks/hooks.jsonregisters hooks that fire on harness events (a tool call, a prompt, session start).skills/*/SKILL.mdbecome skills (covered in the companion article)..mcp.jsonregisters MCP servers.
The manifest at .claude-plugin/plugin.json names and versions the plugin; it does
not list the components. You only add explicit paths when you deviate from these
default locations. The name in the manifest is the namespace: every command,
agent, and skill the plugin ships is prefixed with it, so pr-prep gives you
/pr-prep:summary and the pr-reviewer agent without any risk of clobbering a
user’s own summary command.
Step 1: Scaffold the repository and ignore build artifacts
Create the file
Create the directory tree and the .gitignore before anything else, so generated
archives never land in a commit.
mkdir -p pr-prep-marketplace/.claude-plugin
mkdir -p pr-prep-marketplace/plugins/pr-prep/.claude-plugin
mkdir -p pr-prep-marketplace/plugins/pr-prep/commands
mkdir -p pr-prep-marketplace/plugins/pr-prep/agents
mkdir -p pr-prep-marketplace/plugins/pr-prep/hooks
mkdir -p pr-prep-marketplace/plugins/pr-prep/scripts
cd pr-prep-marketplace
touch .gitignore
Add the code: pr-prep-marketplace/.gitignore
# Build artifacts produced by `make pack`
dist/
*.zip
# macOS clutter
.DS_Store
# Local Claude Code state, if you test inside this repo
.claude/
Detailed breakdown
.claude-plugin/at two levels. The one at the repo root holdsmarketplace.json(the catalog). The one insideplugins/pr-prep/holdsplugin.json(the manifest). Only these two manifest files ever live inside a.claude-plugin/directory. Every component directory —commands/,agents/,hooks/,scripts/— sits at the plugin root, not inside.claude-plugin/. Nesting a component folder inside.claude-plugin/is the single most common plugin bug; the components silently fail to load.plugins/subdirectory. Putting the plugin underplugins/keeps the repo tidy and lets the marketplace grow to several plugins later. The marketplace catalog points at it with a relative path..gitignorefirst. Thedist/folder and*.zipfiles are created by thepacktarget in Step 7. Ignoring them up front keeps the repository to source files only..claude/is ignored in case you run Claude Code from inside this repo while testing, so your local session state is never committed.
Step 2: Write the plugin manifest
Create the file
touch plugins/pr-prep/.claude-plugin/plugin.json
Add the code: plugins/pr-prep/.claude-plugin/plugin.json
{
"name": "pr-prep",
"description": "Prepare pull requests: draft a description from the diff, review it with a subagent, and block accidental force-pushes to protected branches.",
"version": "1.0.0",
"author": {
"name": "Your Name"
},
"homepage": "https://github.com/your-org/pr-prep-marketplace",
"license": "MIT"
}
Detailed breakdown
nameis the namespace. It must be kebab-case with no spaces. It becomes the prefix for the slash command (/pr-prep:summary) and the agent, and it is half of the install identifier (pr-prep@dev-workflow-tools).versioncontrols updates. Whenversionis set, users only receive an update when you bump this field. If you omit it and distribute via git, every commit counts as a new version. An explicit semver string gives you a deliberate release cadence, so bump it on each release.author,homepage,licenseare optional metadata shown in the plugin manager. They are worth filling in because a plugin ships hooks and tool access, and users are deciding whether to trust it.- The manifest does not list components. There is no
commandsorhooksarray here. Claude Code findscommands/summary.md,agents/pr-reviewer.md, andhooks/hooks.jsonby their conventional locations under the plugin root. Keep the manifest small.
Step 3: Write the slash command
Create the file
touch plugins/pr-prep/commands/summary.md
Add the code: plugins/pr-prep/commands/summary.md
---
description: Draft a pull-request description from the diff against a base branch. Use when opening a PR or writing a change summary.
argument-hint: [base-branch]
allowed-tools: Bash(git diff *), Bash(git log *), Bash(git merge-base *)
---
## Changes on this branch
```!
bash ${CLAUDE_PLUGIN_ROOT}/scripts/diff-summary.sh $1
```
## Your task
Write a pull-request description for the changes above. The base branch is the
first argument to this command, or `main` when none was given.
1. Open with a one-paragraph summary of what the branch does and why.
2. Add a `## Changes` section with a short bullet per logical change, grouped by
area when the diff touches several. Rewrite terse commit subjects into
reviewer-facing sentences; ignore noise like "wip" and "fixup".
3. Add a `## Testing` section. If the diff changed tests, say what they cover; if
it did not, say so plainly so the reviewer knows to ask.
4. Output only the Markdown description, ready to paste into the PR body.
Detailed breakdown
- A command is a Markdown prompt. The body is the instruction Claude runs when
a user types
/pr-prep:summary. Thepr-prepprefix comes from the pluginname; thesummarycomes from the filename. argument-hint: [base-branch]shows[base-branch]in the autocomplete menu, so a user knows they can type/pr-prep:summary developto diff against a branch other thanmain.$1is the first positional argument. Claude Code substitutes it with what the user typed after the command name (empty when they typed nothing). The command passes it straight to the helper script, which defaults an empty value tomain, so the default lives in one place. Commands also expose$2,$3, and$ARGUMENTS(the whole argument string).- The
```!block runs before Claude sees the prompt. Claude Code executes the fenced command and replaces the block with its output, so the prompt arrives with the actual diff stat and commit list already inlined.${CLAUDE_PLUGIN_ROOT}resolves to this plugin’s install directory wherever it landed, which is what makes the bundled-script reference portable. allowed-toolspre-approves the git read commands so Claude runs them without a prompt if it needs to inspect the diff further while writing. Scope it narrowly rather than grantingBash(*).- Note the outer fence. In this listing the file is shown inside a four-backtick
block so its own triple-backtick
```!block renders. In the actualsummary.md, that block uses three backticks.
Step 4: Write the helper script the command calls
Create the file
touch plugins/pr-prep/scripts/diff-summary.sh
chmod +x plugins/pr-prep/scripts/diff-summary.sh
Add the code: plugins/pr-prep/scripts/diff-summary.sh
#!/usr/bin/env bash
# Summarize the current branch against a base branch: the merge-base, a file
# stat, and the commit subjects unique to this branch. Prints readable messages
# (never a stack trace) in the edge states a fenced-command block can hit.
set -euo pipefail
base="${1:-main}"
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "(not inside a git repository)"
exit 0
fi
if ! git rev-parse --verify --quiet "$base" >/dev/null 2>&1; then
echo "(base branch '$base' not found — pass an existing branch: /pr-prep:summary <base>)"
exit 0
fi
# The merge-base is where this branch diverged from the base, so the range
# below describes only this branch's own work, not commits already on base.
fork_point="$(git merge-base HEAD "$base" 2>/dev/null || true)"
if [ -z "$fork_point" ]; then
echo "(no common history with '$base')"
exit 0
fi
range="${fork_point}..HEAD"
count="$(git rev-list --count "$range")"
if [ "$count" -eq 0 ]; then
echo "No commits on this branch beyond '$base'."
exit 0
fi
echo "Files changed vs ${base}:"
git diff --stat "$range"
echo
echo "Commit subjects unique to this branch:"
git log --no-merges --pretty=format:'- %s' "$range"
echo
Detailed breakdown
- Why a bundled script. The command could inline the git plumbing directly, but the merge-base logic, the empty-range guard, and the not-a-repo fallback are clearer and independently testable as a script. Bundling it means the plugin carries its own tooling and behaves identically on every machine that installs it.
base="${1:-main}"applies the default.${1:-main}expands tomainwhen$1is unset or empty, so it does the right thing whether the command passed no argument or an empty one.set -euo pipefailmakes the script fail loudly on an error, an unset variable, or a broken pipe, rather than emitting partial output that Claude would treat as real data.- The three guards cover the states where git would otherwise abort and dump an
error into the prompt: not inside a repository, a base branch that does not exist,
and no shared history with the base. Each prints one readable line and exits
0, because the command block inlines whatever the script prints. git merge-base HEAD "$base"finds the fork point, so the${fork_point}..HEADrange is exactly this branch’s own commits — not everything already merged into the base.--no-mergesdrops merge commits, and--pretty=format:'- %s'prints each subject as a Markdown bullet, close to the shape Claude turns into the final description.- Executable bit.
chmod +xis a convenience; the command invokes the script with an explicitbash ..., so it runs even if the bit is lost in a zip or copy that drops permissions.
Step 5: Write the review subagent
Create the file
touch plugins/pr-prep/agents/pr-reviewer.md
Add the code: plugins/pr-prep/agents/pr-reviewer.md
---
name: pr-reviewer
description: Reviews a branch diff for issues a PR reviewer would flag — leftover debug code, missing tests, unclear naming, and undocumented behavior changes. Use before opening a pull request.
tools: Bash, Read, Grep, Glob
---
You are a focused pull-request reviewer. Your job is to catch the problems a
human reviewer would raise, before the PR is opened.
## How to work
1. Determine the base branch. If the caller named one, use it; otherwise default
to `main`. Find the fork point with `git merge-base HEAD <base>`.
2. Read the diff with `git diff <fork-point>..HEAD`. Read the full surrounding
file with the Read tool when a change is hard to judge from the hunk alone.
3. Group findings by severity: **Blocking**, **Should fix**, **Nit**.
## What to look for
- **Leftover debug code**: stray `print`/`console.log`, commented-out blocks,
`TODO`/`FIXME` added in this diff, hard-coded credentials or absolute paths.
- **Missing tests**: new behavior or a bug fix with no matching test change.
- **Naming and clarity**: names that mislead, functions doing two jobs, magic
numbers without a constant.
- **Behavior changes**: a changed default, signature, or error path that the PR
description does not mention.
## Output
Report only what you found, most severe first, each as `path:line — issue`.
If a category is clean, say so in one line. Do not restate the diff. End with a
one-sentence verdict: ready to open, or the single most important thing to fix.
Detailed breakdown
- An agent is a system prompt with a frontmatter contract. The
nameis how you address it (the plugin namespace is applied automatically). Thedescriptionis what Claude reads when deciding whether to delegate a task to this subagent, so it leads with the action and names concrete triggers. toolsscopes the subagent’s capabilities. This reviewer needs to read code and run git, so it listsBash, Read, Grep, Globand nothing that writes. Omitting the field grants the full default tool set; naming tools is the safer default for an agent you are handing to others. A read-only reviewer should not carryWriteorEdit.- The body is the whole behavior. A subagent runs in its own context window, so the
prompt spells out its procedure, its checklist, and its exact output format instead
of assuming shared conversation history. The severity grouping and the
path:line — issueformat give predictable, skimmable output. - How it is invoked. With the plugin installed, ask Claude to “use the pr-reviewer
agent to review this branch” and it delegates. The agent shares the plugin’s
namespace, so it never collides with a
pr-reviewera user defined themselves.
Step 6: Write the force-push guard hook
A hook runs your own code on a harness event. This one fires on PreToolUse for the
Bash tool: before Claude runs any shell command, Claude Code hands the proposed
command to the script, and the script can allow it or deny it. Here it denies a
force-push aimed at main or master.
Create the file
touch plugins/pr-prep/hooks/guard-protected-push.sh
chmod +x plugins/pr-prep/hooks/guard-protected-push.sh
Add the code: plugins/pr-prep/hooks/guard-protected-push.sh
#!/usr/bin/env bash
# PreToolUse hook for the Bash tool. Reads the hook payload on stdin and denies
# a force-push (git push --force / -f / --force-with-lease) whose target is a
# protected branch (main or master). Everything else is allowed to proceed.
set -euo pipefail
payload="$(cat)"
# Pull the proposed shell command out of the tool input. `// empty` keeps jq
# quiet when the field is absent so `command` just ends up empty.
command="$(printf '%s' "$payload" | jq -r '.tool_input.command // empty')"
deny() {
# PreToolUse decides via hookSpecificOutput.permissionDecision. "deny" blocks
# the tool call and shows the reason to Claude instead of running it.
jq -n --arg reason "$1" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $reason
}
}'
exit 0
}
# Not a git push, or not forced: stay silent and let it run.
case "$command" in
*"git push"*) ;;
*) exit 0 ;;
esac
case "$command" in
*"--force"*|*"--force-with-lease"*|*" -f"*) ;;
*) exit 0 ;;
esac
# A force-push. Deny only when it targets a protected branch by name.
for branch in main master; do
case "$command" in
*"$branch"*)
deny "Blocked by pr-prep: force-pushing to '$branch' rewrites shared history. Push to a feature branch and open a PR, or run the command outside Claude if you are certain."
;;
esac
done
# Forced, but not at a protected branch (e.g. your own feature branch): allow.
exit 0
Detailed breakdown
- Input arrives on stdin as JSON. A
PreToolUsehook receives an object that includestool_nameandtool_input. For theBashtool,tool_input.commandholds the exact shell string Claude wants to run.jq -r '.tool_input.command // empty'extracts it, defaulting to an empty string when the field is missing. - Deciding via
permissionDecision. Printing an object withhookSpecificOutput.permissionDecision: "deny"blocks the tool call; thepermissionDecisionReasonis shown to Claude in place of running the command. The other values are"allow"(skip the normal permission prompt) and"ask"(fall through to the user). Emitting nothing and exiting0, as the non-match paths do, lets the command proceed normally. - Two-stage matching. The first
caseexits early unless the command is agit push; the second exits unless it carries a force flag. Only a forced push reaches the protected-branch check, so ordinary pushes and every non-git command pass through untouched and fast. The*" -f"*pattern has a leading space so it matches the-fflag without also matching, say, a branch namedhotfix. - Why deny rather than block silently. The reason string tells Claude why the command was refused and what to do instead, so the model can adjust (push a feature branch, open a PR) rather than retrying the same thing. It also reminds the user the guard is advisory: a force-push they truly intend can be run in a normal terminal outside Claude.
exit 0even on deny. The decision is carried by the JSON on stdout, not by the exit code. A non-zero exit is a hook error, which Claude Code surfaces differently; reserve it for the script actually failing.
Step 7: Register the hook and add the Makefile
Create the file
touch plugins/pr-prep/hooks/hooks.json
Add the code: plugins/pr-prep/hooks/hooks.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/guard-protected-push.sh"
}
]
}
]
}
}
Detailed breakdown
hooks.jsonis the registration, the script is the logic. The JSON says when to run (PreToolUse), for what (matcher: "Bash"), and what to run (the shell command). The script from Step 6 decides the outcome. This is the same shape as thehooksblock in asettings.json, which is why hook config you already use personally ports straight into a plugin.matcher: "Bash"limits the hook to theBashtool. Without a matcher the hook would fire on every tool call and waste work parsing payloads it does not care about. The matcher is a regular expression, so"Edit|Write"would target file edits instead.${CLAUDE_PLUGIN_ROOT}is the same variable the command uses. It resolves to the plugin’s install path at runtime, so the hook finds its script whether the plugin was loaded from your working tree, a local marketplace, or a cloned GitHub repo.type: "command"runs an external program. It is the only hook type you need here; the alternative shapes exist for inline handlers but a bundled script keeps the logic testable on its own.
Create the file
touch Makefile
Add the code: pr-prep-marketplace/Makefile
# pr-prep marketplace — validation, testing, and packaging tasks.
PLUGIN_DIR := plugins/pr-prep
PLUGIN_NAME := pr-prep
DIST := dist
.DEFAULT_GOAL := help
.PHONY: help validate validate-plugin test-diff test-hook pack clean
help: ## Show this help screen
@echo "pr-prep marketplace tasks:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "} {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
validate: ## Validate the marketplace catalog (strict)
claude plugin validate . --strict
validate-plugin: ## Validate the plugin manifest, command, agent, and hook (strict)
claude plugin validate ./$(PLUGIN_DIR) --strict
test-diff: ## Run the command's diff-summary script against this repo
bash $(PLUGIN_DIR)/scripts/diff-summary.sh main
test-hook: ## Feed the force-push guard a blocked and an allowed payload
@echo "→ force push to main (expect deny):"
@printf '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \
| bash $(PLUGIN_DIR)/hooks/guard-protected-push.sh
@echo "→ normal push (expect no output / allow):"
@printf '{"tool_name":"Bash","tool_input":{"command":"git push origin feature"}}' \
| bash $(PLUGIN_DIR)/hooks/guard-protected-push.sh
@echo "(allowed)"
pack: ## Build a distributable zip of the plugin for --plugin-dir
@mkdir -p $(DIST)
@rm -f $(DIST)/$(PLUGIN_NAME).zip
cd $(PLUGIN_DIR) && zip -r ../../$(DIST)/$(PLUGIN_NAME).zip . -x '*.DS_Store'
@echo "Wrote $(DIST)/$(PLUGIN_NAME).zip"
clean: ## Remove build artifacts
rm -rf $(DIST)
Detailed breakdown
- Default target prints help.
.DEFAULT_GOAL := helpmakes a baremakeprint the target list instead of running the first rule. Thegrep/awkpipeline reads the##comment after each target name, so the help screen stays in sync with the targets as you add more. validate/validate-plugin. Pointed at the repo root, the validator checksmarketplace.json. Pointed at the plugin directory, it also checksplugin.jsonand the frontmatter of every command, agent, and hook.--strictturns warnings (unrecognized fields, missing metadata) into a non-zero exit, which is what you want in CI.test-diffandtest-hookexercise the two scripts directly, outside Claude.test-hookpipes a realistic payload into the guard so you can confirm the deny/allow decision without launching a session. Testing the scripts standalone is how you catch logic bugs before they hide behind the harness.packzips the plugin’s contents (note thecdinto$(PLUGIN_DIR)), so the archive’s root holds.claude-plugin/plugin.json. That archive works withclaude --plugin-dir ./pr-prep.zip(v2.1.128+). Thedist/output is gitignored from Step 1.- Tabs, not spaces. Recipe lines must be indented with a real tab, the usual Makefile gotcha. Copy the block verbatim.
Step 8: Write the marketplace catalog
Create the file
touch .claude-plugin/marketplace.json
Add the code: pr-prep-marketplace/.claude-plugin/marketplace.json
{
"name": "dev-workflow-tools",
"owner": {
"name": "Your Name",
"email": "you@example.com"
},
"metadata": {
"description": "Developer workflow helpers for Claude Code."
},
"plugins": [
{
"name": "pr-prep",
"source": "./plugins/pr-prep",
"description": "Prepare pull requests: draft a description from the diff, review it with a subagent, and block accidental force-pushes to protected branches.",
"version": "1.0.0",
"category": "workflow",
"tags": ["pull-request", "git", "review", "hooks"]
}
]
}
Detailed breakdown
nameis what users type. Installing this plugin is/plugin install pr-prep@dev-workflow-tools— plugin name@marketplace name. Each user registers only one marketplace per name, so pick something specific and stable. Names resembling an official Anthropic marketplace are reserved and blocked.source: "./plugins/pr-prep"is a relative path from the marketplace root (the directory containing.claude-plugin/), not from the.claude-plugin/folder. It must start with./. Relative sources resolve when users add the marketplace from git or a local path. For plugins that live in other repos, asourcecan instead be agithub,git, ornpmobject.- The duplicated
version. Listingversionboth here and inplugin.jsonis intentional. The validator warns if they disagree, which catches the classic release mistake of bumping one and forgetting the other. categoryandtagspower discovery and search in the plugin manager. They are optional but cheap to add.owneris required.nameis mandatory;emailis optional but gives users a contact for a plugin they are about to trust with hook and tool access.
Step 9: Validate the manifests
Run the validator before you ever try to load the plugin. It catches malformed JSON, misplaced directories, and frontmatter errors that would otherwise surface as a confusing “command not found” later.
make validate
make validate-plugin
Both targets print ✔ Validation passed and exit 0 when the manifests are clean.
Because both pass --strict, any unrecognized field or missing recommended metadata
fails the build, so a green run here means the manifests are genuinely correct, not
merely parseable.
If validation fails, the message names the file and the problem. Fix the named file and re-run. Then confirm the two scripts work on their own:
make test-hook
make test-diff
make test-hook prints the deny JSON for the force-push-to-main payload, then
prints (allowed) for the ordinary push, proving the guard blocks the dangerous case
and stays out of the way otherwise:
→ force push to main (expect deny):
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Blocked by pr-prep: force-pushing to 'main' rewrites shared history. Push to a feature branch and open a PR, or run the command outside Claude if you are certain."
}
}
→ normal push (expect no output / allow):
(allowed)
make test-diff output depends on where you run it. On a feature branch with commits
past main, it prints the git diff --stat and one - <subject> bullet per commit.
Run from main itself (or any branch with no commits beyond the base) it prints
No commits on this branch beyond 'main'.; outside a git repository it prints
(not inside a git repository); and against a base branch that does not exist it
prints the (base branch '...' not found ...) hint.
Step 10: Test the plugin locally with --plugin-dir
The fastest development loop skips installation entirely. Launch Claude Code with the plugin loaded directly from disk:
claude --plugin-dir ./plugins/pr-prep
Inside that session:
- Run
/pr-prep:summary(optionally with a base branch, e.g./pr-prep:summary develop). Claude Code runs the helper script through the dynamic block and returns a drafted PR description. - Ask Claude to “use the pr-reviewer agent to review this branch” to exercise the subagent.
- Try to have Claude run
git push --force origin main. The hook denies it with the reason string; a push to a feature branch goes through.
Run /help to confirm the command is listed under the plugin namespace and
/agents to confirm the subagent appears. As you edit the files, run
/reload-plugins in the session to pick up changes without restarting. To test the
packaged archive instead of the directory, run make pack and load the zip:
claude --plugin-dir ./dist/pr-prep.zip
Step 11: Install from the local marketplace
--plugin-dir loads a single plugin. To exercise the full install path the way your
users will, add the marketplace and install from it. Point the marketplace add at the
repo root (the directory above .claude-plugin/):
claude plugin marketplace add ./
claude plugin install pr-prep@dev-workflow-tools
claude plugin marketplace add accepts a local path, a git URL, or a GitHub
owner/repo shorthand. The equivalent in-session commands are /plugin marketplace add and /plugin install. Inspect what got installed:
claude plugin details pr-prep
The details view reports the plugin’s component inventory and its token cost. For
pr-prep it lists the summary command, the pr-reviewer agent, and one
PreToolUse hook:
pr-prep 1.0.0
...
Component inventory
Skills (1) summary
Agents (1) pr-reviewer
Hooks (1) PreToolUse (harness-only — no model context cost)
MCP servers (0)
LSP servers (0)
The inventory groups slash commands under the Skills line rather than a separate
“Commands” row, so seeing Skills (1) summary for a file you authored under
commands/ is expected, not a mistake. The hook is marked harness-only: it runs as
a shell script on an event and costs no model context, unlike the command and agent,
which are paid for when they fire.
To remove the local test install before publishing for real:
claude plugin marketplace remove dev-workflow-tools
Removing a marketplace from its last scope also uninstalls the plugins you installed from it, so this cleans up in one step.
Step 12: Distribute through GitHub
Publishing is just pushing the repository. The marketplace.json at the root is what
turns a plain repo into an installable marketplace.
Create the file
git init
git add .
git commit -m "pr-prep plugin and marketplace v1.0.0"
git branch -M main
git remote add origin git@github.com:your-org/pr-prep-marketplace.git
git push -u origin main
Detailed breakdown
What consumers run. Once the repo is public, anyone adds your marketplace and installs the plugin with two commands:
/plugin marketplace add your-org/pr-prep-marketplace /plugin install pr-prep@dev-workflow-toolsThe GitHub
owner/reposhorthand is the marketplace source. Because the plugin entry uses a relativesource, Claude Code clones the repo and resolves./plugins/pr-prepinside its local copy.Releasing an update. Change a component, bump
versionto1.1.0in bothplugin.jsonandmarketplace.json(the validator warns if they drift), and push. Users pull the new version with/plugin marketplace update dev-workflow-tools. If you had omittedversion, every commit would register as a new version instead — convenient for a fast-moving internal tool, noisy for a public release.Private distribution. To keep a plugin internal, host the marketplace repo privately. Teammates add it with the same command as long as their git credentials grant access; Claude Code uses the local git configuration to clone.
Community submission. To list in Anthropic’s public community marketplace, run
claude plugin validate .locally (the review pipeline runs the same check) and submit through the in-app form. Approved plugins are pinned to a commit and synced into the community catalog.
Recap
You built a distributable Claude Code plugin that uses three surfaces at once:
- A slash command (
commands/summary.md) that shells out to a bundled script via a dynamic```!block and drafts a PR description from the branch diff. - A subagent (
agents/pr-reviewer.md) with a scoped, read-only tool set and an explicit review contract. - A hook (
hooks/hooks.jsonplus its script) that inspects everyBashcommand onPreToolUseand denies a force-push to a protected branch. - A plugin manifest and a marketplace catalog that name, version, and publish
the whole thing behind
/plugin install pr-prep@dev-workflow-tools.
The layering is the point: the components are the behavior, the plugin makes them
shareable and versioned, and the marketplace makes them discoverable. A consumer goes
from nothing to your command, agent, and guard with /plugin marketplace add and
/plugin install.
Troubleshooting
- “Plugin not found in any marketplace.” The marketplace is missing or stale. Run
claude plugin marketplace update dev-workflow-tools, or re-add it withclaude plugin marketplace add ./. Confirm the marketplacenamein the install command matchesmarketplace.json. - Command or agent does not appear after install. Run
/reload-plugins, then/helpand/agents. Check thatcommands/andagents/sit at the plugin root, not inside.claude-plugin/— the most common layout mistake. - The
!block in the command does not run. Confirm it uses three backticks (```!) and that${CLAUDE_PLUGIN_ROOT}is spelled exactly. The block runs at invocation; check the script alone withmake test-diff. - The hook never fires. A malformed
hooks.jsonis loaded silently. Runmake validate-plugin, and launch withclaude --debugto see hook registration and each invocation. Confirmjqis installed, since the script depends on it. - The hook blocks a push it should allow (or vice versa). The match is on the literal
command string. A branch literally named
main-ishcontainsmain, so it would match; tighten thecasepatterns for your naming if needed. Verify decisions withmake test-hookbefore trusting them live. - Relative source fails for remote users. A relative
sourceneeds the whole repo cloned, so it works from a git or path marketplace but not from a marketplace added as a direct URL tomarketplace.json. Use agithub,git, ornpmplugin source for URL-based distribution.
Next improvements
- Add a skill to the same plugin by creating
skills/<name>/SKILL.md. No manifest change is required; it installs alongside the command, agent, and hook. - Bundle an MCP server by adding
.mcp.jsonat the plugin root, so the plugin can ship a tool server together with the commands that drive it. See the companion article Bundle an MCP Server in a Claude Code Plugin. - Wire validation into CI so
claude plugin validate . --strictruns on every push and blocks a broken manifest from reaching users. - Pin the plugin
sourceto a tag or SHA inmarketplace.jsonfor reproducible installs across a team. - Extend the guard to cover
git reset --hardon shared branches or aSessionStarthook that prints the current branch and its base, using the samehooks.jsonfile.