Skip to main content
BlogSoftware Development

Migrating Claude Code Dotfiles into a Versioned Plugin

A PostToolUse hook in this repository started reviewing every edit twice, and the script was not the problem — I had copied it into a plugin and left the original in settings.json. Here is the file-by-file map out of a dotfiles directory, and the precedence rules that decide which copy runs.

Matthews Wong

Software Engineer

September 202611 min read
Migrating Claude Code Dotfiles into a Versioned Plugin

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.

Take the inventory before you move anything

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-163035

Two 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.

The map from a loose file to a plugin directory

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 directoryIn the pluginWhat changes
skills/name/SKILL.mdskills/name/SKILL.md at the plugin rootInvocation becomes /plugin-name:name. The original keeps answering /name, so both stay available
commands/name.mdcommands/name.md at the plugin rootFlat markdown keeps working, but skills/ is the recommended shape for anything new
agents/name.mdagents/name.md at the plugin rootLoads as plugin-name:name, but a same-named user or project file overrides it
The hooks object inside settings.jsonhooks/hooks.jsonIdentical object shape, but it merges with your settings hooks instead of replacing them
MCP entries in ~/.claude.json.mcp.json at the plugin rootTool names gain a plugin scope, so permission rules written for the old name stop matching
Everything else in settings.jsonIt stays where it isA plugin settings.json accepts only the agent and subagentStatusLine keys
CLAUDE.md and rules filesNowhereThe 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.

What must stay personal, and what breaks if it does not

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.

Why one hook now fires twice, and which layer wins

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.
  • Hooks merge. A settings hook and a plugin hook on the same event both run, so the old block has to be deleted rather than kept as a fallback.
  • Subagents override. A user or project agents file of the same name beats the plugin's copy, so the plugin version stays inert until the original is deleted.
  • Skills coexist. Plugin skills are namespaced as plugin-name:skill-name, so nothing conflicts and nothing is replaced — you simply have two of everything until you clean up. Among the other levels, personal overrides project.
  • MCP servers resolve by scope. Local outranks project, project outranks user, and user outranks anything a plugin provides. The whole entry comes from the highest source and fields are never merged.

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.

Three panels dividing a dotfiles directory into components that move into the plugin, configuration that stays on the machine, and leftovers that must be deleted rather than copied, with skills, hooks and MCP servers set against theme, permission rules, tokens and absolute paths.
The third panel is the one that costs you time. Everything in it is a copy you have already made, and leaving any of them in place is how a plugin ends up firing a hook twice or shadowing its own MCP server.

The honest outcome is two artefacts, not 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.

Verify each component by its own signal

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.

  1. Run claude plugin validate on the plugin directory. It is the same check the community review pipeline runs, and --strict promotes warnings to errors. It also works on a bare skills directory, which is the fastest way to find a SKILL.md whose frontmatter no longer parses.
  2. Load it with claude --plugin-dir pointed at the directory rather than installing it, so the originals are still on disk when you need to compare behaviour.
  3. Run /reload-plugins after every edit instead of restarting the session. It re-reads plugins, skills, agents, hooks and the plugin's MCP and LSP servers.
  4. Open /plugin and read the Errors tab. A component that failed to load is listed there, a missing executable for instance, while a component with invalid configuration is skipped instead — and claude --debug says why.
# 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 wrong

Then 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.

A five-band stack of post-migration checks, each tagged with the command that produces its signal: skills activating, hooks firing once, subagents loading from the plugin, MCP servers connecting, and a final band for the server whose old registration still answers.
Read the last band as the reason the other four are not enough: every one of them can report success while the component being used is not the one you migrated.

The component you lose without noticing

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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

How do I turn my .claude dotfiles directory into a Claude Code plugin?

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.

Why does my hook run twice after I move it into a plugin?

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.

Which one wins when a plugin and my local config define the same component?

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.

What should never go inside a shared Claude Code 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.

How do I check that no component was lost in the migration?

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.