Ringkasan Utama
Memigrasikan direktori dotfiles Claude Code yang sudah menumpuk ke sebuah plugin ter-version memindahkan skills, subagents, commands, hooks dan MCP servers menjadi satu bundle yang bisa di-install. Bagian yang mahal adalah sisa yang tertinggal: hook lama di settings tetap jalan berdampingan dengan salinan plugin, registrasi MCP user-scope mengalahkan milik plugin, dan file subagent lokal bernama sama menang tanpa suara.
Laporannya tercetak dua kali. Sebuah hook PostToolUse di repository ini memeriksa apakah figure sebuah artikel cocok dengan section di sebelahnya, dan setelah saya membungkus hook itu ke dalam plugin, ia mulai me-review setiap edit dua kali. Satu jam saya pakai untuk membaca script-nya. Script-nya tidak apa-apa. Saya menyalin hook itu ke plugin dan membiarkan versi aslinya tetap di settings.json, dan Claude Code menjalankan keduanya.
Begitulah bentuk seluruh migrasi ini. Memindahkan file hanya butuh satu sore dengan cp; biayanya ada di layer yang lupa Anda kosongkan. Tulisan ini adalah peta file per file dari direktori dotfiles ke sebuah plugin, empat aturan precedence yang menentukan salinan mana yang sebenarnya jalan, dan cara memastikan sebuah komponen benar-benar pindah dan bukan hilang tanpa suara. Mekanismenya dari dokumentasi Claude Code; inventarisnya dan laporan ganda itu milik saya.
Buat inventaris dulu sebelum memindahkan apa pun
Tidak ada satu pun isi direktori dotfiles yang menumpuk itu dirancang, dan Anda baru melihatnya setelah semuanya jadi daftar. Milik saya bahkan bukan satu direktori. Folder .claude di repository ini berisi empat skills, satu subagent dan satu hook PostToolUse, sementara file user settings saya membawa enam belas key di level teratas dan hook handler pada delapan event berbeda, dari PreToolUse sampai 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-163035Dua hal dalam output itu mengubah rencana. Hooks-nya terpecah antara file user dan file project, jadi memigrasikan salah satunya akan meninggalkan yang lain tetap jalan. Lalu dua backup yang duduk di sebelah settings.json bukan salinan yang saya buat sengaja, dan itu justru argumen paling jujur untuk seluruh kerja ini: sebuah plugin punya field version, sedangkan file settings yang diedit tangan hanya punya salinan dirinya sendiri berstempel waktu.
Peta dari file lepas ke direktori plugin
Sebagian besar perpindahannya mekanis dan bentuknya tidak berubah. Sebuah skill tetap folder berisi SKILL.md, sebelum dan sesudah; sebuah subagent tetap file markdown dengan frontmatter, sebelum dan sesudah. Hanya dua baris di tabel ini yang butuh kerja sungguhan, dan keduanya adalah baris yang sumbernya settings.json, bukan sebuah direktori.
| Di direktori dotfiles | Di dalam plugin | Yang berubah |
|---|---|---|
| skills/name/SKILL.md | skills/name/SKILL.md di root plugin | Cara memanggilnya jadi /plugin-name:name. Versi aslinya masih menjawab /name, jadi keduanya tetap tersedia |
| commands/name.md | commands/name.md di root plugin | Markdown datar masih jalan, tapi skills/ adalah bentuk yang disarankan untuk apa pun yang baru |
| agents/name.md | agents/name.md di root plugin | Dimuat sebagai plugin-name:name, tapi file user atau project bernama sama mengalahkannya |
| Objek hooks di dalam settings.json | hooks/hooks.json | Bentuk objeknya identik, tapi ia digabung dengan hooks di settings Anda, bukan menggantinya |
| Entri MCP di ~/.claude.json | .mcp.json di root plugin | Nama tool mendapat scope plugin, jadi permission rule yang ditulis untuk nama lama berhenti cocok |
| Sisa isi settings.json | Tetap di tempatnya | settings.json milik plugin hanya menerima key agent dan subagentStatusLine |
| CLAUDE.md dan file rules | Tidak ke mana-mana | Layout plugin yang terdokumentasi tidak punya slot untuk file memory, jadi keduanya tetap di 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" }
}Manifest-nya lebih kecil dari dugaan orang. Hanya name yang wajib, dan name itu sekaligus menjadi namespace untuk setiap skill di dalam bundle. version bersifat opsional, tapi menghilangkannya berarti pemasang tidak pernah menerima update ketika Anda mengubah isinya, jadi migrasi yang melewatkannya menghasilkan plugin yang tidak bisa di-upgrade siapa pun. Satu aturan struktural layak dibaca dua kali: hanya plugin.json yang masuk ke dalam .claude-plugin, dan semua direktori komponen duduk di root plugin.
Apa yang harus tetap personal, dan apa yang rusak kalau tidak
Kegagalan di sini bukan berupa pesan error. Sebuah skill yang menyebut absolute path di bawah home directory Anda tetap ter-parse, tetap ter-aktivasi, lalu menyuruh Claude menjalankan script yang tidak ada di mesin yang memasangnya. Tidak ada bagian bundle yang invalid, jadi tidak ada validator yang protes, dan kegagalannya muncul tiga langkah kemudian sebagai tool error yang membingungkan.
# 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
}
}
}Tiga variabel menghapus masalah ini sejak awal. CLAUDE_SKILL_DIR menunjuk ke direktori yang memuat SKILL.md itu, CLAUDE_PLUGIN_ROOT ke direktori pemasangan plugin, dan CLAUDE_PROJECT_DIR ke root project — dan Claude Code mensubstitusikan ketiganya baik di body skill maupun di frontmatter allowed-tools, dan itulah yang membuat script bawaan bisa jalan tanpa permission prompt. Credential mendapat perlakuan berbeda: deklarasikan di userConfig, tandai sensitive, lalu biarkan setiap pemasang memberi milik mereka sendiri.
Jangan coba memigrasikan permission rules Anda. settings.json milik plugin hanya menerima key agent dan subagentStatusLine, jadi rule allow dan deny sama sekali tidak bisa ikut di dalam bundle — dan rule yang sudah Anda tulis untuk sebuah MCP tool berhenti cocok begitu server itu dikirim di dalam plugin, karena nama tool yang ter-scope mendapat prefix plugin. Setiap panggilan yang tadinya Anda izinkan mulai meminta konfirmasi lagi.
Kenapa satu hook sekarang jalan dua kali, dan layer mana yang menang
Hooks tidak saling menimpa. Dokumentasinya tegas bahwa entri hook digabung antar level settings dan bukan saling mengganti, bahwa semua hook yang cocok jalan paralel, dan bahwa handler yang sama yang didefinisikan di lebih dari satu file settings hanya jalan sekali — tetapi salinan milik plugin atau skill tetap dihitung terpisah. Klausa terakhir itulah laporan ganda saya: deduplikasi tidak pernah menyeberangi batas antara file settings dan sebuah 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 digabung. Hook di settings dan hook di plugin pada event yang sama dua-duanya jalan, jadi blok lama harus dihapus, bukan disimpan sebagai fallback.
- Subagents saling menimpa. File agents milik user atau project yang bernama sama mengalahkan salinan plugin, jadi versi plugin diam saja sampai versi aslinya dihapus.
- Skills hidup berdampingan. Skill plugin diberi namespace plugin-name:skill-name, jadi tidak ada yang bentrok dan tidak ada yang diganti — Anda hanya punya dua dari segalanya sampai dibersihkan. Di antara level lain, personal mengalahkan project.
- MCP servers diselesaikan berdasarkan scope. Local mengalahkan project, project mengalahkan user, dan user mengalahkan apa pun yang disediakan plugin. Seluruh entri diambil dari sumber tertinggi dan field-nya tidak pernah digabung.
Ada dua kasus yang tidak bisa saya pastikan dari dokumentasi, jadi saya berhenti menduga. Dokumentasi tidak menyebut apa yang terjadi kalau dua plugin yang aktif mengirim handler identik untuk event yang sama, dan tidak mengurutkan key agent di settings.json milik plugin melawan key yang sama di level user. Yang pertama ternyata tidak sepenting kedengarannya, karena hook yang cocok jalan paralel dan tidak ada urutan yang bisa diandalkan. Untuk yang kedua tesnya murah: set key-nya di satu tempat, baca apa yang termuat, lalu set di tempat lain — lebih lambat dari aturan yang terdokumentasi, dan jauh lebih aman dari aturan yang diasumsikan.

Hasil yang jujur adalah dua artefak, bukan satu
Saya tidak berhasil sampai ke satu bundle tunggal, dan setelah mencoba saya berhenti menginginkannya. Separuh dari yang menumpuk di konfigurasi saya secara prinsip tidak bisa dibagikan: perintah status line yang menunjuk script di home directory saya, sebuah effort level, sebuah theme, permission rules yang mencerminkan seberapa jauh saya membiarkan agent bekerja tanpa diawasi. Dorong semua itu ke team plugin dan Anda sedang mendorong kebiasaan kerja Anda ke empat orang lain.
# 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.Jadi migrasinya mendarat sebagai satu team plugin plus satu personal layer yang tipis, dan temuan yang berguna adalah personal layer itu juga bisa berupa plugin, tanpa mesin distribusi sama sekali. claude plugin init menulis sebuah manifest dan satu SKILL.md awal ke direktori personal skills Anda, dan pada session berikutnya ia dimuat sebagai name@skills-dir — tanpa marketplace dan tanpa langkah install, tapi dengan manifest sungguhan, jadi ia tetap bisa membawa agents, hooks dan MCP servers sendiri. Bagian tim ikut di-version dan di-review. Bagian personal boleh tetap berantakan.
Uji dengan --plugin-dir sebelum meng-install apa pun. Flag ini memuat plugin langsung dari sebuah direktori, dan salinan lokal punya precedence di atas plugin ter-install yang bernama sama untuk session itu, jadi Anda bisa melatih sebuah upgrade tanpa uninstall dulu. Lalu jalankan /reload-plugins setelah setiap edit — ia membaca ulang skills, agents, hooks serta MCP dan LSP server milik plugin tanpa restart.
Verifikasi setiap komponen lewat sinyalnya sendiri
Migrasi yang tidak memunculkan pesan error sama sekali belum membuktikan apa pun. Empat dari lima jenis komponen gagal tanpa suara ketika gagal, jadi setiap pemeriksaan harus bersifat positif: sebutkan sinyal yang hanya muncul kalau komponennya benar-benar termuat, lalu pergi mencarinya. Loop sebelum pemeriksaan itu sifatnya tetap.
- Jalankan claude plugin validate pada direktori plugin. Ini pemeriksaan yang sama dengan yang dijalankan pipeline review komunitas, dan --strict menaikkan warning menjadi error. Ia juga bekerja pada direktori skills biasa, dan itu cara tercepat menemukan SKILL.md yang frontmatter-nya tidak lagi ter-parse.
- Muat dengan claude --plugin-dir yang diarahkan ke direktori itu, bukan dengan meng-install-nya, supaya versi aslinya masih ada di disk saat Anda perlu membandingkan perilakunya.
- Jalankan /reload-plugins setelah setiap edit, bukan me-restart session. Ia membaca ulang plugins, skills, agents, hooks serta MCP dan LSP server milik plugin.
- Buka /plugin dan baca tab Errors. Komponen yang gagal dimuat terdaftar di sana, misalnya executable yang tidak ditemukan, sementara komponen dengan konfigurasi invalid justru dilewati — dan claude --debug memberi tahu alasannya.
# 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 wrongLalu komponennya sendiri, dan cara Anda merumuskan pemeriksaan skill lebih penting dari yang terlihat. Mengetik slash command hanya membuktikan bahwa sebuah file ada di suatu tempat; mendeskripsikan tugasnya dengan kalimat Anda sendiri itulah yang membuktikan description-nya masih layak memicu aktivasi, dan justru itu yang bisa rusak tanpa suara karena penyalinan antar direktori. Untuk sisanya, /hooks memberi jumlah per event beserta label sumbernya, /context mendaftar custom agents, dan /mcp memberi status per server.

Komponen yang hilang tanpa Anda sadari
Komponennya adalah MCP server. Ketika nama server yang sama ada di lebih dari satu scope, Claude Code menyambungkannya sekali saja, dan urutannya local, lalu project, lalu user, lalu apa pun yang disediakan plugin — dengan seluruh entri diambil dari sumber tertinggi dan tanpa penggabungan field. Registrasi user-scope di ~/.claude.json karena itu mengalahkan salinan yang susah payah Anda pindahkan ke .mcp.json milik plugin. Tidak ada peringatan. Statusnya terbaca Connected, tool-nya bekerja, dan definisi yang dipakai adalah yang Anda yakini sudah Anda ganti.
Kondisi itu nyaman untuk didiami berminggu-minggu, dan pecah begitu seorang rekan meng-install plugin-nya: mereka mendapat entri milik plugin, Anda masih memakai milik sendiri, dan dua konfigurasi itu melenceng tanpa sebab yang kelihatan. Pemeriksaannya claude mcp list, yang menampilkan setiap server beserta scope-nya, lalu claude mcp get untuk server yang Anda pedulikan. Jalankan sekali di akhir migrasi dan sekali lagi setelah versi aslinya Anda hapus.
Jadi aturan yang akan saya berikan kepada siapa pun yang melakukan ini: migrasi belum selesai ketika plugin-nya termuat, migrasi selesai ketika versi aslinya sudah hilang. Salinan yang tertinggal bukan fallback. Ia adalah sumber kebenaran kedua yang tidak kelihatan, dan aturan precedence Claude Code sendirilah yang menentukan mana dari keduanya yang Anda jalankan — dan aturannya berbeda untuk hooks, subagents, skills dan MCP servers.
Sumber
- Dokumentasi Claude Code — Create plugins: langkah migrasi, kontrak direktori dan catatan tentang menghapus file aslinya
- Dokumentasi Claude Code — Plugins reference: layout direktori lengkap, skema manifest dan environment variable milik plugin
- Dokumentasi Claude Code — Hooks: di mana hooks bisa dikonfigurasi, bagaimana ia digabung antar level, dan aturan deduplikasinya
- Dokumentasi Claude Code — Skills: di mana skills disimpan, bagaimana skill bernama sama diselesaikan, dan variabel substitusi path
- Dokumentasi Claude Code — MCP: scope local, project dan user, serta urutan precedence yang menempatkan server plugin di bawahnya



