Skip to main content
← Back to blog
claude-codeproductivityclidevtools

Claude Code Multi-Profile, v2: a clean XDG architecture

After a month on the old alias-based approach, I rebuilt my Claude Code multi-profile setup into an XDG-compliant structure under ~/.config/claude-profiles/. The real reason wasn't tidiness — it was discovering that ~/.claude.json is a separate file in the home root where the MCP servers added via --scope user were quietly living.

Published June 13, 20267 min read

A month ago I published a post about running two Claude Code accounts side-by-side on one machine — personal and work, through CLAUDE_CONFIG_DIR and a shell alias. The approach worked. Everything did what it was supposed to do.

But after a month of real use I ran into a few substantial problems that post didn't cover. The biggest one — a hidden Claude Code quirk I tripped over by accident, when I added the Gmail MCP and it "vanished" from my profile five minutes later. Today I rebuilt the whole thing into a new architecture — XDG-compliant, with a symlink and an interactive profile picker via gum. This is v2 — an evolution out of real experience, not a theoretical improvement.

What started to chafe

Four things. The first three are about tidiness, visibility, and scale. The fourth is the real architectural gotcha I didn't see right away. I'll walk through them in order, because it's the fourth one that finally forced the rebuild.

1. Two separate folders in $HOME — it's a mess

~/.claude and ~/.claude-promova — two dotfolders sitting next to each other at the root of $HOME. The XDG Base Directory Specification says configs belong in ~/.config/. Dotfile folders scattered straight in home are an antipattern that slowly turns $HOME into a junk drawer. Cosmetic, sure, but it bugged me every time I saw ls -la ~.

2. No visual confirmation of the active profile

I launch claude and have no idea which profile is active — not until I run claude config list inside the session. If I forgot which terminal I started where, I had to check. A small thing, but with personal + work running in parallel across two tabs it adds up.

3. Aliases don't scale

Two profiles — claude and claude-promova — that's fine. Add a third (a freelance client) — you need a third alias. A fourth — a fourth. Six months in I wouldn't remember which aliases I'd actually created.

4. The hidden trap in ~/.claude.json

And this is the real reason for the rebuild. Claude Code has two different places for config, and the docs don't shout about it: ~/.claude/ — the directory holding projects/, sessions/, hooks/, skills/. And separately — ~/.claude.json, a file sitting right in the $HOME root, which is where oauthAccount, mcpServers, the projects history, skillUsage, and roughly 40 other fields of actual live state live.

The claude mcp add --scope user command writes into that ~/.claude.json in the home root, not into ~/.claude/.claude.json or the profile directory. I didn't know that. Until one day I ran into it.

Discovery: why the Gmail MCP "vanished"

This morning I was setting up the Gmail MCP in Claude Code. Standard setup: Google Cloud project, OAuth credentials, claude mcp add gmail --scope user -- npx -y @gongrzhe/server-gmail-autoauth-mcp. All fine. Restarted the session — works, I'm reading mail, replying to messages. An hour later we started refactoring the aliases into a function with a gum profile picker, then moved everything over to XDG. I did mv ~/.claude → ~/.config/claude-profiles/personal, restarted CC, picked personal in the menu. And in the new session I opened /mcp:

/mcp output (personal profile)
figma            (failed)
playwright-test
claude.ai Notion

No gmail. No vaultforge. Just three servers, one of which had even failed. And I had just added Gmail. Meanwhile a session in another terminal (the work profile) was showing Gmail and Vaultforge without any problem.

I started digging in and found that I had three different files on my machine named .claude.json:

PathSizemcpServers
~/.claude.json (home root)113 KBfigma, gmail, vaultforge
~/.config/claude-profiles/personal/.claude.json29 KBfigma, playwright-test
~/.config/claude-profiles/promova/.claude.json40 KBfigma, playwright-test, vaultforge

There it was. That's the architectural gotcha:

  1. claude mcp add --scope user always writes into ~/.claude.json in the home root, regardless of CLAUDE_CONFIG_DIR
  2. When CLAUDE_CONFIG_DIR is set, Claude Code reads $CLAUDE_CONFIG_DIR/.claude.json — the file inside the profile
  3. The "user-scope MCPs" entries and the "profile MCPs" entries live in different files with the same name — and they're easy to mix up

In my case ~/.claude.json (home root, 113 KB) was the live, current state — with gmail, vaultforge, the OAuth session, everything. And ~/.config/claude-profiles/personal/.claude.json (29 KB) turned out to be an old snapshot that had been sitting in the old ~/.claude/ from earlier — maybe an older CC version wrote there, maybe a plugin. jq -r 'keys[]' on both files showed that the home-root version had 41 unique keys that weren't in the snapshot.

And those 41 keys aren't junk. That's the real state of Claude Code:

  • skillUsage — skill usage statistics
  • githubRepoPaths — cached repos for project navigation
  • cachedGrowthBookFeatures + cachedStatsigGates — feature flags (without them CC fetches fresh ones on every startup)
  • hasShownOpus45Notice, hasShownOpus46Notice, hasShownS1MWelcomeV2 — UI flags (without them the modals come back on the next launch)
  • lastPlanModeUse, feedbackSurveyState, installMethod — onboarding and UX state

If you just mv ~/.claude ~/.config/claude-profiles/personal with no merge, you lose all of it. The welcome modals come back, the githubRepoPaths search runs again, every survey prompt returns. Which is what I almost did.

The new architecture

Everything lives under one parent directory in ~/.config/, the way XDG wants it. Each profile is self-contained — it has its full state including its own .claude.json. ~/.claude stays as a symlink to the personal profile for backward compatibility.

~/.config/claude-profiles/
├── personal/
│   ├── .claude.json          ← full live state + MCP list (113 KB)
│   ├── projects/, sessions/, hooks/, skills/, ...
│   └── CLAUDE.md
└── promova/
    ├── .claude.json          ← work state + work MCPs (41 KB)
    └── projects/, sessions/, agents/, skills/, ...

~/.claude  →  symlink to ~/.config/claude-profiles/personal/

No .claude.json in the $HOME root. Each profile is a separate isolated directory holding everything: the projects/sessions folders and the same file with MCP servers and OAuth tokens. One source of truth per profile.

Anything that hard-codes the ~/.claude/ path — old scripts, plugins, Claude Code IDE extensions, statusline configs like claude-powerline.json — keeps working without changes. The symlink resolves to the personal profile. If you accidentally run command claude (bypassing the wrapper function), you also land in personal through default-path lookup. Personal becomes the "quiet default" it used to be, but now it physically lives in the XDG location.

Interactive picker on launch — function + gum

Instead of aliases — a claude() function in ~/.zshrc that shows an arrow-key menu via gum (Charm's TUI helper). The function intercepts the claude call at the shell level, lets you pick a profile, and runs command claude with the matching CLAUDE_CONFIG_DIR. command matters here — it bypasses the wrapper function and invokes the real binary.

~/.zshrc
# brew install gum

# Claude Code: profile picker on launch
claude() {
  local profile
  profile=$(gum choose \
    --header "Claude profile:" \
    --cursor "▸ " \
    --selected.foreground 212 \
    --cursor.foreground 212 \
    --header.foreground 244 \
    "personal" "promova") || return
  case "$profile" in
    personal) CLAUDE_CONFIG_DIR="$HOME/.config/claude-profiles/personal" command claude "$@" ;;
    promova)  CLAUDE_CONFIG_DIR="$HOME/.config/claude-profiles/promova"  command claude "$@" ;;
  esac
}

What it looks like on launch:

Claude profile:
▸ personal
  promova

↑↓ to navigate, Enter to pick, Esc to cancel (Claude simply doesn't start). The profile is always visible — you can't miss it.

Migration script

For anyone reading this who wants to move off the old scheme. The most critical step is the second one: it merges ~/.claude.json from the home root with what's already in the personal profile, combining the mcpServers lists. Without that step the profile loses both its MCPs and all of its live state.

Terminal
# 1. Move existing dotfolders into a new XDG-style parent.
#    APFS mv is an inode rename — safe even if claude --resume is open in
#    another terminal, file descriptors stay alive on the same inode.
mkdir -p ~/.config/claude-profiles
mv ~/.claude          ~/.config/claude-profiles/personal
mv ~/.claude-promova  ~/.config/claude-profiles/promova   # rename as needed

# 2. CRITICAL: merge ~/.claude.json (the live state — likely 100+ KB) into
#    the personal profile, unioning mcpServers so no MCP is dropped.
jq -s '.[0] * {mcpServers: (.[0].mcpServers + .[1].mcpServers)}' \
  ~/.claude.json \
  ~/.config/claude-profiles/personal/.claude.json \
  > /tmp/personal-merged.json
mv /tmp/personal-merged.json ~/.config/claude-profiles/personal/.claude.json

# 3. Fix permissions — jq+mv inherits umask (likely 644), but this file
#    holds OAuth tokens. Tighten to 600 immediately.
chmod 600 ~/.config/claude-profiles/personal/.claude.json

# 4. Back up the orphaned home-root file (delete once verified)
mv ~/.claude.json ~/.claude.json.migrated.bak

# 5. Symlink for backward compatibility (statusline configs, IDE plugins,
#    anything that hardcodes ~/.claude/ keeps working unchanged)
ln -s ~/.config/claude-profiles/personal ~/.claude

The third step about permissions is its own important thing. jq | mv creates a file with umask 644 (world-readable). It has OAuth tokens in it. chmod 600 immediately after the merge is mandatory.

After migration — close and reopen all active Claude sessions, reload the shell (source ~/.zshrc or a new terminal), run claude, pick a profile, verify via claude mcp list that all MCPs are in place. If everything is good — delete ~/.claude.json.migrated.bak. If something is off — rollback is trivial: mv ~/.claude.json.migrated.bak ~/.claude.json and remove the symlink.

What you get

  • One parent directory instead of two dotfolders in $HOME — XDG-compliant
  • The symlink preserves compatibility with anything that hard-codes ~/.claude/
  • Each profile is self-contained — its full state and its MCPs live in its own .claude.json
  • One source of truth per profile — no more orphan config in the home root quietly drifting apart from the profile one
  • Sensitive data (oauthAccount, tokens) is guaranteed to have 600 permissions
  • Visual confirmation of the active profile on every launch — you can't forget which profile is active
  • Adding a third profile = adding one line to the case in the function, not cloning a new alias and remembering its name

Where it falls short

I want to be honest. This isn't a free improvement — a few trade-offs came with it, and they're worth knowing up front.

  • gum is an extra dependency (brew install gum, ~13 MB). If you really don't want to install it — fall back to select in zsh or a plain read. Works, but it doesn't look as nice and there's no arrow-key navigation.
  • One Enter press on every launch. For people who fire up claude dozens of times a day, this can get annoying. Alternative below (direnv).
  • The ~/.claude → personal symlink makes personal the default. If you need the work profile as the default, you have to repoint the symlink (ln -sf). Not hard, but it's not "forget about it and nothing breaks".
  • The symlink could theoretically break if some tool atomically rewrites ~/.claude.json via a temp+rename pattern (write-file-atomic). In practice Claude Code itself doesn't do that, but if you install third-party plugins — check.
  • If you have different Anthropic accounts on different profiles with different plans — there can be a sub-second lag after switching while Claude Code syncs OAuth state. In my usage it's not noticeable, but it isn't zero.

Alternatives I considered

direnv — automatically sets CLAUDE_CONFIG_DIR based on an .envrc at the root of each project. Zero interaction, zero clicks. Downside: you have to drop an .envrc into every work root, and if you run claude in an unrecognized folder, you get the default profile (which might not be the one you want). For people who live inside a small set of work roots and want to never click anything, direnv is genuinely better.

Symlink-based switching (one active profile via repointing the ~/.claude symlink) I also considered and immediately dropped. You can't have two terminals with different profiles open at the same time — the global "current" is single. For me that's a deal-breaker.

Conclusion

v2 isn't just better UX on top of v1. It's an acknowledgement that Claude Code has a hidden architectural quirk (~/.claude.json as a separate file in the home root, written by --scope user commands regardless of CLAUDE_CONFIG_DIR) that you have to account for if you want real isolation between profiles. The first approach (~/.claude + ~/.claude-promova + alias) worked 80% of the way, but the remaining 20% showed up as silent state drift between profiles. Now it's accounted for. If you're just starting — start with v2 directly. If you're already on v1 — the migration script is above, the move takes five minutes and breaks nothing (the jq merge is the step that saves you from losing state).