BUILD · PHASE 09 · LESSON 63

Install from a Marketplace, and Author One

You can build a three-component plugin in one sitting, load it from disk

Type
Build
Prerequisites
Lesson 62, Plugin Anatomy; and the components themselves —
Time
~45 minutes

Learning Objectives

  • Build a plugin directory holding a skill, an agent, and a hook, in the layout from Lesson 62
  • Validate it from the shell before you ever start a session with it
  • Load it with --plugin-dir, reload edits with /reload-plugins, and read the error surface when it doesn't load
  • Publish it through a local marketplace.json so a teammate installs it by name

The Problem

You have the three files. You move them into a folder, run claude plugin install — except there is nothing to install from, because the plugin isn't in any marketplace, it's just a directory on your laptop. So you pass --plugin-dir, it loads, and the hook doesn't fire. You check the JSON. It's correct. You check the script by hand — it blocks. In the session, nothing happens.

Twenty minutes later: the hook is loaded, but the matcher is on Bash and the session's tool is named something else, or the script path is ${CLAUDE_PLUGIN_ROOT}/scripts/... written in shell form without quotes and Windows ate it. The session never says. The debug log does.

Or the other one: you paste the hook into settings.json "just to test it" and forget to remove it. Now the plugin's hook and the settings hook both fire, and the doubled output reads as a broken plugin.


The Concept

There are three ways to get a plugin in front of a session, and they differ only in who decides when it's ready.

RouteLoads whenWho runs it
--plugin-dir ./xThat one sessionYou, every time
Plugin in ~/.claude/skills/Every session, no flagNobody — it just loads
Marketplace entrySessions where it's installedA teammate, by name

Author loop: --plugin-dir → validate → reload. Build, validate from the shell, load for one session, edit, /reload-plugins. Nothing is installed while you're developing, so there is no half-installed state to clean up.

Debug loop: four places, in this order. When an edit doesn't show up:

  1. claude plugin validate <path> — manifest and frontmatter
  2. /reload-plugins — did the session pick up your disk edits
  3. /plugin → Errors tab — what failed to load and why
  4. claude plugin list (shell) — Status: loaded or the load error

The hook case needs one more: trigger the event and read the hook debug log. A hook that is loaded and never matched looks exactly like a hook that isn't loaded.

The rules that cost you the twenty minutes

Paths are not yours. A hook calls its script through ${CLAUDE_PLUGIN_ROOT}, which expands to the plugin's installed directory. Under --plugin-dir that's your folder. Use exec form (command + args) so the substituted path stays one argument; if you must use shell form, quote it.

Hooks are the only component with a wrapper key. hooks/hooks.json wraps the event map in "hooks":. A bare event map fails to load. Lesson 62 covered this; it is the single most common first-plugin failure.

Hooks have no namespace prefix. A copy in settings.json and the plugin's copy both fire. While converting your existing setup, leave the originals in place until the plugin version is verified — then delete them, or live with every block firing twice.

Skills and agents get renamed. A skill that was /review becomes /guardrail-kit:review. /deploy and /guardrail-kit:deploy both work while both exist; they stop being the same agent once you delete the original.

What a marketplace actually is

A directory or repo with .claude-plugin/marketplace.json, listing plugins and where to fetch each. Not a hosted store — you add it once, then its plugins install by name. You need one only to share. A teammate with your folder on a shared drive can just as easily copy the directory.

The reframe: a plugin has four places to fail — the manifest, the layout,

the path wiring, and the load itself — and exactly one of them will tell you

so, so the whole authoring loop is really just "find which of the four it

was" in the order cheapest to check.


Build It

Everything below runs from code/ in this lesson's folder. A complete plugin is there as the answer key; do these steps yourself before comparing.

1. Create the directory and the manifest

cd code
mkdir -p guardrail-kit/.claude-plugin

Write guardrail-kit/.claude-plugin/plugin.json:

{
  "name": "guardrail-kit",
  "description": "Blocks dangerous git operations and hands diff review to a subagent",
  "version": "1.0.0",
  "author": { "name": "You" }
}

✅ You pass when claude plugin validate ./guardrail-kit prints Validation passed.

2. Add a skill

mkdir -p guardrail-kit/skills/review-diff

guardrail-kit/skills/review-diff/SKILL.md:

---
name: review-diff
description: Review the current diff for lost work, secrets, and scope creep before committing.
disable-model-invocation: true
---

Review the working-tree diff for three things: lost work, secrets, scope.
Report file paths. End with `SAFE TO COMMIT` or `BLOCKED: <reason>`.
Do not edit files.

Note the directory — skills/<name>/SKILL.md. One directory per skill, not a flat .md file.

✅ You pass when claude plugin validate still passes and the file is at skills/review-diff/SKILL.md, not skills/review-diff.md.

3. Add a subagent

guardrail-kit/agents/diff-scout.md:

---
name: diff-scout
description: Reads one diff and reports what changed. No fixes, no style opinions.
tools: Read, Grep, Glob, Bash
model: sonnet
---

Report file paths and a one-line factual description per file.
Never propose a fix. Never edit a file.

The restricted tools list is the point: this agent reads and reports. It cannot edit, so it cannot quietly "help".

✅ You pass when claude plugin validate passes and the agent's frontmatter has no Edit or Write in tools.

4. Add the hook

guardrail-kit/hooks/hooks.json — note the wrapper:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/block-dangerous-git.mjs\""
          }
        ]
      }
    ]
  }
}

And guardrail-kit/scripts/block-dangerous-git.mjs:

#!/usr/bin/env node
// PreToolUse hook: reads tool-call JSON on stdin, exits 0 to allow,
// 2 to block and show stderr to the model.
import { createInterface } from 'node:readline';

const DENY = [
  { re: /\bgit\s+push\b[^|;&\n]*(--force\b(?!-with-lease)|\s-f\b)/,
    why: 'force push overwrites a branch others may already have based work on. Use --force-with-lease.' },
  { re: /\bgit\s+reset\b[^|;&\n]*--hard\b/,
    why: 'reset --hard discards uncommitted work with no undo. Commit or stash first.' },
];

const rl = createInterface({ input: process.stdin });
let raw = '';
for await (const chunk of rl) raw += chunk;

let toolInput = {};
try { toolInput = JSON.parse(raw || '{}').tool_input ?? {}; }
catch { process.exit(0); }   // unparseable stdin is not our call to make

const normalized = String(toolInput.command ?? '').replace(/\\\r?\n/g, ' ');
const hit = DENY.find(({ re }) => re.test(normalized));
if (hit) {
  process.stderr.write(`guardrail-kit blocked this command: ${hit.why}\n`);
  process.exit(2);
}
process.exit(0);

✅ You pass when you can run the script by hand and watch it exit 2:

echo '{"tool_input":{"command":"git push --force origin main"}}' \
  | node guardrail-kit/scripts/block-dangerous-git.mjs
echo $?

5. Load it without installing

claude --plugin-dir ./guardrail-kit

In the session: type / and look for /guardrail-kit:review-diff. Run /plugin → Installed, find guardrail-kit, open its details and confirm the inventory lists one skill, one agent, one hook.

✅ You pass when /guardrail-kit:review-diff runs and /plugin lists all three components.

6. Prove the guardrail blocks

In that session, ask the agent to push your branch with force. It should refuse and quote the hook's stderr. Then try a normal git status — that must go through.

✅ You pass when the force push is refused with your hook's wording and the harmless command is not.

7. Edit and reload

Change the wording in the script's why string. Save. In the same session:

/reload-plugins

Ask again. New wording.

✅ You pass when the new wording appears without restarting the session.

8. Publish it as a marketplace

code/marketplace/.claude-plugin/marketplace.json:

{
  "name": "workflowtest-local",
  "owner": { "name": "You" },
  "plugins": [
    { "name": "guardrail-kit", "source": "./guardrail-kit" }
  ]
}

Note source is relative to the marketplace directory, so it reads ../guardrail-kit from here — fix the path so it resolves, then:

/plugin marketplace add ./code/marketplace
/plugin install guardrail-kit@workflowtest-local

Choose a scope. Then /plugin install guardrail-kit@workflowtest-local again in a different session without --plugin-dir and confirm the command is there.

✅ You pass when a session you did not pass --plugin-dir to still offers /guardrail-kit:review-diff, and claude plugin list shows it with a scope.

9. Break it deliberately

Move skills/ inside .claude-plugin/. Validate. Reload. Now the plugin loads with its skill missing and /plugin → Errors names the path.

✅ You pass when you have read the exact Errors-tab line, then put skills/ back and confirmed the skill returns.


Use It

Command / conceptWhen you reach for it
claude plugin validate <path>Before every load while authoring
claude --plugin-dir <path>Testing your plugin for one session
/reload-pluginsYou edited files on disk mid-session
/plugin → ErrorsSomething loads but a component is missing
claude plugin listWhich plugins are loaded, from where, at what scope
/plugin marketplace add <source>Sharing your plugin by name
/plugin install <name>@<marketplace>Installing, with a scope choice
claude plugin install/disable/enable/uninstallThe same, from a script, no session
~/.claude/skills/<name>/ with a manifestA plugin you want in every session, forever
"hooks": { ... } wrapperEvery plugin hooks file
${CLAUDE_PLUGIN_ROOT}Locating bundled scripts — never for state

Exercises

  1. Add a second skill to the plugin. Name it ship. Reload. Confirm both /guardrail-kit:review-diff and /guardrail-kit:ship exist and that neither appears without the prefix. ✅ You pass when you can run both, and can say what the unprefixed version would have been called as a loose skill.
  1. Copy the whole plugin to a scratch folder, delete .claude-plugin/plugin.json, and load it with --plugin-dir. It should still work, named after the directory. ✅ You pass when the skill and agent still run and you can state where the name now comes from — and which name prefix the components now carry.
  1. Add a third rule to DENY for git rebase on a shared branch. Then test all four of these by piping to the script and reporting each exit code: git push --force-with-lease, git push --force, git reset --soft HEAD~1, git reset --hard HEAD~1. ✅ You pass when --force-with-lease and --soft exit 0, and you can point at the exact regex difference that separates the allowed form from the blocked one.
  1. Install your plugin at user scope, then at project scope, and open .claude/settings.json and .claude/settings.local.json to see where each landed. Then commit a change and note what that does for a collaborator. ✅ You pass when you can say which file each scope writes and what a collaborator still has to run themselves.

Key Terms

TermWhat people sayWhat it actually means
--plugin-dir"install it"Loads a directory for one session. Nothing is written to settings. The authoring loop.
marketplace"the store"A repo or directory with .claude-plugin/marketplace.json. You add it once; its plugins install by name.
name in plugin.json"the label"The required prefix on every skill and agent the plugin provides.
scope"where it's installed"user (all your projects), project (committed .claude/settings.json, everyone), local (this repo, you).
validate"test it"Checks the manifest and every component's frontmatter, from the shell, before load.
/reload-plugins"refresh"Picks up disk edits in a running session. May decline if it would invalidate the prompt cache.
Errors tab"the error log"/plugin → Errors: what failed to load and why. Silent failures are listed here, nowhere else.
${CLAUDE_PLUGIN_ROOT}"the install path"The plugin's installed directory. Moves on update — never write state there.

What this lesson skipped

Publishing to Anthropic's directory. Submitting for public listing, review, and the listing fields (icon, homepage, the four URL fields). Real process, separate page in the docs. Come back when you actually want strangers to find it.

Plugin dependencies. "dependencies": ["other-plugin"] makes Claude Code install and enable the other plugin too, and refuse to disable one that something still needs. Come back when you split a plugin in two.

userConfig. Prompting the user for values — an endpoint, a masked token — at install time. This is the mechanism that lets a plugin ship an MCP server needing credentials. Worth a whole pass; not needed by a plugin that runs a local script.

Bundled MCP servers. .mcpb / .dxt bundles inside a plugin. Phase 07 owns the server; this phase only needed to show it rides along.

Auto-update. Marketplace plugins from the official marketplace refresh on their own; yours and third-party ones do not, by default. One line in /plugin → Marketplaces when you want it, and a Phase 03 cost conversation.

Check yourself

3 questions. Answer before you reveal — recognising the right option is not the same as being able to produce it.

  1. Your plugin loads under claude --plugin-dir ./guardrail-kit and its skill runs, but the PreToolUse hook never fires. You run the script by hand with piped stdin and it exits 2 correctly. claude plugin validate passes. Where do you look next, and why?

    1. Reinstall the plugin through a marketplace — --plugin-dir runs hooks in a sandbox where PreToolUse never fires
    2. Check the hook debug log after triggering the event: a loaded hook whose matcher never matched looks identical to a hook that isn't loaded
    3. Add disable-model-invocation: false to the hook's frontmatter so the harness schedules it
    4. Move hooks/hooks.json into .claude-plugin/ so it is discovered alongside the manifest
    Show answer

    B. Check the hook debug log after triggering the event: a loaded hook whose matcher never matched looks identical to a hook that isn't loaded

    Validate passing plus the script behaving correctly by hand narrows the failure to wiring, not logic — and the wiring check is the debug log, which reports which hooks matched, their exit codes, and their output. --plugin-dir loads hooks exactly as an installed plugin does (A is false). Hooks have no frontmatter and no model-invocation flag (C invents a field). Moving the file into .claude-plugin/ is the one place hooks definitely do not load (D), and it would have broken validate, which passed.

  2. You converted your .claude/settings.json setup into a plugin. Skills and agents both work, so you deleted the originals from .claude/. Now a teammate reports every blocked git command prints its refusal twice. What is happening?

    1. The plugin is installed twice, once per component directory, so each hook fires twice
    2. You moved the hooks but left the original hooks block in .claude/settings.json, and plugin hooks carry no namespace prefix to de-duplicate them
    3. The matcher "Bash" matched two tools — the shell tool and the plugin's own script runner — so each hook ran twice
    4. Exit code 2 causes Claude Code to retry the PreToolUse check once before surfacing the block
    Show answer

    B. You moved the hooks but left the original hooks block in .claude/settings.json, and plugin hooks carry no namespace prefix to de-duplicate them

    Skills and agents get a guardrail-kit: prefix, so the two sets coexisted harmlessly and deleting the originals was correct. Hooks get no prefix and no conflict detection — the leftover block in settings.json and the plugin's copy both match PreToolUse on Bash, so both run. Plugins are installed once per source, not per component directory (A), a matcher names tool types rather than counting them (C), and exit 2 blocks without retrying (D). This is the exact failure the convert-and-keep-originals order exists to prevent: verify the plugin's hooks work before deleting the old ones.

  3. A teammate on another machine needs your three-component setup. The directory sits in a repo you both have read access to, it will change every few weeks, and neither of you wants to maintain an install step. What is the smallest thing that works?

    1. They clone the repo and run claude --plugin-dir ./guardrail-kit in each session themselves
    2. They add your repo as a marketplace once, then install and update the plugin by name as it changes
    3. You zip the plugin and send a new zip every time anything changes
    4. They copy the plugin folder into their own ~/.claude/skills/ and update it with git pull
    Show answer

    B. They add your repo as a marketplace once, then install and update the plugin by name as it changes

    A marketplace entry lists the plugin and where to fetch it; they add it once, and from then on claude plugin install name@marketplace plus an update flow handles every change — which is exactly the drift problem plugins exist to solve. --plugin-dir (A) makes them remember a flag per session and never picks up changes, so it recreates the manual step you were trying to remove. Repackaging a zip (C) makes you the update mechanism. Pulling into ~/.claude/skills/ (D) works mechanically and is a fine solo trick, but it puts an unmanaged copy of a versioned plugin outside any version tracking, and it hardcodes an assumption about where their plugins live.