
When I started using Claude Code seriously for TYPO3 upgrades, my setup lived in the .claude/ folder of each project: a few skills, two or three subagents, a hook script. Then I copied that folder into the next project. And the next. After a month I had five slightly different versions of the same guard hook, and the best fix was in a project I had already closed.
Claude Code plugins solve exactly this. A plugin is a directory with a fixed layout that bundles skills, agents, hooks, MCP servers and more. You install it from a marketplace, it has a version, and every project uses the same copy. This post shows how I packaged my typo3-project-migration plugin, the one that did the legwork for the hoja.in move from TYPO3 7.6 to 14.3.
The layout
The most common mistake first: only the manifest goes into .claude-plugin/. Everything else sits at the plugin root. This is the real tree of my plugin, without node_modules and CI files:
typo3-project-migration/
├── .claude-plugin/
│ ├── plugin.json
│ └── marketplace.json
├── skills/
│ ├── typo3-project-migration/
│ │ ├── SKILL.md
│ │ ├── references/ (hop cards v10 → v14, troubleshooting, ...)
│ │ └── scripts/ (inventory.sh, project-gate.sh, t3.sh, ...)
│ ├── assess/SKILL.md
│ ├── verify/SKILL.md
│ └── dev-companion/SKILL.md
├── agents/
│ ├── project-scout.md
│ ├── extension-auditor.md
│ ├── upgrade-strategist.md
│ └── upgrade-reviewer.md
├── hooks/
│ ├── hooks.json
│ └── scripts/guard-bash.sh
├── scripts/selftest.sh
└── CHANGELOG.mdSkills are folders with a SKILL.md; scripts and reference files the skill needs live next to it. Agents are single Markdown files with frontmatter. Hooks are configured in hooks/hooks.json. Each of these is a default location, so Claude Code finds them without any configuration. Every component is namespaced with the plugin name, so the skill verify appears as typo3-project-migration:verify and cannot clash with a verify skill from another plugin.
Step 1: the manifest
.claude-plugin/plugin.json is optional; only name is required when you write one. I write one anyway, because the version and the description matter once other people install the plugin:
{
"name": "typo3-project-migration",
"displayName": "TYPO3 Project Migration",
"version": "0.1.0",
"description": "Upgrade whole TYPO3 projects from v10 to v14, one LTS at a time.",
"author": {
"name": "Hoja Mustaffa Abdul Latheef",
"url": "https://jweiland.net"
},
"license": "MIT",
"keywords": ["typo3", "migration", "upgrade", "composer", "ddev"]
}Use kebab-case for name. The validator also refuses names that look like Anthropic’s own plugins (for example anything starting with claude-), so do not call yours claude-typo3.
Step 2: agents with a model per job
Agents are where a plugin pays off most for me, because each one can have its own tools and model. A read-only scout that greps through templates does not need the biggest model; the reviewer that has to find what the green test run misses does. The frontmatter of the reviewer:
---
name: upgrade-reviewer
description: Adversarial reviewer for one finished hop of a TYPO3 project upgrade. Reads the hop's commits, the gate output, the ledger and the plan, and hunts for what the gate misses. Read-only.
tools: Read, Grep, Glob, Bash
model: opus
---
You review one hop of a TYPO3 project upgrade, and you don't trust the green gate. You change nothing.In my plugin the scout runs on Haiku, the extension auditor on Sonnet, the strategist and the reviewer on Opus. The description is not decoration: it is what the main session reads to decide when to delegate, so write it like a job ad, with the trigger words people actually use.
Step 3: hooks that travel with the plugin
A rule in a prompt is a wish. A hook is enforced by Claude Code, outside the model. My plugin ships one PreToolUse hook for the Bash tool:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ""${CLAUDE_PLUGIN_ROOT}/hooks/scripts/guard-bash.sh""
}
]
}
]
}
}${CLAUDE_PLUGIN_ROOT} resolves to the installed plugin directory, which changes with every version, so never hard-code a path and never write state there. The script reads the tool call as JSON on stdin and answers with a decision. The core of it:
if has '(typo3|t3.sh)[[:space:]]+[^|;&]*(extension:setup|upgrade:run|upgrade:mark:undone)'; then
recent="$(find .project-migration/backups .ddev/db_snapshots -maxdepth 1 -mmin -1440
( -name '*.sql' -o -name '*.sql.gz' -o -name '*.gz' ) 2>/dev/null | head -1 || true)"
if [[ -z "$recent" ]]; then
decide deny "No database dump from the last 24 h. Take one first, then run it again."
fi
fidecide prints a small JSON object with hookSpecificOutput.permissionDecision set to deny or ask and a reason that Claude sees. So the agent gets told why, takes the dump with ddev snapshot, and tries again. On hoja.in this is the hook that blocked database writes until a fresh dump existed. It also asks before ddev import-db and denies --ignore-platform-reqs and git commit --no-verify.
Step 4: a marketplace for the team
A marketplace is just a .claude-plugin/marketplace.json that lists plugins and where to get them. My plugin repository is its own marketplace, with the plugin at the root:
{
"name": "typo3-project-migration",
"owner": { "name": "Hoja Mustaffa Abdul Latheef" },
"plugins": [
{
"name": "typo3-project-migration",
"source": "./",
"description": "Upgrade whole TYPO3 projects from v10 to v14, one LTS at a time."
}
]
}For a team with several plugins, one repository with a plugins/ folder and one entry per plugin works better. Keep the entry name and the name in plugin.json identical; a mismatch gives confusing “not found” errors at install time.
Then, in the shell:
claude plugin validate ./typo3-project-migration
claude plugin marketplace add ./typo3-project-migration
claude plugin install typo3-project-migration@typo3-project-migration
# while developing: load the plugin directly, no install
claude --plugin-dir ./typo3-project-migrationTo make a project suggest the marketplace and plugin to everybody who opens it, commit this to the project’s .claude/settings.json (the repository name is a placeholder for your own):
{
"extraKnownMarketplaces": {
"typo3-tools": {
"source": { "source": "github", "repo": "your-org/claude-typo3-plugins" }
}
},
"enabledPlugins": {
"typo3-project-migration@typo3-tools": true
}
}Step 5: versions and a self-test
The version in plugin.json pins users to that version until you change it. So a fix only reaches your colleagues when you bump the version. I keep a CHANGELOG.md in Keep a Changelog format, and a scripts/selftest.sh that runs in CI and checks the boring things: all manifests are valid JSON, the changelog has an entry for the current version, every skill and agent has frontmatter with a real description, and every agent declares a model. Plus claude plugin validate --strict, which turns warnings into failures.
One more thing to know: when you add a marketplace from a local path, Claude Code reads the plugin files in place. Your edits are live at the next session start or after /reload-plugins, without a version bump. Great for development, but remember that your colleagues who installed from git do not have your local edits.
Was it worth it?
Yes. The guard hook exists once, with a self-test. When I improve an agent or a hook, every project gets the better version with the next update instead of the next copy-paste. If you have a .claude/ folder that you copied more than twice, package it.