Skip to main content
← Retour au blog
claude-codeproductivityclidevtools

Claude Code Multi-Profile, v2 : une architecture XDG propre

Après un mois sur l'ancienne approche par alias, j'ai refait mon setup multi-profile Claude Code dans une structure compatible XDG sous ~/.config/claude-profiles/. La vraie raison n'était pas la propreté — c'était la découverte que ~/.claude.json est un fichier séparé à la racine du home dans lequel vivaient discrètement les serveurs MCP ajoutés via --scope user.

Publié 13 juin 20267 min de lecture

Il y a un mois, j'ai publié un billet sur la façon de faire tourner deux comptes Claude Code en parallèle sur une même machine — personnel et pro, via CLAUDE_CONFIG_DIR et un alias shell. L'approche fonctionnait. Tout faisait ce qu'il devait faire.

Mais après un mois d'utilisation réelle, je suis tombé sur plusieurs problèmes substantiels que ce billet ne couvrait pas. Le principal — une particularité cachée de Claude Code sur laquelle j'ai marché par accident, quand j'ai ajouté le MCP Gmail et qu'il a "disparu" de mon profil cinq minutes plus tard. Aujourd'hui j'ai tout refait sur une nouvelle architecture — compatible XDG, avec un symlink et un sélecteur de profil interactif via gum. C'est la v2 — une évolution issue de l'expérience réelle, pas une amélioration théorique.

Ce qui a commencé à agacer

Quatre choses. Les trois premières concernent la propreté, la visibilité et l'échelle. La quatrième est le vrai piège architectural que je n'avais pas vu tout de suite. Je les passe en revue dans l'ordre, parce que c'est justement la quatrième qui a fini par forcer la refonte.

1. Deux dossiers distincts dans $HOME — c'est le bazar

~/.claude et ~/.claude-promova — deux dotfolders côte à côte à la racine de $HOME. La XDG Base Directory Specification dit que les configs doivent vivre dans ~/.config/. Des dossiers dotfile éparpillés directement dans home, c'est un antipattern qui transforme $HOME en fourre-tout avec le temps. Cosmétique, certes, mais ça m'agaçait à chaque ls -la ~.

2. Pas de confirmation visuelle du profil actif

Je lance claude et je ne sais pas quel profil est actif — tant que je n'ai pas fait claude config list à l'intérieur de la session. Si j'ai oublié quel terminal j'ai lancé où, il faut vérifier. Une broutille, mais avec personal + work qui tournent en parallèle dans deux onglets, ça s'accumule.

3. Les alias ne passent pas à l'échelle

Deux profils — claude et claude-promova — c'est OK. Un troisième s'ajoute (un client freelance) — il faut un troisième alias. Un quatrième — un quatrième. Au bout de six mois, je ne me souviendrais plus quels alias j'ai vraiment créés.

4. Le piège caché de ~/.claude.json

Et c'est la vraie raison de la refonte. Claude Code a deux endroits distincts de configuration, et la doc ne le crie pas : ~/.claude/ — le répertoire qui contient projects/, sessions/, hooks/, skills/. Et séparément — ~/.claude.json, un fichier directement à la racine de $HOME, où vivent oauthAccount, mcpServers, l'historique projects, skillUsage et environ 40 autres champs de véritable état live.

La commande claude mcp add --scope user écrit précisément dans ce ~/.claude.json à la racine du home, et pas dans ~/.claude/.claude.json ni dans le répertoire du profil. Je ne le savais pas. Jusqu'au jour où je suis tombé dedans.

Discovery : pourquoi le MCP Gmail a "disparu"

Ce matin je mettais en place le MCP Gmail dans Claude Code. Setup classique : projet Google Cloud, credentials OAuth, claude mcp add gmail --scope user -- npx -y @gongrzhe/server-gmail-autoauth-mcp. Tout va bien. Session redémarrée — ça marche, je lis mes mails, je réponds. Une heure plus tard on a commencé à refactoriser les alias en fonction avec sélecteur de profil gum, puis on a tout migré vers XDG. J'ai fait mv ~/.claude → ~/.config/claude-profiles/personal, redémarré CC, choisi personal dans le menu. Et dans la nouvelle session, j'ai ouvert /mcp :

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

Pas de gmail. Pas de vaultforge. Juste trois serveurs, dont un même en failed. Alors que je venais d'ajouter Gmail. Pendant ce temps, la session dans un autre terminal (profil work) affichait Gmail et Vaultforge sans le moindre souci.

Je me suis mis à creuser et j'ai découvert que j'avais sur ma machine trois fichiers différents portant le nom .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

Le voilà. C'est ça, le piège architectural :

  1. claude mcp add --scope user écrit toujours dans ~/.claude.json à la racine du home, indépendamment de CLAUDE_CONFIG_DIR
  2. Quand CLAUDE_CONFIG_DIR est défini, Claude Code lit $CLAUDE_CONFIG_DIR/.claude.json — donc le fichier à l'intérieur du profil
  3. Les entrées "user-scope MCPs" et "MCPs du profil" vivent dans des fichiers différents portant le même nom — et il est facile de les confondre

Dans mon cas, ~/.claude.json (racine du home, 113 KB) était l'état vivant et à jour — avec gmail, vaultforge, la session OAuth, tout. Et ~/.config/claude-profiles/personal/.claude.json (29 KB) s'est avéré être un vieux snapshot qui traînait déjà avant dans l'ancien ~/.claude/ — peut-être qu'une ancienne version de CC y écrivait, peut-être un plugin. jq -r 'keys[]' sur les deux fichiers a montré que la version racine-home avait 41 clés uniques absentes du snapshot.

Et ces 41 clés ne sont pas du déchet. C'est le véritable état de Claude Code :

  • skillUsage — statistiques d'utilisation des skills
  • githubRepoPaths — cache des repos pour la navigation projet
  • cachedGrowthBookFeatures + cachedStatsigGates — feature flags (sans elles, CC va en chercher de fraîches à chaque démarrage)
  • hasShownOpus45Notice, hasShownOpus46Notice, hasShownS1MWelcomeV2 — flags UI (sans elles, les modales reviennent au prochain lancement)
  • lastPlanModeUse, feedbackSurveyState, installMethod — état d'onboarding et UX

Si tu fais simplement mv ~/.claude ~/.config/claude-profiles/personal sans merge — tu perds tout ça. Tu revois les modales de welcome, la recherche githubRepoPaths se relance, toutes les sollicitations de survey reviennent. Ce que j'ai failli faire.

La nouvelle architecture

Tout vit sous un unique répertoire parent dans ~/.config/, comme le veut XDG. Chaque profil est autosuffisant — il a tout son état, y compris son propre .claude.json. ~/.claude reste un symlink vers le profil personal pour la rétrocompatibilité.

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

Aucun .claude.json à la racine de $HOME. Chaque profil est un répertoire isolé séparé qui contient tout : les dossiers projects/sessions et ce même fichier avec les serveurs MCP et les tokens OAuth. Une seule source of truth par profil.

Tout ce qui code en dur le chemin ~/.claude/ — vieux scripts, plugins, extensions IDE de Claude Code, configs de statusline comme claude-powerline.json — continue à fonctionner sans changement. Le symlink se résout vers le profil personal. Si par accident tu lances command claude (en court-circuitant la fonction wrapper) — tu atterris aussi dans personal via le lookup du chemin par défaut. Personal devient le "quiet default" qu'il était avant, mais vit désormais physiquement à l'emplacement XDG.

Sélecteur interactif au lancement — fonction + gum

À la place des alias — une fonction claude() dans ~/.zshrc qui affiche un menu fléché via gum (le TUI-helper de Charm). La fonction intercepte l'appel à claude au niveau du shell, laisse choisir un profil et lance command claude avec le CLAUDE_CONFIG_DIR correspondant. command est crucial — ça contourne la fonction wrapper et invoque le vrai binaire.

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

Ce que ça donne au lancement :

Claude profile:
▸ personal
  promova

↑↓ pour naviguer, Enter pour choisir, Esc pour annuler (Claude ne démarre simplement pas). Le profil est toujours visible — impossible de l'oublier.

Script de migration

Pour celles et ceux qui lisent ça et veulent quitter l'ancien schéma. L'étape la plus critique est la deuxième : elle fusionne ~/.claude.json de la racine du home avec ce qui se trouve déjà dans le profil personal, en combinant les listes mcpServers. Sans cette étape, le profil perd à la fois ses MCPs et tout son état live.

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

La troisième étape, sur les permissions, est importante en soi. jq | mv crée un fichier avec umask 644 (world-readable). Il contient des tokens OAuth. chmod 600 juste après le merge est obligatoire.

Après la migration — ferme et rouvre toutes les sessions Claude actives, recharge le shell (source ~/.zshrc ou nouveau terminal), lance claude, choisis un profil, vérifie via claude mcp list que tous les MCPs sont en place. Si tout va bien — supprime ~/.claude.json.migrated.bak. Si quelque chose cloche — le rollback est trivial : mv ~/.claude.json.migrated.bak ~/.claude.json et on retire le symlink.

Ce que tu gagnes

  • Un unique répertoire parent au lieu de deux dotfolders dans $HOME — compatible XDG
  • Le symlink préserve la compatibilité avec tout ce qui code en dur ~/.claude/
  • Chaque profil est autosuffisant — son état complet et ses MCPs vivent dans son propre .claude.json
  • Une seule source of truth par profil — fini la config orpheline à la racine du home qui dérive silencieusement par rapport à celle du profil
  • Les données sensibles (oauthAccount, tokens) ont des permissions 600 garanties
  • Confirmation visuelle du profil actif à chaque lancement — impossible d'oublier quel profil est actif
  • Ajouter un troisième profil = ajouter une ligne dans le case de la fonction, pas cloner un nouvel alias et retenir son nom

Là où ça pèche

Je veux être honnête. Ce n'est pas une amélioration gratuite — quelques compromis viennent avec, et autant les connaître à l'avance.

  • gum est une dépendance supplémentaire (brew install gum, ~13 MB). Si par principe tu ne veux pas l'installer — fallback sur select de zsh ou un simple read. Ça fonctionne, mais c'est moins joli et il n'y a pas de navigation aux flèches.
  • Un Enter à chaque lancement. Pour qui lance claude des dizaines de fois par jour — ça peut agacer. Alternative en dessous (direnv).
  • Le symlink ~/.claude → personal fait de personal le profil par défaut. S'il te faut le profil work par défaut — il faut rediriger le symlink (ln -sf). Pas compliqué, mais ce n'est pas "j'oublie et rien ne casse".
  • Le symlink peut théoriquement casser si un outil réécrit ~/.claude.json de façon atomique via un pattern temp+rename (write-file-atomic). En pratique Claude Code lui-même ne le fait pas, mais si tu installes des plugins tiers — vérifie.
  • Si tu as des comptes Anthropic différents sur les profils, avec des plans différents — après un switch il peut y avoir un lag inférieur à la seconde le temps que Claude Code synchronise l'état OAuth. Dans mon usage c'est imperceptible, mais ce n'est pas nul.

Alternatives que j'ai considérées

direnv — positionne automatiquement CLAUDE_CONFIG_DIR en fonction d'un .envrc à la racine de chaque projet. Zéro interaction, zéro clic. Inconvénient : il faut poser un .envrc dans chaque work-root, et si tu lances claude dans un dossier non reconnu — tu récupères le profil par défaut (peut-être pas celui que tu voulais). Pour qui vit dans un nombre limité de work-roots et veut ne jamais cliquer — direnv est franchement meilleur.

Switching basé sur symlink (un unique profil actif en redirigeant le symlink ~/.claude) — je l'ai aussi envisagé puis écarté immédiatement. Tu ne peux pas avoir deux terminaux ouverts avec des profils différents en même temps — le "courant" global est unique. Pour moi c'est un deal-breaker.

Conclusion

La v2 n'est pas juste une meilleure UX par-dessus la v1. C'est la reconnaissance que Claude Code a une particularité architecturale cachée (~/.claude.json en tant que fichier séparé à la racine du home, écrit par les commandes --scope user indépendamment de CLAUDE_CONFIG_DIR) qu'il faut prendre en compte si on veut une vraie isolation entre profils. La première approche (~/.claude + ~/.claude-promova + alias) marchait à 80 %, mais les 20 % restants se manifestaient par une dérive silencieuse de l'état entre profils. C'est désormais pris en compte. Si tu débutes — démarre directement en v2. Si tu es déjà en v1 — le script de migration est plus haut, le déménagement prend cinq minutes et ne casse rien (le merge jq est précisément l'étape qui te sauve de la perte d'état).