A Claude Code skill is a SKILL.md file plus optional supporting files that
teach Claude a repeatable procedure. Kept in ~/.claude/skills/ or a project’s
.claude/skills/, a skill is personal or project-local. To hand one to a
teammate, ship it across every project, or publish it to the community, you wrap
it in a plugin and list that plugin in a marketplace that others can add
with one command.
This tutorial builds a real skill, packages it as a plugin, catalogs it in a
marketplace, validates the manifests with the claude CLI, tests it locally, and
distributes it through GitHub. The end result is a repository anyone can install
with /plugin marketplace add your-org/your-repo followed by /plugin install.
What you will build
A plugin named changelog-helper that adds one skill, /changelog-helper:changelog.
The skill reads the commits since your last release tag, then asks Claude to draft
a grouped changelog entry. It demonstrates the three features that make a skill
worth packaging: a bundled helper script, dynamic context injection, and
per-skill tool permissions.
The finished repository doubles as a marketplace, so the same git repo both defines the plugin and distributes it:
changelog-marketplace/
├── .claude-plugin/
│ └── marketplace.json # Catalog: lists the plugin and its source
├── plugins/
│ └── changelog-helper/
│ ├── .claude-plugin/
│ │ └── plugin.json # Plugin manifest: name, version, metadata
│ └── skills/
│ └── changelog/
│ ├── SKILL.md # The skill itself
│ └── scripts/
│ └── commits-since-tag.sh
├── Makefile # validate / pack / test targets
└── .gitignore
Prerequisites
- macOS (the commands are macOS-first; they work on Linux unchanged).
- Claude Code v2.1.128 or later. Namespaced plugin skills,
claude plugin validate, and zip-based--plugin-dirall need a recent build. This tutorial was validated on v2.1.207. Check yours withclaude --version; upgrade withclaude updateif needed. - git — the marketplace is distributed as a git repository, and the skill’s helper script shells out to git.
- 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 one POSIX shell script, so consumers install it with zero dependencies.
How the three layers relate
Skills, plugins, and marketplaces are nested, not competing:
- A skill is the unit of behavior: one
SKILL.mdand its supporting files. - A plugin is the unit of distribution: a directory with a
.claude-plugin/plugin.jsonmanifest that can bundle skills, agents, hooks, and MCP servers. Plugin skills are namespaced as/plugin-name:skill-name, so they never collide with a user’s own skills. - A marketplace is the unit of discovery: a
.claude-plugin/marketplace.jsoncatalog that points at one or more plugins. Users add the marketplace once, then install any plugin it lists.
You can keep a skill standalone in .claude/skills/ while you iterate, then
promote it to a plugin when it is ready to share. This tutorial starts at the
plugin layer directly, because the goal is distribution.
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 changelog-marketplace/.claude-plugin
mkdir -p changelog-marketplace/plugins/changelog-helper/.claude-plugin
mkdir -p changelog-marketplace/plugins/changelog-helper/skills/changelog/scripts
cd changelog-marketplace
touch .gitignore
Add the code: changelog-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/changelog-helper/holdsplugin.json(the manifest). Only these manifest files go inside a.claude-plugin/directory. Every other directory (skills/,agents/,hooks/) lives at the plugin root, not inside.claude-plugin/. Nesting them wrong is the single most common plugin bug.plugins/subdirectory. Putting the plugin underplugins/keeps the repo tidy and lets the marketplace grow to several plugins later. The marketplace catalog will point at it with a relative path..gitignorefirst. Thedist/folder and*.zipfiles are created by thepacktarget in Step 6. 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 skill
Create the file
touch plugins/changelog-helper/skills/changelog/SKILL.md
Add the code: plugins/changelog-helper/skills/changelog/SKILL.md
---
description: Draft a grouped changelog entry from the commits since the last release tag. Use when preparing release notes, writing a CHANGELOG, or summarizing what shipped.
argument-hint: [version]
allowed-tools: Bash(git tag *), Bash(git log *)
---
# Changelog entry
## Commits since the last release
```!
bash ${CLAUDE_SKILL_DIR}/scripts/commits-since-tag.sh
```
## Your task
Draft a changelog entry for version **$ARGUMENTS** from the commits above.
1. Group the commits under `### Added`, `### Changed`, `### Fixed`, and
`### Removed`. Omit any group that has no entries.
2. Rewrite each commit subject as a user-facing sentence. Drop noise like
"wip", "fixup", and merge commits.
3. Start the entry with a `## $ARGUMENTS` heading. If `$ARGUMENTS` is empty, use
`## Unreleased`.
4. Output only the Markdown entry, ready to paste at the top of `CHANGELOG.md`.
Detailed breakdown
- Frontmatter drives discovery. The
descriptionis the only text Claude sees when deciding whether to load the skill automatically, so it leads with the action and lists trigger phrases (“release notes”, “CHANGELOG”). Put the key use case first: the listing truncates long descriptions. argument-hint: [version]shows[version]in the autocomplete menu, so a user knows to type/changelog-helper:changelog v1.4.0.allowed-toolspre-approves exactly the two git read commands the skill needs, so Claude runs them without a permission prompt each time. It grants access; it does not restrict anything else. Scope it narrowly rather than grantingBash(*).- Dynamic context injection. The
```!fenced block runs before Claude sees the skill. Claude Code executes the script and replaces the block with its output, so the prompt arrives with the actual commit list already inlined.${CLAUDE_SKILL_DIR}resolves to this skill’s directory no matter where the plugin is installed, which is what makes the bundled-script reference portable. $ARGUMENTSexpands to whatever the user types after the skill name. The body handles the empty case explicitly so the skill still produces valid output when invoked with no version.- 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 actualSKILL.md, the```!block uses three backticks.
Step 3: Add the bundled helper script
Create the file
touch plugins/changelog-helper/skills/changelog/scripts/commits-since-tag.sh
chmod +x plugins/changelog-helper/skills/changelog/scripts/commits-since-tag.sh
Add the code: plugins/changelog-helper/skills/changelog/scripts/commits-since-tag.sh
#!/usr/bin/env bash
# Print the commit subjects since the most recent annotated or lightweight tag.
# Falls back to the entire history when the repository has no tags yet.
set -euo pipefail
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 HEAD >/dev/null 2>&1; then
echo "(no commits yet)"
exit 0
fi
last_tag="$(git describe --tags --abbrev=0 2>/dev/null || true)"
if [ -n "$last_tag" ]; then
echo "Commits since ${last_tag}:"
range="${last_tag}..HEAD"
else
echo "No tags found. Showing all commits:"
range="HEAD"
fi
count="$(git log --oneline "$range" | wc -l | tr -d ' ')"
if [ "$count" = "0" ]; then
echo "(no commits in range)"
exit 0
fi
git log --no-merges --pretty=format:'- %s' "$range"
echo
Detailed breakdown
- Why a bundled script. A skill can inline a git command directly, but real logic (tag detection, an empty-range guard, a no-tags fallback) is clearer and testable as a script. Bundling it means the plugin carries its own tooling and works the same on every machine that installs it.
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 two
git rev-parseguards keep the script from erroring in the two states where git commands would abort: outside a repository, and inside a freshly initialized repo that has no commits yet (whereHEADdoes not resolve). Each prints a readable line and exits0. The dynamic-context block inlines whatever the script prints, so a clean message beats a stack trace. git describe --tags --abbrev=0finds the most recent tag reachable fromHEAD. The|| truepreventsset -efrom aborting when there are no tags, and the branch below switches the range to full history.--no-mergesdrops merge commits so the changelog input is just the real work.--pretty=format:'- %s'prints each subject as a Markdown bullet, which is already close to the shape Claude turns into the final entry.- Executable bit.
chmod +xis a convenience; theSKILL.mdinvokes the script with an explicitbash ..., so it runs even if the bit is lost in transit (some zip and copy operations drop it).
Step 4: Write the plugin manifest
Create the file
touch plugins/changelog-helper/.claude-plugin/plugin.json
Add the code: plugins/changelog-helper/.claude-plugin/plugin.json
{
"name": "changelog-helper",
"description": "Draft a grouped changelog entry from commits since the last release tag.",
"version": "1.0.0",
"author": {
"name": "Your Name"
},
"homepage": "https://github.com/your-org/changelog-marketplace",
"license": "MIT"
}
Detailed breakdown
nameis the namespace. Every skill in this plugin is invoked as/changelog-helper:<skill>, andnamesupplies thechangelog-helperprefix. It must be kebab-case with no spaces.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. Setting 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 and helpful for attribution. The other directories the manifest can describe (skills/,agents/,hooks/,.mcp.json) are discovered by convention from the plugin root, so a skills-only plugin needs no extra fields.- What is not here. The manifest does not list the skill. Claude Code finds
skills/changelog/SKILL.mdautomatically because it sits at the plugin root underskills/. You only add explicit component paths when you deviate from the default layout.
Step 5: Write the marketplace catalog
Create the file
touch .claude-plugin/marketplace.json
Add the code: changelog-marketplace/.claude-plugin/marketplace.json
{
"name": "changelog-tools",
"owner": {
"name": "Your Name",
"email": "[email protected]"
},
"metadata": {
"description": "Release and changelog helpers for Claude Code."
},
"plugins": [
{
"name": "changelog-helper",
"source": "./plugins/changelog-helper",
"description": "Draft a grouped changelog entry from commits since the last release tag.",
"version": "1.0.0",
"category": "release",
"tags": ["changelog", "release", "git"]
}
]
}
Detailed breakdown
nameis what users type. Installing this plugin is/plugin install changelog-helper@changelog-tools— plugin name@marketplace name. Each user registers only one marketplace per name, so pick something specific and stable. A set of reserved names (anything resembling an official Anthropic marketplace) is blocked.source: "./plugins/changelog-helper"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 other hosting, a plugin entry’ssourcecan instead be agithub,url,git-subdir, ornpmobject, letting one marketplace list plugins that live in other repositories.- 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 tool access.
Step 6: Add a Makefile for validation and packaging
Create the file
touch Makefile
Add the code: changelog-marketplace/Makefile
# Changelog marketplace — validation and packaging tasks.
PLUGIN_DIR := plugins/changelog-helper
PLUGIN_NAME := changelog-helper
DIST := dist
.DEFAULT_GOAL := help
.PHONY: help validate validate-plugin test-script pack clean
help: ## Show this help screen
@echo "Changelog 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 and its skill files (strict)
claude plugin validate ./$(PLUGIN_DIR) --strict
test-script: ## Run the bundled helper script against this repo
bash $(PLUGIN_DIR)/skills/changelog/scripts/commits-since-tag.sh
pack: ## Build a distributable zip of the plugin for --plugin-dir / --plugin-url
@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 automatically as you add more. validate/validate-plugin. Pointed at the repo root, the validator checksmarketplace.jsonfor schema errors, duplicate plugin names, path traversal in sources, and a version mismatch against each local plugin’splugin.json. Pointed at the plugin directory, it also checks theplugin.jsonand the frontmatter of every skill, agent, command, and hook.--strictturns warnings (unrecognized fields, missing metadata) into a non-zero exit, which is what you want in CI.test-scriptruns the bundled script directly, independent of Claude, so you can confirm it produces sane output before trusting it inside the skill.packzips the plugin’s contents (not the wrapping folder) so the archive’s root holds.claude-plugin/plugin.json. That archive works withclaude --plugin-dir ./changelog-helper.zip(v2.1.128+) and with--plugin-urlwhen hosted at a URL. 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 7: 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 “skill 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. (To see the components Claude Code
detects in the plugin, along with its token cost, use claude plugin details changelog-helper after installing it in Step 9.)
If validation fails, the message names the file and the problem (for example,
plugins[0] plugin.json → missing required field "name"). Fix the named file and
re-run. Confirm the helper script works on its own, too:
make test-script
Its output depends on where you run it. Run from inside a git project with
history and no tags, it prints No tags found. Showing all commits: followed by
one - <subject> bullet per non-merge commit. In a tagged repository it prints
Commits since <tag>: and only the commits after that tag. In a freshly created
folder that is not yet a git repository (before Step 10’s git init) it prints
(not inside a git repository), and in a repo with no commits yet it prints
(no commits yet). The exact subjects depend on your history.
Step 8: 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/changelog-helper
Inside that session, invoke the skill by its namespaced name:
/changelog-helper:changelog v1.0.0
Claude Code runs the bundled script through the dynamic-context block, hands the
commit list to Claude, and Claude returns a drafted ## v1.0.0 changelog entry.
Run /help to confirm the skill is listed under the plugin namespace.
As you edit SKILL.md, 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/changelog-helper.zip
Step 9: 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 changelog-helper@changelog-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. Verify the install:
claude plugin list
The changelog-helper plugin appears, sourced from the changelog-tools
marketplace. Its skill is now available as /changelog-helper:changelog in any
project, not just this repo.
To remove the local test install before publishing for real:
claude plugin marketplace remove changelog-tools
Removing a marketplace from its last scope also uninstalls the plugins you installed from it, so this cleans up in one step.
Step 10: 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 "changelog-helper plugin and marketplace v1.0.0"
git branch -M main
git remote add origin [email protected]:your-org/changelog-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/changelog-marketplace /plugin install changelog-helper@changelog-toolsThe GitHub
owner/reposhorthand is the marketplace source. Because the plugin entry uses a relativesource, Claude Code clones the repo and resolves./plugins/changelog-helperinside its local copy.Releasing an update. Change the skill, bump
versionto1.1.0in bothplugin.jsonandmarketplace.json(the validator warns if they drift), and push. Users pull the new version with/plugin marketplace update changelog-tools. If you had omittedversion, every commit would register as a new version instead, which is convenient for a fast-moving internal tool but 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
claude-communitymarketplace, runclaude 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 skill from the inside out:
- A skill (
SKILL.md) with a description that drives discovery, a bundled helper script, dynamic context injection, and scoped tool permissions. - A plugin (
plugin.json) that namespaces the skill and pins a version. - A marketplace (
marketplace.json) that catalogs the plugin behind an install command. - Validation with
claude plugin validate --strict, local testing with--plugin-dir, a full install through the local marketplace, and distribution by pushing to GitHub.
The layering is the point: the skill is the behavior, the plugin makes it
shareable and versioned, and the marketplace makes it discoverable. A consumer
goes from nothing to your skill 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 changelog-tools, or re-add it withclaude plugin marketplace add ./. Confirm the marketplacenamein the install command matchesmarketplace.json. - Skill does not appear after install. Run
/reload-plugins, then/help. Check thatskills/sits at the plugin root, not inside.claude-plugin/— the most common layout mistake. - Skill loads but the description is empty. The YAML frontmatter is
malformed. Claude Code loads the body with empty metadata, so
/namestill works but auto-invocation fails. Launch withclaude --debugto see the parse error. - The dynamic-context script does not run. Confirm the
```!block uses three backticks and that${CLAUDE_SKILL_DIR}is spelled exactly. The block runs at invocation; check the script alone withmake test-script. - 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,url, ornpmplugin source for URL-based distribution.
Next improvements
- Add a second skill (for example,
bump-version) to the same plugin by creatingskills/bump-version/SKILL.md. No manifest change is required. - 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. - Bundle a hook (
hooks/hooks.json) so the plugin can, say, remind Claude to regenerate the changelog after a release commit.