Skip to main content
← Zurück zum Blog
claude-codeproductivityclidevtools

Claude Code Multi-Profile, v2: eine saubere XDG-Architektur

Nach einem Monat mit dem alten Alias-Ansatz habe ich mein Claude-Code-Multi-Profile-Setup auf eine XDG-konforme Struktur unter ~/.config/claude-profiles/ umgebaut. Der eigentliche Grund war keine Ordnungsliebe — ich habe entdeckt, dass ~/.claude.json eine separate Datei im Home-Root ist, in der die per --scope user hinzugefügten MCP-Server still vor sich hin lebten.

Veröffentlicht 13. Juni 20267 Min. Lesezeit

Vor einem Monat habe ich einen Beitrag darüber veröffentlicht, wie man zwei Claude-Code-Accounts parallel auf einem Gerät betreibt — privat und beruflich, über CLAUDE_CONFIG_DIR und einen Shell-Alias. Der Ansatz hat funktioniert. Alles hat das getan, was es tun sollte.

Aber nach einem Monat realem Einsatz bin ich auf ein paar substanzielle Probleme gestoßen, die jener Beitrag nicht abgedeckt hat. Das größte davon — eine versteckte Eigenheit von Claude Code, in die ich zufällig hineingelaufen bin, als ich den Gmail-MCP hinzugefügt habe und er fünf Minuten später aus meinem Profil "verschwunden" war. Heute habe ich alles auf eine neue Architektur umgebaut — XDG-konform, mit einem Symlink und einem interaktiven Profil-Picker über gum. Das ist v2 — eine Evolution aus realer Erfahrung, keine theoretische Verbesserung.

Was angefangen hat zu nerven

Vier Sachen. Die ersten drei drehen sich um Sauberkeit, Sichtbarkeit und Skalierung. Die vierte ist der echte Architektur-Gotcha, den ich nicht sofort gesehen habe. Ich gehe sie der Reihe nach durch, denn genau die vierte hat am Ende den Umbau erzwungen.

1. Zwei separate Ordner in $HOME — das ist Unordnung

~/.claude und ~/.claude-promova — zwei Dotfolder direkt nebeneinander im Root von $HOME. Die XDG Base Directory Specification sagt, dass Configs in ~/.config/ gehören. Dotfile-Ordner, die direkt in Home herumliegen, sind ein Antipattern, das $HOME mit der Zeit zur Rumpelkammer macht. Kosmetisch, klar, aber es hat mich jedes Mal genervt, wenn ich ls -la ~ gemacht habe.

2. Keine visuelle Bestätigung des aktiven Profils

Ich starte claude und weiß nicht, welches Profil aktiv ist — bis ich innerhalb der Session claude config list ausführe. Wenn ich vergessen habe, welches Terminal ich wo gestartet habe, muss ich nachschauen. Eine Kleinigkeit, aber bei parallelem Arbeiten mit personal + work in zwei Tabs summiert sich das.

3. Aliase skalieren nicht

Zwei Profile — claude und claude-promova — okay. Ein drittes dazu (ein Freelance-Kunde) — brauchst du einen dritten Alias. Ein viertes — einen vierten. Nach einem halben Jahr wüsste ich nicht mehr, welche Aliase ich überhaupt angelegt hatte.

4. Die versteckte Falle in ~/.claude.json

Und das ist der eigentliche Grund für den Umbau. Claude Code hat zwei verschiedene Orte für Konfiguration, und die Doku schreit das nicht heraus: ~/.claude/ — das Verzeichnis mit projects/, sessions/, hooks/, skills/. Und separat — ~/.claude.json, eine Datei direkt im $HOME-Root, in der oauthAccount, mcpServers, die projects-History, skillUsage und etwa 40 weitere Felder echten Live-State liegen.

Der Befehl claude mcp add --scope user schreibt genau in dieses ~/.claude.json im Home-Root, nicht in ~/.claude/.claude.json oder ins Profilverzeichnis. Das wusste ich nicht. Bis ich eines Tages reingelaufen bin.

Discovery: warum der Gmail-MCP "verschwunden" ist

Heute Morgen habe ich den Gmail-MCP in Claude Code eingerichtet. Standard-Setup: Google-Cloud-Projekt, OAuth-Credentials, claude mcp add gmail --scope user -- npx -y @gongrzhe/server-gmail-autoauth-mcp. Alles okay. Session neu gestartet — läuft, ich lese Mails, antworte auf Nachrichten. Eine Stunde später haben wir angefangen, die Aliase in eine Funktion mit gum-Profil-Picker zu refaktorieren, und danach alles auf XDG umgezogen. Ich habe mv ~/.claude → ~/.config/claude-profiles/personal gemacht, CC neu gestartet, im Menü personal gewählt. Und in der neuen Session /mcp geöffnet:

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

Kein gmail. Kein vaultforge. Nur drei Server, von denen einer sogar failed war. Dabei hatte ich Gmail gerade hinzugefügt. Die Session in einem anderen Terminal (Work-Profil) zeigte Gmail und Vaultforge gleichzeitig ohne Probleme.

Ich habe angefangen zu graben und entdeckt, dass ich auf meinem Rechner drei verschiedene Dateien namens .claude.json habe:

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

Da war es. Genau das ist der Architektur-Gotcha:

  1. claude mcp add --scope user schreibt immer in ~/.claude.json im Home-Root, unabhängig von CLAUDE_CONFIG_DIR
  2. Claude Code liest bei gesetztem CLAUDE_CONFIG_DIR die Datei $CLAUDE_CONFIG_DIR/.claude.json — also die Datei innerhalb des Profils
  3. Die Einträge "user-scope MCPs" und "Profil-MCPs" liegen in verschiedenen Dateien mit gleichem Namen — und lassen sich leicht verwechseln

In meinem Fall war ~/.claude.json (Home-Root, 113 KB) der lebendige, aktuelle State — mit gmail, vaultforge, OAuth-Session, alles. Und ~/.config/claude-profiles/personal/.claude.json (29 KB) erwies sich als alter Snapshot, der irgendwie schon vorher im alten ~/.claude/ lag — vielleicht hat eine ältere CC-Version dort geschrieben, vielleicht ein Plugin. jq -r 'keys[]' auf beiden Dateien zeigte, dass die Home-Root-Version 41 unique Keys hatte, die im Snapshot fehlten.

Und diese 41 Keys sind kein Müll. Das ist der echte State von Claude Code:

  • skillUsage — Nutzungsstatistik der Skills
  • githubRepoPaths — Repo-Cache für Project-Navigation
  • cachedGrowthBookFeatures + cachedStatsigGates — Feature Flags (ohne sie holt CC bei jedem Start frische)
  • hasShownOpus45Notice, hasShownOpus46Notice, hasShownS1MWelcomeV2 — UI-Flags (ohne sie erscheinen die Modals beim nächsten Start wieder)
  • lastPlanModeUse, feedbackSurveyState, installMethod — Onboarding- und UX-State

Wenn du einfach mv ~/.claude ~/.config/claude-profiles/personal ohne Merge machst — verlierst du das alles. Welcome-Modals kommen zurück, githubRepoPaths-Suche läuft erneut, alle Survey-Aufforderungen kommen wieder. So wie ich es fast gemacht hätte.

Die neue Architektur

Alles lebt unter einem Eltern-Verzeichnis in ~/.config/, wie XDG es will. Jedes Profil ist self-contained — hat seinen vollen State inklusive eigener .claude.json. ~/.claude bleibt als Symlink auf das Personal-Profil für Abwärtskompatibilität.

~/.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/

Keine .claude.json im $HOME-Root. Jedes Profil ist ein separates isoliertes Verzeichnis, in dem alles liegt: die Ordner projects/sessions und dieselbe Datei mit MCP-Servern und OAuth-Tokens. Eine Source of Truth pro Profil.

Alles, was den Pfad ~/.claude/ hardcodet — alte Skripte, Plugins, Claude-Code-IDE-Extensions, Statusline-Configs wie claude-powerline.json — funktioniert weiter ohne Änderungen. Der Symlink wird ins Personal-Profil aufgelöst. Wenn du versehentlich command claude (ohne Wrapper-Funktion) startest — landest du ebenfalls über den Default-Path-Lookup im Personal. Personal wird zum "quiet default", wie es vorher war, lebt aber jetzt physisch an der XDG-Location.

Interaktiver Picker beim Start — Funktion + gum

Statt Aliase — eine claude()-Funktion in ~/.zshrc, die ein Pfeil-Menü über gum (TUI-Helper von Charm) zeigt. Die Funktion fängt den claude-Aufruf auf Shell-Ebene ab, lässt dich ein Profil wählen und startet command claude mit dem passenden CLAUDE_CONFIG_DIR. command ist wichtig — es umgeht die Wrapper-Funktion und ruft das echte Binary auf.

~/.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
}

So sieht es beim Start aus:

Claude profile:
▸ personal
  promova

↑↓ zum Navigieren, Enter zum Auswählen, Esc zum Abbrechen (Claude startet dann einfach nicht). Das Profil ist immer sichtbar — vergessen unmöglich.

Migrationsskript

Für alle, die das lesen und vom alten Schema umziehen wollen. Der kritischste Schritt ist der zweite: er merged ~/.claude.json aus dem Home-Root mit dem, was bereits im Personal-Profil liegt, und vereint die mcpServers-Listen. Ohne diesen Schritt verliert das Profil sowohl die MCPs als auch den gesamten 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

Der dritte Schritt zu den Rechten ist für sich genommen wichtig. jq | mv erzeugt eine Datei mit umask 644 (world-readable). Darin stecken OAuth-Tokens. chmod 600 sofort nach dem Merge ist Pflicht.

Nach der Migration — alle aktiven Claude-Sessions schließen und neu öffnen, Shell neu laden (source ~/.zshrc oder neues Terminal), claude starten, Profil wählen, über claude mcp list prüfen, dass alle MCPs da sind. Wenn alles passt — ~/.claude.json.migrated.bak löschen. Wenn etwas nicht stimmt — Rollback ist trivial: mv ~/.claude.json.migrated.bak ~/.claude.json und den Symlink entfernen.

Was du bekommst

  • Ein Eltern-Verzeichnis statt zweier Dotfolder in $HOME — XDG-konform
  • Der Symlink erhält die Kompatibilität mit allem, was ~/.claude/ hardcodet
  • Jedes Profil ist self-contained — sein voller State und seine MCPs liegen in seiner eigenen .claude.json
  • Eine Source of Truth pro Profil — keine Orphan-Config im Home-Root mehr, die still vom Profil-Pendant abdriftet
  • Sensitive Daten (oauthAccount, Tokens) haben garantiert Rechte 600
  • Visuelle Bestätigung des aktiven Profils bei jedem Start — du kannst nicht vergessen, welches Profil aktiv ist
  • Ein drittes Profil hinzufügen = eine Zeile im case der Funktion hinzufügen, kein neuer Alias zum Klonen und kein Name zum Merken

Wo es Schwächen hat

Ich will ehrlich sein. Das ist keine kostenlose Verbesserung — ein paar Kompromisse kamen mit dazu, und die solltest du vorher kennen.

  • gum ist eine zusätzliche Abhängigkeit (brew install gum, ~13 MB). Wenn du es prinzipiell nicht installieren willst — Fallback auf select in zsh oder ein simples read. Funktioniert, sieht aber nicht so gut aus und hat keine Pfeil-Navigation.
  • Ein Enter-Druck bei jedem Start. Für Leute, die claude dutzende Male am Tag starten — kann nerven. Alternative unten (direnv).
  • Der Symlink ~/.claude → personal macht personal zum Default. Wenn du das Work-Profil als Default brauchst, musst du den Symlink umbiegen (ln -sf). Nicht schwer, aber es ist nicht "vergessen und nichts bricht".
  • Der Symlink kann theoretisch brechen, wenn irgendein Tool ~/.claude.json atomar über ein Temp+Rename-Pattern (write-file-atomic) überschreibt. In der Praxis macht Claude Code das selbst nicht, aber wenn du Drittanbieter-Plugins installierst — prüf das.
  • Wenn du auf den Profilen unterschiedliche Anthropic-Accounts mit unterschiedlichen Plans hast — kann es nach einem Wechsel einen Sub-Sekunden-Lag geben, während Claude Code den OAuth-State synct. In meiner Nutzung nicht spürbar, aber nicht null.

Alternativen, die ich erwogen habe

direnv — setzt CLAUDE_CONFIG_DIR automatisch abhängig von einer .envrc im Root jedes Projekts. Null Interaktion, null Klicks. Minus: du musst in jedem Work-Root eine .envrc ablegen, und wenn du claude in einem nicht erkannten Ordner startest — bekommst du das Default-Profil (eventuell nicht das, das du willst). Für Leute, die in einer überschaubaren Anzahl von Work-Roots leben und nie klicken wollen — ist direnv tatsächlich besser.

Symlink-basiertes Switching (ein aktives Profil über das Umbiegen des ~/.claude-Symlinks) habe ich ebenfalls erwogen und sofort verworfen. Du kannst nicht zwei Terminals mit unterschiedlichen Profilen gleichzeitig haben — das globale "aktuelle" ist nur eines. Für mich ein Deal-Breaker.

Fazit

v2 ist nicht einfach besseres UX über v1. Es ist das Eingeständnis, dass Claude Code eine versteckte Architektur-Eigenheit hat (~/.claude.json als separate Datei im Home-Root, die von --scope user-Befehlen unabhängig von CLAUDE_CONFIG_DIR geschrieben wird), die man berücksichtigen muss, wenn man echte Isolation zwischen Profilen will. Der erste Ansatz (~/.claude + ~/.claude-promova + Alias) hat zu 80% funktioniert, aber die übrigen 20% äußerten sich in stillem State-Drift zwischen den Profilen. Jetzt ist das berücksichtigt. Wenn du gerade erst anfängst — starte direkt mit v2. Wenn du schon auf v1 sitzt — das Migrationsskript ist oben, der Umzug dauert fünf Minuten und bricht nichts (gerade der jq-Merge ist der Schritt, der dich vor State-Verlust rettet).