How to Build a Claude Code Plugin
A hands on guide to packaging your Claude Code workflow as a plugin. We build a real conventional-commits helper with a slash command and an auto-invoked skill, then publish it through a marketplace and version it for release.
Once you have used Claude Code for a while you start to collect rituals: the way you phrase a commit, the checklist you paste before a release, the prompt you keep re-typing to summarize what changed. A plugin is how you bottle those rituals up and hand them to the rest of your team, or to yourself on a different machine, without copy and paste.
A Claude Code plugin is just a folder of components that extends the tool: slash commands, skills, agents, hooks, and MCP servers, distributed through a marketplace so other people can install it with one line. In this article we are going to build a small but genuinely useful one called git-flow. It bundles two things: a /commit command that writes a Conventional Commit from your staged diff, and a changelog skill that Claude reaches for on its own when it is time to cut a release. By the end you will have published it through a marketplace and versioned it for an actual release.
What lives inside a plugin
Every plugin is a directory with one required file, the manifest at .claude-plugin/plugin.json. Everything else is optional and sits at the root of the plugin, not inside .claude-plugin/. That distinction trips people up, so it is worth saying plainly: only the manifest goes in .claude-plugin/. Your commands/ and skills/ folders live one level up.
Here is the shape of the plugin we are building:
git-flow/
├── .claude-plugin/
│ └── plugin.json
├── commands/
│ └── commit.md
└── skills/
└── changelog/
└── SKILL.md
The manifest itself only insists on one field, name, which must be kebab-case because it becomes the namespace for everything the plugin ships. The rest are metadata that show up in the /plugin interface and help people decide whether to trust your code:
{
"name": "git-flow",
"version": "0.1.0",
"description": "Conventional Commits helper: a /commit command and an auto-invoked changelog skill.",
"author": {
"name": "Frank Perez",
"email": "frank@fjp.io"
},
"homepage": "https://github.com/frankperez87/git-flow",
"license": "MIT"
}
By default Claude Code discovers a commands/ folder and a skills/ folder automatically, so for a layout this conventional you do not need to point at them from the manifest. You only add "commands" or "skills" path keys when your files live somewhere non-standard. Note that setting those keys replaces the default folder rather than adding to it, so if you do override one, list every path you want scanned.
Adding a slash command
A command is the simplest component there is: a single Markdown file whose body is a prompt. The frontmatter describes the command, and the body is what Claude runs when you type the slash command. Create commands/commit.md:
---
description: Write a Conventional Commit from the staged diff
argument-hint: [scope or intent hint]
allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git diff:*), Bash(git commit:*)
---
Look at the staged changes and write a single Conventional Commit.
1. Run `git diff --staged` to see what is actually staged. If nothing is
staged, run `git status` and ask me what to include before continuing.
2. Infer the right type (feat, fix, docs, refactor, test, chore) and an
optional scope from the files that changed.
3. Write a concise, imperative subject line under 72 characters. Add a short
body only when the change genuinely needs explaining.
4. Treat anything in "$ARGUMENTS" as a hint about the scope or intent.
5. Show me the proposed message, and run `git commit` once I approve it.
Three things are doing the work here. The description is what shows up in the /help list and the plugin UI. The allowed-tools line scopes which Bash invocations the command may run without prompting you each time, which keeps a git helper from being able to touch anything else. And $ARGUMENTS is the placeholder that captures whatever you type after the command name, so /git-flow:commit api error handling drops api error handling straight into step four.
That namespace prefix is automatic. Because the plugin is named git-flow, the command is invoked as /git-flow:commit, never just /commit. Namespacing is what lets two different plugins both ship a commit command without colliding.
Adding a skill
A command is something you invoke. A skill is something Claude invokes, on its own, when the situation calls for it. That is the whole distinction, and it is the reason a plugin usually wants both. You do not want to remember to type /git-flow:changelog at release time. You want to say "let's cut a release" and have the right capability load itself.
Under the hood these two have actually converged: recent Claude Code versions merged custom commands into skills, so a commands/foo.md file and a skills/foo/SKILL.md both produce /foo, and a command is really just a skill that only you can trigger. Plugins still support both directories, which makes the split a handy way to organize: flat files in commands/ for the things you invoke, folders in skills/ for the things Claude should reach for on its own.
Skills are directories rather than flat files, because they can carry supporting material alongside the instructions. The entry point is always SKILL.md. Create skills/changelog/SKILL.md:
---
name: changelog
description: Assembles release notes from Conventional Commits. Use when the user asks to cut a release, write a changelog, or summarize what changed since the last tag.
---
# Changelog
Build a human-readable changelog from the Conventional Commits since the
last release.
## Steps
1. Find the most recent tag with `git describe --tags --abbrev=0`. If there
is no tag yet, use the full history.
2. Collect commit subjects with `git log <last-tag>..HEAD --pretty=format:'%s'`.
3. Group them by Conventional Commit type:
- `feat` becomes Added
- `fix` becomes Fixed
- `refactor` and `perf` become Changed
- everything else becomes Other
4. Drop the type prefix from each subject and write it as a bullet, keeping
the scope in parentheses when one is present.
5. Output Keep a Changelog style Markdown under a version heading, and suggest
the next semantic version from the commit types: a `feat` implies a minor
bump, a breaking change implies a major one.
The magic is in the description. Claude Code uses a progressive-disclosure model: at the start of a session it only loads each skill's name and one-line description, not the whole body. That is cheap, so a session can have dozens of skills available without flooding the context. When your request matches a description, Claude pulls the full SKILL.md into context and follows it. So the description is not flavor text, it is the trigger. Write it as "use when..." and name the phrases a user would actually say.
This is also why the changelog skill pairs so well with the commit command. The command enforces Conventional Commits going in, and the skill relies on that structure coming out. The two halves of the plugin reinforce each other.
The finished plugin
Put together, the whole plugin is three files. The manifest names it, the command gives you an on-demand action, and the skill gives Claude an automatic one:
git-flow/
├── .claude-plugin/
│ └── plugin.json # name, version, metadata
├── commands/
│ └── commit.md # /git-flow:commit, you invoke it
└── skills/
└── changelog/
└── SKILL.md # changelog, Claude invokes it
There is no build step and no compilation. A plugin is plain text that Claude Code reads, which makes it easy to review before you install it and easy to keep in version control.
Testing it locally
Before you hand a plugin to anyone, validate it. The CLI checks the manifest, the command and skill frontmatter, and any hook files for syntax and schema problems:
claude plugin validate ./git-flow
claude plugin validate ./git-flow --strict
The --strict flag promotes warnings (like an unrecognized manifest field) to errors, which is what you want in CI. To actually try it, load the plugin into a single session straight from disk, no marketplace required:
claude --plugin-dir ./git-flow
Now /git-flow:commit is live, and asking Claude to draft release notes should pull in the changelog skill. As you edit the files, run /reload-plugins inside the session to pick up your changes without restarting. This local loop is where you should spend most of your time: tighten the command's instructions, sharpen the skill's description until it triggers on the phrases you expect, and only then think about distribution.
Publishing through a marketplace
A plugin is a folder; a marketplace is a catalog that tells Claude Code where a set of plugins lives. It is a single file, .claude-plugin/marketplace.json, and it needs three things: a name, an owner, and a plugins array. Each entry in that array needs a name and a source.
The simplest setup is one repository that holds both the marketplace file and the plugin, so the source is just a relative path:
{
"name": "frank-claude-tools",
"owner": {
"name": "Frank Perez",
"email": "frank@fjp.io"
},
"plugins": [
{
"name": "git-flow",
"source": "./git-flow",
"description": "Conventional Commits helper for Claude Code."
}
]
}
That gives a repository layout like this:
frank-claude-tools/
├── .claude-plugin/
│ └── marketplace.json
└── git-flow/
├── .claude-plugin/
│ └── plugin.json
├── commands/
│ └── commit.md
└── skills/
└── changelog/
└── SKILL.md
A relative path is not the only option. When the plugin lives in its own repository, point at it with a github source instead, optionally pinning a branch or tag with ref and an exact commit with sha:
{
"name": "git-flow",
"source": {
"source": "github",
"repo": "frankperez87/git-flow",
"ref": "v0.1.0"
}
}
Once the marketplace is pushed to GitHub, anyone adds it once and then installs the plugins they want:
/plugin marketplace add frankperez87/frank-claude-tools
/plugin install git-flow@frank-claude-tools
The git-flow@frank-claude-tools form is why marketplace and plugin names both have to be unique: together they address exactly one plugin. By default the install is personal to you. To make a plugin part of a project so the whole team gets it on checkout, install it into the project scope, which records it in the repo's .claude/settings.json:
/plugin install --scope project git-flow@frank-claude-tools
Versioning and releases
How updates reach your users comes down to one decision: whether you set a version in plugin.json. There are two strategies, and they suit different situations.
Set an explicit version and you have pinned a release. Users get the new code only when you bump that number, so pushing a quick fix to your repository does nothing until you also raise the version. That is the right default for anything other people depend on, paired with normal semantic versioning: bump the patch for a fix, the minor for a new command or skill, the major for a breaking change. If a version is set in both the manifest and the marketplace entry, the manifest wins.
Omit version entirely and Claude Code falls back to the git commit SHA, which means every commit is treated as a new version. That is ideal while you are iterating on an internal tool and want teammates to always run the latest main, with no release ceremony at all.
Either way, shipping an update is just pushing to the repository. Users pull the newest catalog with /plugin marketplace update, and a pinned plugin then upgrades to whatever version you have published. Cutting a release of git-flow, then, is three steps: raise the version in plugin.json, ask Claude to run the changelog skill so the release notes write themselves from your Conventional Commits, and push. The plugin you built ends up maintaining itself.