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

URL: https://www.matthewswong.com/en/blog/claude-code-dotfiles-to-plugin-migration/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-09-09
- Updated: 2026-09-09
- Category: Software Development

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

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

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.

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

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

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

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

## Sources

- Claude Code documentation — Create plugins: the migration steps, the directory contract and the note on removing the originals
- Claude Code documentation — Plugins reference: the full directory layout, the manifest schema and the plugin environment variables
- Claude Code documentation — Hooks: where hooks can be configured, how they merge across levels, and the deduplication rule
- Claude Code documentation — Skills: where skills live, how same-named skills resolve, and the path substitution variables
- Claude Code documentation — MCP: the local, project and user scopes, and the precedence order a plugin server sits below

## Source links

- https://code.claude.com/docs/en/plugins
- https://code.claude.com/docs/en/plugins-reference
- https://code.claude.com/docs/en/hooks
- https://code.claude.com/docs/en/skills
- https://code.claude.com/docs/en/mcp

## FAQ

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

---

- HTML version: https://www.matthewswong.com/en/blog/claude-code-dotfiles-to-plugin-migration/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/claude-code-dotfiles-to-plugin-migration/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
