Migrasi Dotfiles Claude Code ke Plugin yang Ter-version

Foto oleh brownpau via Wikimedia Commons (CC BY 2.0)
Buat direktori plugin di sebelah folder .claude yang akan Anda gantikan, taruh plugin.json di dalam folder .claude-plugin, lalu copy skills, agents dan commands ke root plugin tanpa diubah. Hooks adalah pengecualian karena ia tinggal di dalam settings.json: angkat objek hooks itu secara manual ke hooks/hooks.json, yang bentuknya identik. Hanya plugin.json yang masuk ke .claude-plugin, dan semua direktori komponen duduk di root plugin.
Karena versi aslinya masih ada di settings.json. Entri hook digabung antar level settings dan bukan saling mengganti, dan walaupun handler yang sama di dua file settings hanya jalan sekali, salinan milik plugin dihitung terpisah dari salinan di settings. Hapus blok lamanya, jangan disimpan sebagai fallback, lalu pakai /hooks untuk melihat jumlah per event dan label sumber setiap handler.
Aturannya berbeda per komponen, jadi tidak ada satu aturan yang bisa dihafal. File agents milik user atau project yang bernama sama mengalahkan subagent milik plugin; skill plugin diberi namespace plugin-name:skill-name sehingga kedua salinan tetap tersedia; semua hook yang cocok jalan; dan MCP server diselesaikan berurutan local, project, user, baru plugin. Urutan terakhir itu berarti registrasi user-scope yang tertinggal mengalahkan salinan di dalam plugin Anda.
Apa pun yang spesifik ke satu mesin atau bersifat rahasia. Absolute path di bawah home directory Anda akan rusak di mesin lain dan tidak memunculkan error validasi, jadi pakai CLAUDE_SKILL_DIR, CLAUDE_PLUGIN_ROOT atau CLAUDE_PROJECT_DIR. Credential sebaiknya dideklarasikan di userConfig dengan tanda sensitive supaya setiap pemasang memberi miliknya sendiri, dan permission rule sama sekali tidak bisa ikut karena settings.json milik plugin hanya menerima key agent dan subagentStatusLine.
Jalankan claude plugin validate pada direktorinya, muat dengan claude --plugin-dir alih-alih meng-install, lalu verifikasi setiap komponen lewat sinyalnya sendiri: /skills ditambah tugas yang Anda deskripsikan sendiri untuk membuktikan aktivasi, /hooks untuk jumlah per event, /context untuk custom agents dan /mcp untuk status server. Tutup dengan claude mcp list, karena server yang registrasi lamanya masih mengalahkan plugin akan melaporkan Connected sambil menjalankan entri yang Anda pikir sudah diganti.

Foto oleh brownpau via Wikimedia Commons (CC BY 2.0)
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.
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.
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.
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.
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.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.

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

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