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 20269 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.

Depuis zéro (pas de v1 à migrer)

Si tu as déjà mis en place l'ancien bricolage à deux dossiers + alias, saute directement au script de migration plus bas. Cette section, c'est le build propre à partir de rien — une machine neuve, ou juste le ~/.claude par défaut issu d'un usage normal. D'abord, décide de la forme : un compte avec plusieurs profils (pour isoler les projets et leurs jeux de MCP — le cas courant), ou de véritables comptes différents par profil (personal vs work).

1. Pose les répertoires

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

Un répertoire par profil sous un unique parent XDG. Si tu as déjà utilisé Claude Code, le snippet replie ton ~/.claude existant en tant que personal et emporte avec lui l'état live ~/.claude.json de la racine du home ; sur une machine vraiment neuve, ces lignes ne font tout simplement rien. Tout le reste, ce sont des répertoires vides qui se remplissent d'eux-mêmes au premier lancement — et le chmod 600 compte, parce que ces fichiers finissent par contenir des tokens OAuth.

2. Authentifie chaque 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.

Lance chaque profil une fois avec son propre CLAUDE_CONFIG_DIR et fais /login. Le détail clé : si tous les profils utilisent le même compte, tu te connectes une seule fois — macOS garde le credential dans une unique entrée Keychain partagée, donc tous les autres profils sont authentifiés automatiquement.

Des comptes différents par profil, c'est le chemin plus délicat : cette entrée Keychain partagée ne contient qu'un seul login, donc connecter un second compte sur un autre profil peut écraser le premier. Si c'est ton cas, suis le pas-à-pas dédié dans faire tourner deux comptes Claude sur une même machine, et vérifie l'isolation des credentials sur ta version de Claude Code avant de t'y fier.

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

Fais pointer ~/.claude vers le profil personal pour que tout ce qui code en dur ce chemin continue de fonctionner, puis dépose le sélecteur claude() gum de la section architecture ci-dessus dans ~/.zshrc. Après un rechargement, claude affiche le menu des profils à chaque lancement — le même état final que la config migrée, juste construit à neuf.

4. Trie le MCP de chaque profil

Enfin, ne donne à chaque profil que les outils qu'il devrait voir : mets disableClaudeAiConnectors: true dans le settings.json de ce profil et rajoute ce dont il a besoin avec claude mcp add, exactement comme dans la section connecteurs ci-dessus. C'est ça qui transforme dix profils interchangeables en dix profils taillés sur mesure.

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.

Bonus : connecteurs MCP par profil (quand ton org les partage)

L'architecture ci-dessus débloque un second axe d'isolation — et c'est devenu ce que j'utilise le plus en réalité. Si ton compte Claude appartient à une organisation, l'org peut distribuer un jeu partagé de connecteurs MCP distants (Figma, un tracker de tickets, un backend de métriques, une gateway de base de données, un CMS…). Claude Code récupère automatiquement chaque connecteur activé par l'org dans chaque session, parce qu'ils sont liés au compte, pas au répertoire depuis lequel tu as démarré.

Avec deux ou trois connecteurs, ça va. Avec dix projets et une douzaine de connecteurs d'org, ça ne va plus : chaque session charge des outils qu'elle n'a aucune raison de toucher. C'est du bruit dans la liste d'outils du modèle, c'est du contexte gaspillé, et — la partie qui mord vraiment — ça laisse une session aller chercher dans un système qui appartient à un autre projet. Le connecteur du projet A ne devrait même pas exister pendant que tu travailles sur le projet B.

Désactive l'auto-récupération, puis fais le tri

Le levier est un unique paramètre, disableClaudeAiConnectors (Claude Code v2.1.182+). Il vit dans n'importe quel scope de settings — y compris le settings.json propre à un profil — donc il est par-CLAUDE_CONFIG_DIR. Mets-le à true et ce profil cesse complètement de récupérer automatiquement les connecteurs de l'org :

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

Ensuite, rajoute uniquement les connecteurs dont ce profil a réellement besoin. Les serveurs que tu ajoutes explicitement ne sont pas touchés par le flag, donc ils se chargent normalement — le résultat est un sous-ensemble trié sur le volet, profil par 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
ProfilCe qu'il charge
frontendFigma, Linear, Sentry
backendPostgres, Grafana, Linear
contentNotion, Figma, Linear

Même compte, même catalogue d'org — mais chaque profil ne voit que sa tranche, plus les connecteurs communs que tu choisis de répéter (un tracker de tickets mérite généralement sa place partout).

Je l'ai testé avant de lui faire confiance

Je ne voulais pas croire la doc sur parole, alors j'ai fait un A/B propre sur un profil — même compte, même token OAuth, la seule différence étant ce 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)

Ça fait exactement ce qu'il prétend. Deux mises en garde à connaître avant de t'appuyer dessus :

  • Réajouter un connecteur OAuth nécessite un login interactif. Les connecteurs qui s'authentifient par clé-dans-l'URL reviennent entièrement automatiquement via claude mcp add. Les connecteurs derrière OAuth demanderont une auth navigateur ponctuelle la première fois que tu les utilises dans ce profil.
  • claude mcp list n'affiche pas du tout les connecteurs de l'org — seulement les serveurs définis localement et ajoutés explicitement. Ceux récupérés automatiquement ne sont visibles qu'à l'intérieur d'une session active via /mcp. Pas de panique quand mcp list semble vide d'eux.

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).