Skip to main content
Tilbage til bloggen
claude-codeproductivityclidevtools

Claude Code Multi-Profile, v2: en ren XDG-arkitektur

Efter en måned med den gamle alias-baserede tilgang byggede jeg mit Claude Code multi-profile-setup om til en XDG-kompatibel struktur under ~/.config/claude-profiles/. Den egentlige grund var ikke ryddelighed — det var opdagelsen af, at ~/.claude.json er en separat fil i home-roden, hvor MCP-serverne tilføjet via --scope user stille boede.

Udgivet 13. juni 20269 min læsning

For en måned siden udgav jeg et indlæg om at køre to Claude Code-konti parallelt på den samme maskine — privat og arbejde, via CLAUDE_CONFIG_DIR og et shell-alias. Tilgangen virkede. Alt gjorde, hvad det skulle.

Men efter en måneds reel brug stødte jeg på et par væsentlige problemer, som det indlæg ikke dækkede. Det største — en skjult særegenhed ved Claude Code, som jeg ved et tilfælde trådte midt i, da jeg tilføjede Gmail-MCP'en, og den "forsvandt" fra min profil fem minutter senere. I dag byggede jeg det hele om til en ny arkitektur — XDG-kompatibel, med et symlink og en interaktiv profilvælger via gum. Dette er v2 — en evolution ud af reel erfaring, ikke en teoretisk forbedring.

Det, der begyndte at irritere

Fire ting. De første tre handler om ryddelighed, synlighed og skala. Den fjerde er den ægte arkitektoniske fælde, som jeg ikke så med det samme. Jeg går dem igennem i rækkefølge, for det er netop den fjerde, der til sidst tvang ombygningen.

1. To separate mapper i $HOME — det er roderi

~/.claude og ~/.claude-promova — to dotfolders side om side i roden af $HOME. XDG Base Directory Specification siger, at configs hører hjemme i ~/.config/. Dotfile-mapper spredt direkte i home er et antimønster, der med tiden gør $HOME til en skuffe med rod. Kosmetisk, javel, men det irriterede mig hver gang, jeg så ls -la ~.

2. Ingen visuel bekræftelse på aktiv profil

Jeg starter claude og ved ikke, hvilken profil der er aktiv — før jeg kører claude config list inde i sessionen. Hvis jeg har glemt, hvilken terminal jeg har startet hvor, må jeg tjekke. En bagatel, men med personal + work kørende parallelt i to faner hober det sig op.

3. Aliaser skalerer ikke

To profiler — claude og claude-promova — det er fint. Tilføj en tredje (en freelancekunde) — så skal der bruges et tredje alias. En fjerde — et fjerde. Efter et halvt år ville jeg ikke huske, hvilke aliaser jeg faktisk havde oprettet.

4. Den skjulte fælde i ~/.claude.json

Og dette er den egentlige grund til ombygningen. Claude Code har to forskellige steder til konfiguration, og dokumentationen råber det ikke ud: ~/.claude/ — kataloget, hvor projects/, sessions/, hooks/, skills/ ligger. Og separat — ~/.claude.json, en fil direkte i $HOME-roden, hvor oauthAccount, mcpServers, projects-historikken, skillUsage og omkring 40 andre felter af ægte live state bor.

Kommandoen claude mcp add --scope user skriver netop til ~/.claude.json i home-roden og ikke til ~/.claude/.claude.json eller til profilkataloget. Det vidste jeg ikke. Indtil jeg en dag trådte lige i det.

Discovery: hvorfor Gmail-MCP'en "forsvandt"

I morges satte jeg Gmail-MCP'en op i Claude Code. Sædvanligt setup: Google Cloud-projekt, OAuth-credentials, claude mcp add gmail --scope user -- npx -y @gongrzhe/server-gmail-autoauth-mcp. Alt OK. Genstartede sessionen — virker, jeg læser mail, svarer på beskeder. En time senere begyndte vi at refaktorere aliaserne til en funktion med gum-profilvælger, og derefter flyttede vi alt over på XDG. Jeg kørte mv ~/.claude → ~/.config/claude-profiles/personal, genstartede CC, valgte personal i menuen. Og i den nye session åbnede jeg /mcp:

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

Ingen gmail. Ingen vaultforge. Kun tre servere, hvoraf én oven i købet var failed. Og jeg havde lige tilføjet Gmail. Imens viste sessionen i en anden terminal (work-profilen) Gmail og Vaultforge uden mindste problem.

Jeg begyndte at grave og fandt, at jeg på maskinen havde tre forskellige filer med navnet .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

Der var den. Det er netop dét, der er den arkitektoniske fælde:

  1. claude mcp add --scope user skriver altid til ~/.claude.json i home-roden, uafhængigt af CLAUDE_CONFIG_DIR
  2. Når CLAUDE_CONFIG_DIR er sat, læser Claude Code $CLAUDE_CONFIG_DIR/.claude.json — altså filen inde i profilen
  3. Posterne for "user-scope MCPs" og "profilens MCPs" bor i forskellige filer med samme navn — og de er lette at forveksle

I mit tilfælde var ~/.claude.json (home-roden, 113 KB) den levende, aktuelle tilstand — med gmail, vaultforge, OAuth-sessionen, det hele. Og ~/.config/claude-profiles/personal/.claude.json (29 KB) viste sig at være en gammel snapshot, der af en eller anden grund allerede tidligere lå i gamle ~/.claude/ — måske skrev en ældre CC-version dertil, måske et plugin. jq -r 'keys[]' på begge filer viste, at home-root-versionen havde 41 unikke nøgler, som ikke fandtes i snapshotten.

Og de 41 nøgler er ikke skrald. Det er Claude Codes virkelige tilstand:

  • skillUsage — brugsstatistik for skills
  • githubRepoPaths — repo-cache til projektnavigering
  • cachedGrowthBookFeatures + cachedStatsigGates — feature flags (uden dem henter CC nye ved hver start)
  • hasShownOpus45Notice, hasShownOpus46Notice, hasShownS1MWelcomeV2 — UI-flag (uden dem dukker modalerne op igen ved næste start)
  • lastPlanModeUse, feedbackSurveyState, installMethod — onboarding- og UX-state

Hvis du bare kører mv ~/.claude ~/.config/claude-profiles/personal uden merge — mister du det hele. Du ser welcome-modalerne igen, githubRepoPaths-søgningen kører på ny, alle survey-prompts vender tilbage. Som jeg nær havde gjort.

Den nye arkitektur

Alt bor under ét fælles forældrekatalog i ~/.config/, sådan som XDG vil have det. Hver profil er selvstændig — den har sin fulde state inklusive sin egen .claude.json. ~/.claude bliver tilbage som symlink til personal-profilen for bagudkompatibilitet.

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

Ingen .claude.json i $HOME-roden. Hver profil er et separat, isoleret katalog, hvor alt ligger: projects/sessions-mapperne og den samme fil med MCP-servere og OAuth-tokens. Én source of truth pr. profil.

Alt, der hardkoder stien ~/.claude/ — gamle scripts, plugins, Claude Codes IDE-udvidelser, statusline-configs som claude-powerline.json — bliver ved med at virke uden ændringer. Symlinket resolves til personal-profilen. Hvis du ved et tilfælde kører command claude (uden wrapper-funktionen) — havner du også i personal via default-path lookup. Personal bliver den "quiet default", den var før, men bor nu fysisk på XDG-pladsen.

Interaktiv vælger ved start — funktion + gum

I stedet for aliaser — en funktion claude() i ~/.zshrc, der viser en pilemenu via gum (Charms TUI-hjælper). Funktionen opfanger claude-kaldet på shell-niveau, lader dig vælge en profil og kører command claude med det tilsvarende CLAUDE_CONFIG_DIR. command er vigtigt — det går uden om wrapper-funktionen og kalder den ægte binær.

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

Sådan ser det ud ved start:

Claude profile:
▸ personal
  promova

↑↓ for at navigere, Enter for at vælge, Esc for at afbryde (Claude starter simpelthen ikke). Profilen er altid synlig — umuligt at overse.

Fra bunden (ingen v1 at migrere)

Hvis du allerede har kørt det gamle to-mappe-hack med alias, så spring direkte til migrationsscriptet nedenfor. Dette afsnit er den rene opbygning fra nul — en frisk maskine, eller bare den default ~/.claude, der opstår ved almindelig brug. Beslut først formen: én konto med flere profiler (for at isolere projekter og deres MCP-sæt — det almindelige tilfælde), eller reelt forskellige konti pr. profil (privat vs. arbejde).

1. Læg mapperne ud

Terminal — one-time setup
# prerequisites
brew install gum jq

# 1. XDG parent for all profiles
mkdir -p ~/.config/claude-profiles

# 2. fold an existing default install in as your first profile (no-ops on a fresh machine)
if [ -d ~/.claude ] && [ ! -L ~/.claude ]; then
  mv ~/.claude ~/.config/claude-profiles/personal
  [ -f ~/.claude.json ] && mv ~/.claude.json ~/.config/claude-profiles/personal/.claude.json
fi

# 3. one directory = one profile
mkdir -p ~/.config/claude-profiles/personal ~/.config/claude-profiles/work

# 4. these files will hold OAuth tokens — lock them down
chmod 600 ~/.config/claude-profiles/*/.claude.json 2>/dev/null

Én mappe pr. profil under ét fælles XDG-forældrekatalog. Hvis du allerede har brugt Claude Code, folder snippet'et din eksisterende ~/.claude ind som personal og bærer home-rodens ~/.claude.json live state med over; på en helt frisk maskine gør de linjer ganske enkelt ingenting. Alt andet er tomme mapper, der fylder sig selv ved første start — og chmod 600 betyder noget, for disse filer ender med at indeholde OAuth-tokens.

2. Autentificér hver profil

Terminal
# launch each profile once and authenticate it
CLAUDE_CONFIG_DIR=~/.config/claude-profiles/work claude
#   → run /login in the session, sign in, done.
# Same account for every profile? One /login is enough — macOS keeps it in the
# shared Keychain, so the other profiles are already authenticated.

Start hver profil én gang med sin egen CLAUDE_CONFIG_DIR og kør /login. Den afgørende detalje: hvis hver profil bruger samme konto, logger du kun ind én gang — macOS holder credentialen i én delt Keychain-post, så enhver anden profil bliver autentificeret automatisk.

Forskellige konti pr. profil er den vanskeligere vej: den delte Keychain-post rummer ét login, så når du logger en anden konto ind i en anden profil, kan det overskrive det første. Er det dit tilfælde, så følg den dedikerede gennemgang i at køre to Claude-konti på én maskine, og verificér credential-isolationen på din version af Claude Code, før du læner dig op ad den.

Terminal + ~/.zshrc
# personal becomes the default target of ~/.claude (back-compat)
ln -s ~/.config/claude-profiles/personal ~/.claude

# add the claude() gum picker from the architecture section to ~/.zshrc, then:
source ~/.zshrc
claude        # ← now shows the profile menu on every launch

Peg ~/.claude mod personal-profilen, så alt, der hardkoder den sti, bliver ved med at virke, og læg så claude()-gum-vælgeren fra arkitekturafsnittet ovenover ind i ~/.zshrc. Efter en genindlæsning viser claude profilmenuen ved hver start — samme sluttilstand som det migrerede setup, bare bygget fra frisk.

4. Kuratér hver profils MCP

Til sidst: giv hver profil kun de værktøjer, den bør se: sæt disableClaudeAiConnectors: true i den profils settings.json og tilføj det, den har brug for, tilbage med claude mcp add, præcis som i connector-afsnittet ovenover. Det er dét, der gør ti udskiftelige profiler til ti formålsbyggede.

Migrationsscript

Til den, der læser dette og vil flytte fra det gamle skema. Det mest kritiske trin er det andet: det merger ~/.claude.json fra home-roden med det, der allerede ligger i personal-profilen, og samler mcpServers-listerne. Uden det trin mister profilen både sine MCP'er og hele sin 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

Det tredje trin om rettigheder er vigtigt for sig selv. jq | mv opretter en fil med umask 644 (world-readable). Inde i den ligger OAuth-tokens. chmod 600 umiddelbart efter mergen er obligatorisk.

Efter migrationen — luk og åbn alle aktive Claude-sessioner, genindlæs shellet (source ~/.zshrc eller ny terminal), kør claude, vælg en profil, tjek via claude mcp list at alle MCP'er er på plads. Hvis alt er OK — slet ~/.claude.json.migrated.bak. Hvis noget er galt — rollback er trivielt: mv ~/.claude.json.migrated.bak ~/.claude.json og fjern symlinket.

Bonus: MCP-connectors pr. profil (når din org deler dem)

Arkitekturen ovenover åbner en anden akse af isolation — og det blev faktisk det, jeg bruger allermest. Hvis din Claude-konto hører til en organisation, kan organisationen udlevere et fælles sæt remote MCP-connectors (Figma, en ticket-tracker, et metrics-backend, en database-gateway, et CMS…). Claude Code henter automatisk hver eneste connector, organisationen har slået til, ind i hver eneste session, fordi de er bundet til kontoen, ikke til det katalog, du startede fra.

Med to eller tre connectors er det fint. Med ti projekter og et dusin org-connectors holder det op med at være fint: hver session indlæser værktøjer, den intet har at gøre med. Det er støj i modellens værktøjsliste, det er spildt kontekst, og — den del, der virkelig bider — det lader en session række ind i et system, der tilhører et andet projekt. Connectoren til projekt A burde slet ikke eksistere, mens du arbejder på projekt B.

Slå auto-hentningen fra, og kurater så

Håndtaget er en enkelt indstilling, disableClaudeAiConnectors (Claude Code v2.1.182+). Den lever i et hvilket som helst settings-scope — inklusive en profils egen settings.json — så den er pr. CLAUDE_CONFIG_DIR. Sæt den til true, og den profil holder helt op med at auto-hente org-connectors:

~/.config/claude-profiles/frontend/settings.json
{
  "disableClaudeAiConnectors": true
}

Tilføj derefter kun de connectors tilbage, som den profil faktisk har brug for. Servere, du tilføjer eksplicit, bliver ikke rørt af flaget, så de indlæses normalt — resultatet er en kurateret delmængde, profil for profil:

Terminal — inside the frontend profile
# add back only what this profile uses — writes to the profile's own .claude.json
claude mcp add --scope user --transport http figma  https://<your-figma-connector>/mcp
claude mcp add --scope user --transport http linear https://<your-linear-connector>/mcp
ProfilHvad den indlæser
frontendFigma, Linear, Sentry
backendPostgres, Grafana, Linear
contentNotion, Figma, Linear

Samme konto, samme org-katalog — men hver profil ser kun sin skive, plus de fælles connectors, du vælger at gentage (en ticket-tracker fortjener som regel sin plads alle steder).

Jeg testede det, før jeg stolede på det

Jeg ville ikke bare tro på dokumentationen, så jeg kørte en ren A/B på én profil — samme konto, samme OAuth-token, hvor den eneste forskel var den ene settings.json:

A/B on one profile
profile WITHOUT the flag
  /mcp → Figma, Linear, Grafana, Postgres, Notion, Sentry, …   (entire org catalog)

same account, profile WITH  disableClaudeAiConnectors: true
  /mcp → (nothing auto-loaded)

…then  claude mcp add  Figma + Linear
  /mcp → Figma, Linear                                         (exactly the curated subset)

Det gør præcis, hvad det lover. To forbehold, der er værd at kende, før du læner dig op ad det:

  • At tilføje en OAuth-connector igen kræver ét interaktivt login. Connectors, der autentificerer via key-in-URL, kommer fuldautomatisk tilbage via claude mcp add. Connectors bag OAuth beder om en engangs-browser-auth, første gang du bruger dem i den profil.
  • claude mcp list viser slet ikke org-connectors — kun lokalt definerede og eksplicit tilføjede servere. De auto-hentede er kun synlige inde i en live session via /mcp. Gå ikke i panik, når de ikke dukker op i mcp list.

Det, du får

  • Ét fælles forældrekatalog i stedet for to dotfolders i $HOME — XDG-kompatibelt
  • Symlinket bevarer kompatibilitet med alt, der hardkoder ~/.claude/
  • Hver profil er selvstændig — sin fulde state og sine MCP'er i sin egen .claude.json
  • Én source of truth pr. profil — slut med forældreløs config i home-roden, der stille driver fra profilens
  • Følsomme data (oauthAccount, tokens) garanteret med rettigheder 600
  • Visuel bekræftelse på aktiv profil ved hver start — umuligt at glemme, hvilken profil der er aktiv
  • At tilføje en tredje profil = at tilføje én linje i case i funktionen, ikke klone et nyt alias og huske dets navn

Hvor det halter

Jeg vil være ærlig. Dette er ikke en gratis forbedring — et par kompromiser fulgte med i pakken, og dem er det værd at kende på forhånd.

  • gum er en ekstra afhængighed (brew install gum, ~13 MB). Hvis du principielt ikke vil installere det — fallback til select i zsh eller en simpel read. Virker, men ser ikke lige så pænt ud og mangler pilenavigation.
  • Et Enter-tryk ved hver start. For den, der starter claude dusinvis af gange om dagen — kan irritere. Alternativ nedenfor (direnv).
  • Symlinket ~/.claude → personal gør personal til default. Hvis du har brug for work-profilen som default, skal du pege symlinket om (ln -sf). Ikke svært, men det er ikke "glem det og intet går i stykker".
  • Symlinket kan teoretisk gå i stykker, hvis et eller andet værktøj atomisk overskriver ~/.claude.json via et temp+rename-mønster (write-file-atomic). I praksis gør Claude Code det ikke selv, men hvis du installerer tredjepartsplugins — så tjek.
  • Hvis du har forskellige Anthropic-konti på profilerne med forskellige planer — efter skift kan der være et sub-sekund lag, mens Claude Code synker OAuth-state. I min brug er det ikke mærkbart, men det er ikke nul.

Alternativer, jeg overvejede

direnv — sætter CLAUDE_CONFIG_DIR automatisk baseret på en .envrc i roden af hvert projekt. Nul interaktion, nul klik. Minus: du skal lægge en .envrc i hvert work-root, og hvis du kører claude i en ukendt mappe — får du default-profilen (som måske ikke er den, du vil have). For den, der lever i et begrænset antal work-roots og vil slippe for at klikke — er direnv reelt bedre.

Symlink-baseret skift (én aktiv profil ved at pege ~/.claude-symlinket om) overvejede jeg også og forkastede med det samme. Du kan ikke have to terminaler med forskellige profiler åbne på samme tid — det globale "aktuelle" er ét. For mig er det en deal-breaker.

Konklusion

v2 er ikke bare bedre UX oven på v1. Det er en anerkendelse af, at Claude Code har en skjult arkitektonisk særegenhed (~/.claude.json som separat fil i home-roden, skrevet af --scope user-kommandoer uafhængigt af CLAUDE_CONFIG_DIR), som man skal tage højde for, hvis man vil have ægte isolation mellem profiler. Den første tilgang (~/.claude + ~/.claude-promova + alias) virkede til 80 %, men de resterende 20 % viste sig som stille state-drift mellem profilerne. Nu er der taget højde for det. Hvis du lige er begyndt — start direkte på v2. Hvis du allerede sidder på v1 — migrationsscriptet er ovenover, flytningen tager fem minutter og bryder ingenting (det er netop jq-mergen, der er det trin, som redder dig fra at miste state).