I'm filing this one under "blog posts I wish existed when I started building Claude Code plugins."
Here's the thing: plugin development gets weird fast. Your settings.json gets borked and your hooks vanish. You have three versions of a plugin installed and Claude is loading the wrong one. You're developing two plugins at once and they're stepping on each other because they're both mutating the same shared state. You spend 20 minutes debugging a behavior that turns out to be the other plugin. And then, while trying to fix everything, you nuke your ~/.claude/ and lose every previous Claude session you've ever had.
Ask me how I know.
The fundamental problem: when you build tools for your own development environment, your dev environment is production. There's no staging environment for ~/.claude/. Every plugin you install, uninstall, or reinstall mutates the same config directory you rely on for actual work. And when something goes sideways (which it will), you're doing forensics on your own daily-driver config, which is now harder to debug because you are trying to use your daily-driver.
What Lives in ~/.claude/
Two files you actually edit:
settings.json: permissions, env vars, hooks, Bedrock config. The big one.CLAUDE.md: your user memory. Instructions Claude loads on every session. You've probably spent real time curating this.
Then there's the plugin plumbing that every install/uninstall cycle churns through:
plugins/installed_plugins.json: the source of truth for what's installed. Each entry has aninstallPathpointing to the cache, aprojectPathfor scoping, version info. If any of those paths go stale... well, good luck with thatplugins/cache/{marketplace}/{plugin}/{version}/: where installed plugins actually live. Directory-source marketplaces copy files here at install time. Not symlink. Copy. We'll come back to why that matters.plugins/known_marketplaces.json: marketplace registry.
And then everything else: fifteen-odd internal directories for session history, conversation logs, debug output, shell snapshots, task state. You never touch these. But they all live in ~/.claude/ too, and they're all in the blast radius when something goes wrong.
And there's no sandbox mode for plugins (there is a general sandbox though). No "install this plugin but like, in a pretend way." You're always live.
So I went looking for a way to get out of production.
The Undocumented Env Var
Here's where it gets fun: this isn't in the docs.
If you dig through Claude Code's environment variable table (the big one in the settings docs), you'll find ANTHROPIC_API_KEY, CLAUDE_CODE_USE_BEDROCK, BASH_MAX_TIMEOUT_MS. But CLAUDE_CONFIG_DIR? Not listed. Not mentioned. Nowhere.
I'm pretty sure I found it in someone else's code on GitHub and validated that it worked. I wanted to go back and check the Claude session where I first used it, but, well. That session was in ~/.claude/. Which I nuked. While debugging a plugin. You see the problem.
CLAUDE_CONFIG_DIR=/tmp/test-claude claude
That's it. Claude boots up, but instead of reading from ~/.claude/, it reads from /tmp/test-claude/. Fresh slate. Your real config is untouched.
Except... this minimal version has problems:
- No auth. Your API keys and Bedrock credentials live in your real
settings.json(and subscription login state is stored outside~/.claude/entirely, in the system keychain on macOS). A bare config dir can't talk to any model. - No plugins. You get a naked Claude with no commands, no hooks. Which is the thing you're trying to test.
- No permissions. Every tool call prompts for approval. You'll be hitting "yes" a lot.
So you can't just point at an empty directory and go. You need an isolated config with just enough of your real settings to function.
What does "just enough" look like? It depends on how you connect to Claude:
Subscription users (Max, Pro, Teams, Enterprise): login state is stored outside~/.claude/(in the system keychain on macOS), soCLAUDE_CONFIG_DIRdoesn't affect it. Your auth just works. You don't need to copy anything.API key users: setANTHROPIC_API_KEYin your test config'ssettings.jsonenvblock, or just export it in the shell.Bedrock/AWS users: you need theenvblock (endpoint URLs, region) andawsAuthRefreshcommand copied from your realsettings.json. This is the most involved case, so the examples below show this path.
Beyond auth, everyone needs:
- A minimal permissions allowlist so you're not approving
echoandlsfifty times
That's the config side. For plugins, the CLI itself respects CLAUDE_CONFIG_DIR: claude plugin marketplace add and claude plugin install both write to whatever config dir you've pointed at, not ~/.claude/. The CLI handles marketplace state for you.
Still, that's a lot of steps to do by hand every time which is why I built a script.
Building a Proper Test Harness
Once I knew CLAUDE_CONFIG_DIR worked, I wanted something I could run without thinking: A script that sets up an isolated config, installs my plugins, and drops me into Claude. Plus a --clean flag that nukes everything and rebuilds in seconds.
Here's the shape of it. The full script lives in my plugins monorepo as bin/test-claude.
1. Create the test config directory
TEST_CONFIG="$REPO_ROOT/tmp/test-claude-config"
mkdir -p "$TEST_CONFIG"
I put it in tmp/ inside the repo so it's gitignored by default and lives next to the code I'm testing.
2. Copy auth from your real config (Bedrock/API key users)
GLOBAL_SETTINGS="$HOME/.claude/settings.json"
ENV_SETTINGS=$(jq '.env // {}' "$GLOBAL_SETTINGS")
AWS_AUTH=$(jq -r '.awsAuthRefresh // empty' "$GLOBAL_SETTINGS")
This pulls just the env block (where Bedrock endpoint config lives) and the awsAuthRefresh command from your real settings. Nothing else comes along: no permissions, no hooks, no plugin state.
3. Write a minimal settings.json
{
"env": { "...your bedrock stuff..." },
"awsAuthRefresh": "aws sso login --profile whatever",
"permissions": {
"allow": [
"Bash(echo:*)",
"Bash(git status:*)",
"Bash(ls:*)",
"Bash(pwd:*)",
"Bash(uv run:*)"
]
},
"hooks": {}
}
The permissions list is deliberately minimal, just enough that you're not hitting "approve" on every trivial command. The empty hooks object means no hooks from your real config bleed in. You're testing your plugin's hooks, not whatever else you've got configured.
4. Register your repo as a marketplace
CLAUDE_CONFIG_DIR="$TEST_CONFIG" claude plugin marketplace add "$REPO_ROOT"
The CLI writes known_marketplaces.json, creates the plugins/marketplaces/ directory, all of it inside the test config, not ~/.claude/.
5. Install plugins into the isolated config
MARKETPLACE="$REPO_ROOT/.claude-plugin/marketplace.json"
jq -r '.plugins[] | select(.source | type == "string") | "\(.name)\t\(.source)"' \
"$MARKETPLACE" | while IFS=$'\t' read -r name source; do
full_path="$REPO_ROOT/${source#./}"
if [ -d "$full_path" ]; then
CLAUDE_CONFIG_DIR="$TEST_CONFIG" claude plugin install "$name@local-test"
else
echo "Error: plugin '$name' source not found at $full_path" >&2
exit 1
fi
done
Instead of scanning for plugins/*/ directories and assuming a layout, this reads .claude-plugin/marketplace.json, the file that actually defines what plugins exist. The jq filter grabs every plugin with a local path as its source (skipping any that point at GitHub repos or other remote sources), then verifies the directory exists before installing. CLAUDE_CONFIG_DIR means the install writes to the test config's cache and installed_plugins.json, not your real ones.
6. Run Claude
exec env CLAUDE_CONFIG_DIR="$TEST_CONFIG" claude "$@"
That's it. You get a Claude session with all your local plugins installed, talking to your real API through Bedrock, but with completely isolated config state. Fix a bug, --clean, reinstall, test again. Your real ~/.claude/ never knows it happened.
The Gotchas That Bit Me
Even with isolation, plugin development has some sharp edges. These all cost me at least an hour each.
You fix a bug, but don't see the fix.
This one's subtle. You find a problem in your hook script, fix it, test again. Same broken behavior. You add a print statement. Nothing. You start questioning reality.
The issue: directory-source marketplaces copy your plugin files into the cache at install time. They don't symlink. When you plugin install, Claude copies plugins/my-plugin/ into plugins/cache/marketplace-name/my-plugin/1.0.0/. Your fix is in the source. The cache still has the old code.
The fix is boring but non-negotiable: reinstall after every code change. Uninstall, install, restart Claude. Or if you're using an isolation script, --clean and start fresh. Adds a few seconds to each iteration. You will forget this.
Plugins silently stop loading.
Your plugin was working, now it's not. No error. Claude just doesn't have your commands anymore.
The cause: installed_plugins.json is pointing at a cache path that doesn't match what's actually on disk. Maybe you did a partial cleanup, or an install wrote a new version directory but the old entry is still around. The file says your plugin is at cache/marketplace/plugin/1.0.0/ but the cache only has 1.2.0/.
Claude doesn't crash when this happens. It just... doesn't load the plugin. Silent failure.
The fix: uninstall and reinstall. Or nuke the cache entry for that plugin and start clean. With an isolated test config, --clean solves this instantly. In your real config, you'll need to be more surgical: check the installPath entries in installed_plugins.json and make sure they actually exist.
Only some of your plugins load.
I had a monorepo with multiple plugins. Some loaded fine. Others were invisible, no error, just missing.
The issue: local-scoped plugins are registered with a projectPath matching where you installed them. If your cwd doesn't match that path exactly, Claude skips them. I was running from plugins/tool-routing/ instead of the repo root, so only plugins installed from that subdirectory were in scope.
This gets worse when plugins read from each other. tool-routing discovers route config from all enabled plugins (via claude plugin list --json), regardless of which marketplace they came from. If only half your plugins are in scope because of a projectPath mismatch, it only sees half the routes, and you get mysterious "route not found" behavior with no obvious cause.
The fix: always run from the repo root, or set CLAUDE_PROJECT_ROOT explicitly. In the test harness this is less of an issue because you control the working directory, but it'll bite you in manual testing.
The config dir you forgot about. (Bedrock users)
You set up CLAUDE_CONFIG_DIR, build a clean test config, everything works. Then you try something that needs authentication and it fails. You copy over more settings. Still fails. You spend 20 minutes wondering if the env var is even working.
Turns out login state doesn't live in ~/.claude/ at all. On macOS it's in the system keychain, and it works regardless of CLAUDE_CONFIG_DIR. You don't need to copy it. But you might spend a while looking for it in the wrong place first.
What does need to be in your test config is the Bedrock/AWS configuration: the env block and awsAuthRefresh in settings.json. That's what bin/test-claude copies over. The login session itself just works.
If you're on a subscription plan, this gotcha doesn't apply to you. Your auth is global and just works with an empty test config. Lucky you.
Where Isolation Is Just the Start
CLAUDE_CONFIG_DIR and a good test script get you pretty far. You can iterate on plugins without worrying about trashing your real config, and --clean gives you a predictable baseline on every run.
But isolation is one layer. Once your plugin installs cleanly, there's more:
- Unit tests for internal logic: route matching, config parsing, the stuff that doesn't need Claude running at all. pytest with temp directories and env var overrides.
- Integration tests for hooks: subprocess calls with realistic JSON payloads, checking exit codes and stderr.
- Behavioral tests for skills and prompts. This one's wild. You can dispatch subagents with and without your plugin's prompts and grade the difference against a rubric. Your plugin's effectiveness becomes measurable.
That's a whole other post. For now: isolate your config, script the setup, and stop testing in "production".