Migrating Claude Code Dotfiles into a Versioned Plugin

Photo by brownpau via Wikimedia Commons (CC BY 2.0)
Create a plugin directory next to the .claude you are replacing, put a plugin.json inside a .claude-plugin folder, then copy skills, agents and commands to the plugin root unchanged. Hooks are the exception, because they live inside settings.json: lift the hooks object out by hand into hooks/hooks.json, which takes the identical shape. Only plugin.json belongs in .claude-plugin, and every component directory sits at the plugin root.
Because the original is still in settings.json. Hook entries merge across settings levels rather than replacing each other, and although the same handler defined in two settings files runs once, a plugin's copy is counted separately from a settings copy. Delete the old block rather than keeping it as a fallback, and use /hooks to see the per-event count and the source label for each handler.
It differs by component, which is why there is no single rule to memorise. A user or project agents file of the same name overrides the plugin's subagent; plugin skills are namespaced as plugin-name:skill-name so both copies stay available; every matching hook runs; and MCP servers resolve local, then project, then user, then plugin. That last order means a leftover user-scope registration outranks the copy inside your plugin.
Anything machine-specific or secret. An absolute path under your home directory breaks on every other machine and produces no validation error, so use CLAUDE_SKILL_DIR, CLAUDE_PLUGIN_ROOT or CLAUDE_PROJECT_DIR instead. Credentials belong in userConfig marked sensitive so each installer supplies their own, and permission rules cannot travel at all, because a plugin settings.json accepts only the agent and subagentStatusLine keys.
Run claude plugin validate on the directory, load it with claude --plugin-dir instead of installing it, then verify each component by its own signal: /skills plus a task described in your own words to prove activation, /hooks for the per-event count, /context for custom agents and /mcp for server status. Finish with claude mcp list, because a server whose old registration still outranks the plugin reports Connected while running the entry you thought you had replaced.

Photo by brownpau via Wikimedia Commons (CC BY 2.0)
Key Takeaway
Migrating a grown Claude Code dotfiles directory into a versioned plugin moves skills, subagents, commands, hooks and MCP servers into one installable bundle. The expensive part is what stays behind: a hook left in settings fires alongside the plugin copy, a user-scope MCP registration outranks the plugin's, and a same-named local subagent file silently wins.
The report printed twice. A PostToolUse hook in this repository checks whether a blog figure fits the section it sits beside, and after I packaged that hook into a plugin it began reviewing every edit two times over. I spent an hour reading the script. The script was fine. I had copied the hook into the plugin and left the original in settings.json, and Claude Code was running both.
That is the shape of this whole migration. Moving the files is an afternoon of cp; the cost sits in the layer you forgot to empty. This post is the file-by-file map from a dotfiles directory into a plugin, the four precedence rules that decide which copy actually runs, and the checks that tell you a component moved rather than quietly disappeared. The mechanics come from the Claude Code documentation; the inventory and the doubled report are mine.
Nothing in a grown dotfiles directory was designed, and you cannot see that until it is a list. Mine was not even one directory. This repository's .claude held four skills, one subagent and a single PostToolUse hook, while my user settings file carried sixteen top-level keys and hook handlers on eight separate events, from PreToolUse through SessionEnd.
# Take the inventory before you move anything. Accretion is invisible until
# it is a list, and mine was spread across two directories, not one.
$ find .claude -maxdepth 3 -type f | sort
.claude/agents/blog-post-author.md
.claude/settings.json
.claude/settings.local.json
.claude/skills/drawing-blog-diagrams/SKILL.md
.claude/skills/enriching-blog-structured-data/SKILL.md
.claude/skills/publishing-blog-posts/SKILL.md
.claude/skills/writing-blog-content/SKILL.md
$ node -e 'const s=require(require("os").homedir()+"/.claude/settings.json");
console.log("keys:", Object.keys(s).length);
console.log("hook events:", Object.keys(s.hooks || {}).join(" "))'
keys: 16
hook events: PreToolUse PostToolUse SubagentStart SubagentStop SessionStart SessionEnd Notification Stop
# And the tell that nothing here was designed: backups I never took deliberately.
$ ls ~/.claude/settings.json*
settings.json settings.json.bak settings.json.bak-20260903-163035Two things in that output changed the plan. The hooks were split across a user file and a project file, so migrating one of them would have left the other firing. And the two backups sitting beside settings.json were not copies I took deliberately, which is the honest argument for the whole exercise: a plugin has a version field, and a hand-edited settings file has timestamped copies of itself.
Most of the move is mechanical and the shapes do not change. A skill is a folder with a SKILL.md inside it before and after; a subagent is a markdown file with frontmatter before and after. Only two rows in this table involve real work, and both are the rows whose source is settings.json rather than a directory.
| In the dotfiles directory | In the plugin | What changes |
|---|---|---|
| skills/name/SKILL.md | skills/name/SKILL.md at the plugin root | Invocation becomes /plugin-name:name. The original keeps answering /name, so both stay available |
| commands/name.md | commands/name.md at the plugin root | Flat markdown keeps working, but skills/ is the recommended shape for anything new |
| agents/name.md | agents/name.md at the plugin root | Loads as plugin-name:name, but a same-named user or project file overrides it |
| The hooks object inside settings.json | hooks/hooks.json | Identical object shape, but it merges with your settings hooks instead of replacing them |
| MCP entries in ~/.claude.json | .mcp.json at the plugin root | Tool names gain a plugin scope, so permission rules written for the old name stop matching |
| Everything else in settings.json | It stays where it is | A plugin settings.json accepts only the agent and subagentStatusLine keys |
| CLAUDE.md and rules files | Nowhere | The documented plugin layout has no slot for a memory file, so these stay in the repository |
# The plugin directory is created NEXT TO the .claude it replaces, so the
# relative copies below resolve and the originals stay available until the
# migration is verified.
mkdir -p blog-toolkit/.claude-plugin blog-toolkit/hooks
cp -r .claude/skills blog-toolkit/ # skills/<name>/SKILL.md keeps its shape
cp -r .claude/agents blog-toolkit/ # agents/<name>.md keeps its shape
# There is no cp for hooks. They live INSIDE settings.json, so the hooks object
# is lifted out by hand into hooks/hooks.json, which takes the identical shape.
blog-toolkit/
├── .claude-plugin/
│ └── plugin.json # ONLY this file belongs in here. Putting skills/,
│ # agents/ or hooks/ inside .claude-plugin/ is the
│ # mistake the documentation calls out by name.
├── skills/
│ ├── writing-blog-content/SKILL.md
│ ├── publishing-blog-posts/SKILL.md
│ └── drawing-blog-diagrams/SKILL.md
├── agents/
│ └── blog-post-author.md
├── hooks/
│ └── hooks.json # the object lifted out of .claude/settings.json
├── scripts/
│ └── review-figures.sh
├── .mcp.json # MCP servers, moved out of ~/.claude.json
└── README.md
// blog-toolkit/.claude-plugin/plugin.json
// name is the only required field, and it is also the namespace: every skill
// in here becomes /blog-toolkit:<skill>. version is optional, but without it
// users do not get an update when you bump the content.
{
"name": "blog-toolkit",
"description": "Skills, subagent and figure hooks for the bilingual blog pipeline",
"version": "1.0.0",
"author": { "name": "Matthews Wong" }
}The manifest is smaller than people expect. Only name is required, and it doubles as the namespace for every skill in the bundle. version is optional, but omitting it means an installer never receives an update when you change the content, so a migration that skips it produces a plugin nobody can upgrade. One structural rule is worth reading twice: only plugin.json goes inside .claude-plugin, and every component directory sits at the plugin root.
The failure here is not an error message. A skill that names an absolute path under your home directory keeps parsing, keeps activating, and then tells Claude to run a script that does not exist on the machine that installed it. Nothing in the bundle is invalid, so no validator objects, and the failure surfaces three steps later as a confusing tool error.
# Wrong: an absolute home path. This worked on the machine it was written on
# and on no other, and nothing warned me — the skill simply told Claude to run
# a script that was not there, and the run failed three steps later.
Run /Users/matthewswong/Documents/matthews-porto-nextjs/scripts/audit.mjs
# Right: the variables Claude Code substitutes in BOTH the skill body and the
# allowed-tools rule, so the same two lines work at personal, project and
# plugin level. Matching them means the script runs without a permission prompt.
---
name: audit-figures
description: Audit every blog figure for size, credit and readability. Use when adding, replacing or auditing an in-article figure.
allowed-tools: Bash(${CLAUDE_SKILL_DIR}/scripts/audit.sh *)
---
Run ${CLAUDE_SKILL_DIR}/scripts/audit.sh to check the figures.
# One trap inside the fix: in a PLUGIN skill, CLAUDE_SKILL_DIR is that skill's
# own subdirectory, not the plugin root. A script shared by three skills goes
# in the plugin's scripts/ folder and is referenced as CLAUDE_PLUGIN_ROOT, and
# anything that must survive a plugin update goes in CLAUDE_PLUGIN_DATA.
// A credential never travels in the plugin. Declare it in plugin.json and let
// each installer supply their own; sensitive keeps it out of display.
{
"userConfig": {
"api_token": {
"type": "string",
"title": "API token",
"description": "Your own token for the deploy API",
"required": true,
"sensitive": true
}
}
}Three variables remove the problem by construction. CLAUDE_SKILL_DIR resolves to the directory holding that SKILL.md, CLAUDE_PLUGIN_ROOT to the plugin's installation directory, and CLAUDE_PROJECT_DIR to the project root — and Claude Code substitutes them in both the skill body and the allowed-tools frontmatter, which is what lets a bundled script run without a permission prompt. Credentials get the other treatment: declare them in userConfig, mark them sensitive, and let each installer supply their own.
Do not try to migrate your permission rules. A plugin settings.json accepts only the agent and subagentStatusLine keys, so allow and deny rules cannot travel in the bundle at all — and the rules you already wrote for an MCP tool stop matching once that server ships inside a plugin, because the scoped tool name gains a plugin prefix. Every call you had allowed starts prompting again.
Hooks do not override each other. The documentation is explicit that hook entries merge across settings levels rather than replacing them, that all matching hooks run in parallel, and that the same handler defined in more than one settings file runs once — but that a plugin's or a skill's copy of the same handler stays separate. That last clause is the doubled report: deduplication never crosses the boundary between a settings file and a plugin.
# The duplicate is a diff, not a mystery. Print both handlers for the event.
$ jq -r '.hooks.PostToolUse[].hooks[].command' ~/.claude/settings.json
node "${CLAUDE_PROJECT_DIR:-.}/scripts/review-blog-figures.mjs" --hook
$ jq -r '.hooks.PostToolUse[].hooks[].command' blog-toolkit/hooks/hooks.json
"${CLAUDE_PLUGIN_ROOT}"/scripts/review-figures.sh
# Two command strings, one event, one matcher. Deduplication applies to the
# same handler across settings FILES; a plugin's copy stays separate. So both
# of these run, in parallel, on every Write and Edit — which is why the figure
# review printed its report twice and I blamed the script for an hour.
# /hooks inside Claude Code is the faster read: it lists every event with a
# count, and labels each handler with its source — User Settings, Project
# Settings, Local Settings, Plugin Hooks or Session Hooks. A count of 2 where
# you migrated 1 is the whole diagnosis.Two cases I could not settle from the documentation, so I stopped guessing. It does not say what happens when two enabled plugins ship an identical handler for the same event, and it does not rank a plugin's own settings.json agent key against a user-level one. The first turns out to matter less than it sounds, because matching hooks run in parallel and there is no order to depend on. For the second the test is cheap: set the key in one place, read what loaded, then set it in the other — slower than a documented rule and far safer than an assumed one.

I could not get to a single bundle, and after trying I stopped wanting to. Half of what accumulated in my configuration is not shareable in principle: a status line command pointing at a script in my home directory, an effort level, a theme, permission rules that reflect how much I let an agent do unattended. Push those into a team plugin and you are pushing your working habits onto four other people.
# The team artefact: a marketplace-installed plugin, enabled in the repository's
# own .claude/settings.json so a teammate gets it by cloning and trusting the
# folder. extraKnownMarketplaces only applies AFTER the workspace trust dialog,
# so a new clone sees no plugin until that prompt is answered.
/plugin marketplace add your-org/claude-marketplace
/plugin install blog-toolkit@your-marketplace
# The personal artefact: a plugin that needs no marketplace and no install.
$ claude plugin init personal-layer
# creates ~/.claude/skills/personal-layer/ with a .claude-plugin/plugin.json
# and a starter SKILL.md. Next session it loads as personal-layer@skills-dir,
# so it can carry its own agents, hooks and MCP servers while staying entirely
# on this machine and out of everyone else's repository.So the migration lands as a team plugin plus a thin personal layer, and the useful discovery is that the personal layer can be a plugin too, with no distribution machinery at all. claude plugin init writes a manifest and a starter SKILL.md into your personal skills directory, and on the next session it loads as name@skills-dir — no marketplace and no install step, but a real manifest, so it can still carry its own agents, hooks and MCP servers. The team half gets versioned and reviewed. The personal half gets to stay messy.
Test with --plugin-dir before you install anything. It loads a plugin straight from a directory, and a local copy takes precedence over an installed plugin of the same name for that session, so you can rehearse an upgrade without uninstalling first. Then run /reload-plugins after each edit — it re-reads skills, agents, hooks and the plugin's MCP and LSP servers without a restart.
A migration that produced no error message has proved nothing. Four of the five component types fail quietly when they fail, so every check has to be positive: name the signal that appears only when the component is genuinely loaded, then go and look for it. The loop that comes before those checks is fixed.
# Then check each component by its own signal, not by the absence of an error.
/skills # is the skill listed, and does it still activate when you
# DESCRIBE the task rather than typing the command? A plugin
# skill answers as /blog-toolkit:writing-blog-content, and the
# un-deleted original still answers /writing-blog-content.
/hooks # count the handlers per event and read the source label
/context # Custom Agents. A leftover .claude/agents/blog-post-author.md
# OVERRIDES the plugin's copy of the same name, so the plugin
# version stays inert until the original is deleted.
/mcp # per-server status: Connected, Needs authentication,
# Failed to connect, Pending approval
$ claude mcp list # every server and its scope
$ claude mcp get pipelines # one server's resolved entry — read this when
# /mcp says Connected but the tools look wrongThen the components themselves, and the phrasing of the skill check matters more than it looks. Typing the slash command proves only that a file exists somewhere; describing the task in your own words is what proves the description still earns activation, and that is exactly what a copy between directories can quietly break. For the rest, /hooks gives a per-event count with a source label, /context lists the custom agents, and /mcp gives a per-server status.

It is the MCP server. When the same server name exists in more than one scope, Claude Code connects to it once, and the order is local, then project, then user, then anything a plugin provides — with the whole entry taken from the highest source and no merging of fields. A user-scope registration in ~/.claude.json therefore outranks the copy you carefully moved into the plugin's .mcp.json. Nothing warns you. The status reads Connected, the tools work, and the definition in use is the one you believed you had replaced.
That is a comfortable state to sit in for weeks, and it breaks the moment a teammate installs the plugin: they get the plugin's entry, you are still on your own, and the two configurations drift with no visible cause. The check is claude mcp list, which shows each server with its scope, followed by claude mcp get for the one you care about. Run it once at the end of the migration and once more after you delete the originals.
So the rule I would hand anyone doing this: the migration is not finished when the plugin loads, it is finished when the original is gone. A copy left behind is not a fallback. It is a second, invisible source of truth, and Claude Code's own precedence rules decide which of the two you are running — differently for hooks, subagents, skills and MCP servers.
Sources