setup-claude-profiles
Two subscriptions, one machine, one set of config.
The model
A profile is a config directory. ~/.claude is the default one; any other is selected by
exporting CLAUDE_CONFIG_DIR before launching. Everything Claude Code stores about you follows
that variable — settings, plugins, skills, sessions, history, the account — so two directories give
you two independent logins with independent rate limits.
Two facts decide the whole design, and both are easy to get wrong:
| Default profile | CLAUDE_CONFIG_DIR=<dir> |
|
|---|---|---|
| account + MCP servers | ~/.claude.json |
<dir>/.claude.json |
| everything else | ~/.claude/… |
<dir>/… |
~/.claude/.claude.json often exists too, as a stale leftover. It is not the default profile's
live config — reading it reports the wrong account. _profiles.config_json_for() encodes the rule;
use it rather than joining paths by hand.
And mcpServers is a key of .claude.json, not of settings.json. Since .claude.json also
carries the account identity and project history, it can never be shared — so MCP servers are the
one thing that must be copied rather than linked.
Workflow
Run the doctor first, always. It is read-only and tells you what actually exists rather than what the user believes exists.
python3 ${CLAUDE_PLUGIN_ROOT}/skills/setup-claude-profiles/scripts/profiles_doctor.py
1 — Create and sign in to the second profile. A fresh config dir is not authenticated; the token does not carry over from the first profile.
CLAUDE_CONFIG_DIR=~/.claude-work claude # then /login, with the SECOND account
Pick the directory per references/platform-notes.md — on WSL this is the step where people put it
on /mnt/c and quietly break everything downstream.
2 — Share the config. Dry run first; it prints the plan and changes nothing.
python3 .../scripts/link_shared_config.py --to ~/.claude-work
python3 .../scripts/link_shared_config.py --to ~/.claude-work --apply
It symlinks settings.json, CLAUDE.md, agents/, commands/, plugins/ and skills/ from
~/.claude, moves anything the target already owned into <target>/backups/profile-link-<ts>/, and
also links whatever CLAUDE.md pulls in with @file.md — an @-import resolves next to the file,
so linking CLAUDE.md alone leaves it dangling. --unlink --apply reverses all of it, copying out
of that backup rather than emptying it, so the undo survives being run more than once and leaves a
manifest of the links it removed. The dry run writes nothing whatsoever — worth saying to anyone
nervous about pointing it at the wrong directory.
3 — Mirror the MCP servers.
python3 .../scripts/sync_mcp.py --to ~/.claude-work --apply
4 — Register a launcher, so the mirror runs on every launch and the server list can never
drift. shell_wrapper.py writes it, and is dry-run by default like the linker:
python3 .../scripts/shell_wrapper.py --to ~/.claude-work # show the plan
python3 .../scripts/shell_wrapper.py --to ~/.claude-work --apply
It picks ~/.zshrc or ~/.bashrc from $SHELL, backs the file up first, and writes between
# >>> claude-profiles >>> markers so re-running replaces its own block rather than stacking
duplicates. Pass --to more than once to register several profiles in one block.
Read what it prints before applying, because two things it reports are easy to wave past. An alias of the same name already existing means the function will never run — in zsh an alias is expanded before a same-named function is considered, so appending underneath one looks like it worked and changes nothing. An existing function of the same name means two definitions, and the later one wins; that is ours, but it is confusing to read six months later.
The launcher is a function rather than an alias because an alias cannot run the sync first. It
resolves the sync script by glob, since the plugin cache carries a version directory that changes
on update and ${CLAUDE_PLUGIN_ROOT} means nothing inside a shell rc; the guard makes a miss cost
the MCP sync, never the launch.
Do not reach for --mcp-config here instead: the flag works and merges with the profile's own
servers, but it is variadic, so claude --mcp-config f "prompt" swallows the prompt as a second
config path.
5 — Verify. Re-run the doctor. Then confirm the accounts really differ — compare
accountUuid, not just the email:
python3 .../scripts/profiles_doctor.py --json | python3 -c \
"import json,sys; [print(p['config_dir'], p['account']['email'], p['account']['account_uuid']) for p in json.load(sys.stdin)['profiles']]"
What to tell the user afterwards
- Shared means shared both ways. Claude Code writes through the symlinks, so enabling a plugin or changing the theme from either profile changes the single shared file and applies to both. There is no per-profile override once an entry is shared — that is the trade, and it is worth saying out loud before they discover it.
- OAuth-based MCP servers still need one
/mcplogin per profile. The server definition copies; its token does not. Expect⚠ N MCP servers need authenticationon first launch. - Anything that hardcodes a path or an account will lie in the second profile. A shared status
line with the email baked in is the classic case: the session is on the right account and only the
display is wrong. Derive such values from
CLAUDE_CONFIG_DIR(falling back to~/.claude.json), and check any shared script for absolute~/.claude/...paths. - A shell inside a Claude Code session inherits
CLAUDE_CONFIG_DIR. So a bareclaude ...in a terminal opened by the second profile targets that profile. Useenv -u CLAUDE_CONFIG_DIR claude ...to act on the default one, and never compare two profiles without setting the directory explicitly on both sides. CLAUDE_CONFIG_DIRseparates user-level config only — not per-project config. A repo's.claude/settings.jsonandCLAUDE.mdload whichever profile you launch. So typing bareclaudein a client's repo out of habit looks completely normal — right project instructions, right behaviour — while running on the wrong subscription and writing to the wrong history. This is the most common way the split quietly fails, which is why the wrapper is a named function andCLAUDE_CONFIG_DIRis never exported globally.- Put the profile name in the status line, not just the email. If the wrapper is bypassed or
CLAUDE_CONFIG_DIRis lost, Claude Code falls back to the default profile silently — the one case that genuinely does bill the wrong account. A label derived fromCLAUDE_CONFIG_DIRat render time is the only thing on screen that would reveal it.
Say what your evidence actually is
Most of this is checkable on the spot, and where it is, check it rather than recalling it. But some of it cannot be: a session running on macOS cannot observe a WSL mount table, and a session with no second profile yet cannot observe how that profile behaves.
When you report something you did not observe on this machine, name the basis in the same sentence
as the claim — "the documented behaviour of drvfs mounts", "the captured mount table
profiles_doctor.py --selfcheck runs against", "what the linker would refuse" — rather than phrasing
it as a measurement. "I checked your path against a WSL mount table" is false when the table was a
fixture, and the reader has no way to catch it. This matters more here than in most tasks: the whole
point of the doctor is that people should stop guessing at their own config, and an answer that
blurs a fixture into a measurement teaches exactly the habit the skill exists to replace.
Being explicit costs a clause and loses nothing — a reader who knows which parts were measured knows which parts to re-check on their own machine.
References
references/what-is-shared.md— the full shareable / never-shareable matrix and why each falls where it does, including the marketplace registry being split across two files.references/platform-notes.md— macOS, Linux and WSL + Ubuntu: where the credential store is, which shell file to edit, and the/mnt/ctrap.
profiles_doctor.py --selfcheck asserts the platform rules — including the WSL filesystem
detection, driven from a captured /proc/mounts so it runs on macOS too.