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 main before 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-dir all need a recent build. This tutorial was validated on v2.1.210. Check yours with claude --version; upgrade with claude update if 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 with brew install jq if which jq comes 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/*.md become slash commands, invoked as /<plugin>:<command>.
  • agents/*.md become subagents you can hand a task to.
  • hooks/hooks.json registers hooks that fire on harness events (a tool call, a prompt, session start).
  • skills/*/SKILL.md become skills (covered in the companion article).
  • .mcp.json registers 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 holds marketplace.json (the catalog). The one inside plugins/pr-prep/ holds plugin.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 under plugins/ keeps the repo tidy and lets the marketplace grow to several plugins later. The marketplace catalog points at it with a relative path.
  • .gitignore first. The dist/ folder and *.zip files are created by the pack target 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

  • name is 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).
  • version controls updates. When version is 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, license are 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 commands or hooks array here. Claude Code finds commands/summary.md, agents/pr-reviewer.md, and hooks/hooks.json by 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. The pr-prep prefix comes from the plugin name; the summary comes from the filename.
  • argument-hint: [base-branch] shows [base-branch] in the autocomplete menu, so a user knows they can type /pr-prep:summary develop to diff against a branch other than main.
  • $1 is 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 to main, 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-tools pre-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 granting Bash(*).
  • 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 actual summary.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 to main when $1 is unset or empty, so it does the right thing whether the command passed no argument or an empty one.
  • set -euo pipefail makes 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}..HEAD range is exactly this branch’s own commits — not everything already merged into the base. --no-merges drops 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 +x is a convenience; the command invokes the script with an explicit bash ..., 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 name is how you address it (the plugin namespace is applied automatically). The description is what Claude reads when deciding whether to delegate a task to this subagent, so it leads with the action and names concrete triggers.
  • tools scopes the subagent’s capabilities. This reviewer needs to read code and run git, so it lists Bash, Read, Grep, Glob and 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 carry Write or Edit.
  • 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 — issue format 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-reviewer a 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 PreToolUse hook receives an object that includes tool_name and tool_input. For the Bash tool, tool_input.command holds 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 with hookSpecificOutput.permissionDecision: "deny" blocks the tool call; the permissionDecisionReason is 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 exiting 0, as the non-match paths do, lets the command proceed normally.
  • Two-stage matching. The first case exits early unless the command is a git 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 -f flag without also matching, say, a branch named hotfix.
  • 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 0 even 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.json is 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 the hooks block in a settings.json, which is why hook config you already use personally ports straight into a plugin.
  • matcher: "Bash" limits the hook to the Bash tool. 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 := help makes a bare make print the target list instead of running the first rule. The grep/awk pipeline 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 checks marketplace.json. Pointed at the plugin directory, it also checks plugin.json and the frontmatter of every command, agent, and hook. --strict turns warnings (unrecognized fields, missing metadata) into a non-zero exit, which is what you want in CI.
  • test-diff and test-hook exercise the two scripts directly, outside Claude. test-hook pipes 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.
  • pack zips the plugin’s contents (note the cd into $(PLUGIN_DIR)), so the archive’s root holds .claude-plugin/plugin.json. That archive works with claude --plugin-dir ./pr-prep.zip (v2.1.128+). The dist/ 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": "[email protected]"
  },
  "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

  • name is 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, a source can instead be a github, git, or npm object.
  • The duplicated version. Listing version both here and in plugin.json is intentional. The validator warns if they disagree, which catches the classic release mistake of bumping one and forgetting the other.
  • category and tags power discovery and search in the plugin manager. They are optional but cheap to add.
  • owner is required. name is mandatory; email is 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 [email protected]: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-tools
    

    The GitHub owner/repo shorthand is the marketplace source. Because the plugin entry uses a relative source, Claude Code clones the repo and resolves ./plugins/pr-prep inside its local copy.

  • Releasing an update. Change a component, bump version to 1.1.0 in both plugin.json and marketplace.json (the validator warns if they drift), and push. Users pull the new version with /plugin marketplace update dev-workflow-tools. If you had omitted version, 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:

  1. 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.
  2. A subagent (agents/pr-reviewer.md) with a scoped, read-only tool set and an explicit review contract.
  3. A hook (hooks/hooks.json plus its script) that inspects every Bash command on PreToolUse and denies a force-push to a protected branch.
  4. 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 with claude plugin marketplace add ./. Confirm the marketplace name in the install command matches marketplace.json.
  • Command or agent does not appear after install. Run /reload-plugins, then /help and /agents. Check that commands/ and agents/ 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 with make test-diff.
  • The hook never fires. A malformed hooks.json is loaded silently. Run make validate-plugin, and launch with claude --debug to see hook registration and each invocation. Confirm jq is 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-ish contains main, so it would match; tighten the case patterns for your naming if needed. Verify decisions with make test-hook before trusting them live.
  • Relative source fails for remote users. A relative source needs the whole repo cloned, so it works from a git or path marketplace but not from a marketplace added as a direct URL to marketplace.json. Use a github, git, or npm plugin 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.json at 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 . --strict runs on every push and blocks a broken manifest from reaching users.
  • Pin the plugin source to a tag or SHA in marketplace.json for reproducible installs across a team.
  • Extend the guard to cover git reset --hard on shared branches or a SessionStart hook that prints the current branch and its base, using the same hooks.json file.