Skip to main content
Volver al blog
claude-codeproductivityclidevtools

Claude Code Multi-Profile, v2: una arquitectura XDG limpia

Después de un mes con el enfoque viejo basado en alias, rehíce mi setup multi-profile de Claude Code a una estructura compatible con XDG bajo ~/.config/claude-profiles/. La razón real no fue el orden — fue descubrir que ~/.claude.json es un archivo separado en el home root donde vivían silenciosamente los servidores MCP añadidos con --scope user.

Publicado 13 de junio de 20269 min de lectura

Hace un mes publiqué un post sobre cómo correr dos cuentas de Claude Code en paralelo en un mismo equipo — personal y de trabajo, mediante CLAUDE_CONFIG_DIR y un alias de shell. El enfoque funcionaba. Todo hacía lo que tenía que hacer.

Pero después de un mes de uso real me topé con varios problemas sustanciales que ese post no cubría. El más grande — una peculiaridad oculta de Claude Code con la que me crucé por accidente, cuando añadí el MCP de Gmail y "desapareció" de mi perfil cinco minutos después. Hoy rehíce todo sobre una arquitectura nueva — compatible con XDG, con un symlink y un selector interactivo de perfil mediante gum. Esto es v2 — una evolución a partir de la experiencia real, no una mejora teórica.

Lo que empezó a molestar

Cuatro cosas. Las primeras tres son sobre orden, visibilidad y escala. La cuarta es el verdadero gotcha arquitectónico que no vi de inmediato. Las repaso en orden, porque fue justamente la cuarta la que al final forzó la reescritura.

1. Dos carpetas separadas en $HOME — es un desastre

~/.claude y ~/.claude-promova — dos dotfolders uno al lado del otro en la raíz de $HOME. La XDG Base Directory Specification dice que las configs deben vivir en ~/.config/. Las carpetas dotfile dispersas directamente en home son un antipatrón que con el tiempo convierte $HOME en un cajón de sastre. Cosmético, sí, pero me molestaba cada vez que veía ls -la ~.

2. Sin confirmación visual del perfil activo

Lanzo claude y no sé qué perfil está activo — hasta que ejecuto claude config list ya dentro de la sesión. Si olvidé qué terminal arranqué dónde, tengo que verificar. Una nimiedad, pero con personal + work corriendo en paralelo en dos pestañas se acumula.

3. Los alias no escalan

Dos perfiles — claude y claude-promova — está bien. Añades un tercero (un cliente freelance) — necesitas un tercer alias. Un cuarto — un cuarto. En medio año ya no recordaría qué alias tengo creados.

4. La trampa oculta en ~/.claude.json

Y esto es la verdadera razón de la reescritura. Claude Code tiene dos lugares distintos de configuración, y la documentación no lo grita: ~/.claude/ — el directorio donde están projects/, sessions/, hooks/, skills/. Y por separado — ~/.claude.json, un archivo justo en la raíz de $HOME, donde viven oauthAccount, mcpServers, el historial de projects, skillUsage y cerca de 40 campos más de auténtico live state.

El comando claude mcp add --scope user escribe precisamente en ese ~/.claude.json del home root, y no en ~/.claude/.claude.json ni en el directorio del perfil. No lo sabía. Hasta que un día me topé con ello.

Discovery: por qué el MCP de Gmail "desapareció"

Esta mañana estaba montando el MCP de Gmail en Claude Code. Setup habitual: proyecto en Google Cloud, credenciales OAuth, claude mcp add gmail --scope user -- npx -y @gongrzhe/server-gmail-autoauth-mcp. Todo ok. Reinicié la sesión — funciona, leo el correo, contesto mensajes. Una hora después empezamos a refactorizar los alias en una función con selector de perfil por gum, y luego migramos todo a XDG. Hice mv ~/.claude → ~/.config/claude-profiles/personal, reinicié CC, elegí personal en el menú. Y en la nueva sesión abrí /mcp:

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

No estaba gmail. No estaba vaultforge. Solo tres servidores, uno de los cuales además había failed. Y yo acababa de añadir Gmail. Mientras tanto, la sesión en otra terminal (perfil de trabajo) mostraba Gmail y Vaultforge sin ningún problema.

Empecé a escarbar y encontré que en mi máquina tenía tres archivos distintos con el nombre .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

Ahí estaba. Ese es el gotcha arquitectónico:

  1. claude mcp add --scope user siempre escribe en ~/.claude.json en el home root, independientemente de CLAUDE_CONFIG_DIR
  2. Cuando CLAUDE_CONFIG_DIR está definido, Claude Code lee $CLAUDE_CONFIG_DIR/.claude.json — es decir, el archivo dentro del perfil
  3. Los registros de "user-scope MCPs" y de "MCPs del perfil" viven en archivos distintos con el mismo nombre — y es fácil confundirlos

En mi caso ~/.claude.json (home root, 113 KB) era el state vivo y actual — con gmail, vaultforge, sesión OAuth, todo. Y ~/.config/claude-profiles/personal/.claude.json (29 KB) resultó ser un snapshot viejo que por alguna razón ya estaba antes en el viejo ~/.claude/ — quizás una versión anterior de CC escribía ahí, quizás un plugin. jq -r 'keys[]' sobre ambos archivos mostró que la versión del home-root tenía 41 claves únicas que no estaban en el snapshot.

Y esas 41 claves no son basura. Es el state real de Claude Code:

  • skillUsage — estadísticas de uso de skills
  • githubRepoPaths — caché de repos para la navegación por proyectos
  • cachedGrowthBookFeatures + cachedStatsigGates — feature flags (sin ellas CC va por nuevas cada arranque)
  • hasShownOpus45Notice, hasShownOpus46Notice, hasShownS1MWelcomeV2 — flags de UI (sin ellas los modales vuelven a aparecer en el próximo arranque)
  • lastPlanModeUse, feedbackSurveyState, installMethod — state de onboarding y UX

Si simplemente haces mv ~/.claude ~/.config/claude-profiles/personal sin merge — pierdes todo eso. Vuelves a ver los modales de welcome, se vuelve a ejecutar la búsqueda de githubRepoPaths, recibes de nuevo todas las peticiones de survey. Como casi hice yo.

La nueva arquitectura

Todo vive bajo un único directorio padre en ~/.config/, como quiere XDG. Cada perfil es autosuficiente — tiene su state completo, incluido su propio .claude.json. ~/.claude queda como symlink al perfil personal para compatibilidad hacia atrás.

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

Ningún .claude.json en la raíz de $HOME. Cada perfil es un directorio separado y aislado donde está todo: las carpetas projects/sessions y ese mismo archivo con los servidores MCP y los tokens OAuth. Una source of truth por perfil.

Todo lo que tiene el path ~/.claude/ hardcoded — scripts viejos, plugins, extensiones de IDE de Claude Code, configs de statusline como claude-powerline.json — sigue funcionando sin cambios. El symlink se resuelve al perfil personal. Si por accidente lanzas command claude (sin la función wrapper) — también caes en personal por el lookup del default-path. Personal se convierte en el "quiet default" que era antes, pero ahora vive físicamente en la ubicación XDG.

Selector interactivo al lanzar — función + gum

En lugar de alias — una función claude() en ~/.zshrc que muestra un menú con flechas mediante gum (el TUI-helper de Charm). La función intercepta la llamada a claude a nivel de shell, deja elegir el perfil y ejecuta command claude con el CLAUDE_CONFIG_DIR correspondiente. command es importante — esquiva la función wrapper e invoca el binario real.

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

Esto es lo que se ve al arrancar:

Claude profile:
▸ personal
  promova

↑↓ para navegar, Enter para elegir, Esc para cancelar (Claude simplemente no arranca). El perfil siempre está a la vista — imposible olvidarlo.

Desde cero (sin v1 que migrar)

Si ya hiciste el viejo truco de dos carpetas + alias, salta al script de migración de abajo. Esta sección es el montaje limpio desde cero — una máquina nueva, o simplemente el ~/.claude por defecto de un uso normal. Primero decide la forma: una cuenta con varios perfiles (para aislar proyectos y sus conjuntos de MCP — el caso habitual), o cuentas genuinamente distintas por perfil (personal vs work).

1. Distribuye los directorios

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 directorio por perfil bajo un único padre XDG. Si ya has usado Claude Code, el snippet integra tu ~/.claude existente como personal y arrastra con él el live state de ~/.claude.json en la raíz del home; en una máquina realmente nueva esas líneas simplemente no hacen nada. Todo lo demás son directorios vacíos que se rellenan solos en el primer arranque — y el chmod 600 importa, porque estos archivos acaban guardando tokens OAuth.

2. Autentica cada perfil

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.

Lanza cada perfil una vez con su propio CLAUDE_CONFIG_DIR y ejecuta /login. El detalle clave: si cada perfil usa la misma cuenta, inicias sesión una sola vez — macOS guarda la credencial en una única entrada compartida del Keychain, así que todos los demás perfiles quedan autenticados automáticamente.

Cuentas distintas por perfil es el camino más espinoso: esa entrada compartida del Keychain guarda un solo login, así que meter una segunda cuenta en otro perfil puede sobrescribir la primera. Si ese es tu caso, sigue el recorrido dedicado en cómo correr dos cuentas de Claude en un mismo equipo, y verifica el aislamiento de credenciales en tu versión de Claude Code antes de fiarte de él.

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

Apunta ~/.claude al perfil personal para que todo lo que tenga ese path hardcoded siga funcionando, y luego deja caer el selector claude() con gum de la sección de arquitectura de arriba en ~/.zshrc. Tras una recarga, claude muestra el menú de perfiles en cada arranque — el mismo estado final que el setup migrado, solo que montado desde cero.

4. Cura el MCP de cada perfil

Por último, dale a cada perfil solo las herramientas que debería ver: pon disableClaudeAiConnectors: true en el settings.json de ese perfil y vuelve a añadir lo que necesita con claude mcp add, exactamente como en la sección de conectores de arriba. Eso es lo que convierte diez perfiles intercambiables en diez hechos a medida.

Script de migración

Para quien lea esto y quiera mudarse del esquema viejo. El paso más crítico es el segundo: fusiona ~/.claude.json del home root con lo que ya hay en el perfil personal, combinando las listas de mcpServers. Sin ese paso el perfil pierde tanto los MCPs como todo el 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

El tercer paso, sobre permisos, merece atención propia. jq | mv crea un archivo con umask 644 (legible por todos). Dentro hay tokens OAuth. chmod 600 inmediatamente después del merge es obligatorio.

Tras la migración — cierra y abre todas las sesiones activas de Claude, recarga el shell (source ~/.zshrc o terminal nueva), ejecuta claude, elige perfil, comprueba con claude mcp list que todos los MCPs están en su sitio. Si todo está bien — borra ~/.claude.json.migrated.bak. Si algo va mal — el rollback es trivial: mv ~/.claude.json.migrated.bak ~/.claude.json y quitar el symlink.

Bonus: conectores MCP por perfil (cuando tu organización los comparte)

La arquitectura de arriba desbloquea un segundo eje de aislamiento — y se convirtió en lo que de hecho más uso. Si tu cuenta de Claude pertenece a una organización, la org puede repartir un conjunto compartido de conectores MCP remotos (Figma, un gestor de tickets, un backend de métricas, un gateway de base de datos, un CMS…). Claude Code descarga automáticamente todos los conectores que la org tenga habilitados en cada sesión, porque están atados a la cuenta, no al directorio desde el que arrancaste.

Con dos o tres conectores no pasa nada. Con diez proyectos y una docena de conectores de la org deja de no pasar nada: cada sesión carga herramientas que no tiene por qué tocar. Es ruido en la lista de herramientas del modelo, es contexto desperdiciado y — la parte que de verdad muerde — deja que una sesión meta la mano en un sistema que pertenece a otro proyecto. El conector del proyecto A ni siquiera debería existir mientras trabajas en el proyecto B.

Desactiva la descarga automática y luego cura la lista

La palanca es un único setting, disableClaudeAiConnectors (Claude Code v2.1.182+). Vive en cualquier scope de settings — incluido el propio settings.json de un perfil — así que es por CLAUDE_CONFIG_DIR. Ponlo en true y ese perfil deja de descargar automáticamente los conectores de la org por completo:

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

Luego vuelve a añadir solo los conectores que ese perfil realmente necesita. Los servidores que añades explícitamente no los toca el flag, así que se cargan con normalidad — el resultado es un subconjunto curado, perfil por perfil:

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
PerfilQué carga
frontendFigma, Linear, Sentry
backendPostgres, Grafana, Linear
contentNotion, Figma, Linear

Misma cuenta, mismo catálogo de la org — pero cada perfil ve solo su porción, más los conectores comunes que elijas repetir (un gestor de tickets normalmente se gana su lugar en todas partes).

Lo probé antes de fiarme

No quería creerme la documentación a ciegas, así que corrí un A/B limpio sobre un perfil — misma cuenta, mismo token OAuth, con la única diferencia de ese 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)

Hace exactamente lo que promete. Dos salvedades que conviene saber antes de apoyarte en ello:

  • Volver a añadir un conector OAuth requiere un login interactivo. Los conectores que se autentican con key-in-URL vuelven de forma totalmente automática vía claude mcp add. Los conectores tras OAuth pedirán una autenticación única en el navegador la primera vez que los uses en ese perfil.
  • claude mcp list no muestra los conectores de la org en absoluto — solo los servidores definidos localmente y los añadidos explícitamente. Los descargados automáticamente solo son visibles dentro de una sesión viva mediante /mcp. No te asustes cuando mcp list parezca no tener ninguno.

Lo que ganas

  • Un único directorio padre en lugar de dos dotfolders en $HOME — compatible con XDG
  • El symlink preserva la compatibilidad con todo lo que tiene ~/.claude/ hardcoded
  • Cada perfil es autosuficiente — su state completo y sus MCPs viven en su propio .claude.json
  • Una source of truth por perfil — ya no hay config huérfana en el home root divergiendo silenciosamente del perfil
  • Los datos sensibles (oauthAccount, tokens) tienen permisos 600 garantizados
  • Confirmación visual del perfil activo en cada arranque — imposible olvidar qué perfil está activo
  • Añadir un tercer perfil = añadir una línea al case de la función, no clonar un alias nuevo y memorizar su nombre

Dónde flojea

Quiero ser honesto. Esto no es una mejora gratuita — vienen algunos compromisos en el paquete, y conviene conocerlos por adelantado.

  • gum es una dependencia extra (brew install gum, ~13 MB). Si por principio no quieres instalarlo — fallback a select en zsh o un read simple. Funciona, pero no se ve tan bonito y no tiene navegación por flechas.
  • Un Enter en cada arranque. Para quien lanza claude decenas de veces al día — puede molestar. Alternativa abajo (direnv).
  • El symlink ~/.claude → personal hace personal el default. Si necesitas que el perfil de trabajo sea el default, hay que redirigir el symlink (ln -sf). No es difícil, pero no es "olvídate y nada se rompe".
  • El symlink podría romperse en teoría si alguna herramienta reescribe ~/.claude.json atómicamente con un patrón temp+rename (write-file-atomic). En la práctica Claude Code en sí no lo hace, pero si instalas plugins de terceros — verifícalo.
  • Si tienes cuentas de Anthropic distintas en los perfiles con planes distintos — tras cambiar puede haber un lag de menos de un segundo mientras Claude Code sincroniza el state de OAuth. En mi uso no se nota, pero no es cero.

Alternativas que consideré

direnv — define automáticamente CLAUDE_CONFIG_DIR según el .envrc en la raíz de cada proyecto. Cero interacción, cero clics. Contra: hay que colocar un .envrc en cada work-root, y si ejecutas claude en una carpeta no reconocida — obtienes el perfil por defecto (que puede no ser el que necesitas). Para quien vive en un conjunto acotado de work-roots y quiere no clicar nunca — direnv realmente es mejor.

Switching basado en symlink (un único perfil activo redirigiendo el symlink ~/.claude) también lo consideré y lo descarté de inmediato. No puedes tener dos terminales con perfiles distintos abiertas a la vez — el "actual" global es uno solo. Para mí es un deal-breaker.

Conclusión

v2 no es solo mejor UX encima de v1. Es el reconocimiento de que Claude Code tiene una peculiaridad arquitectónica oculta (~/.claude.json como archivo separado en el home root, escrito por comandos --scope user independientemente de CLAUDE_CONFIG_DIR) que hay que tener en cuenta si quieres aislamiento real entre perfiles. El primer enfoque (~/.claude + ~/.claude-promova + alias) funcionaba al 80%, pero el 20% restante se manifestaba como una deriva silenciosa de state entre perfiles. Ahora está contemplado. Si recién empiezas — arranca directamente con v2. Si ya estás en v1 — el script de migración está arriba, la mudanza lleva cinco minutos y no rompe nada (justamente el merge con jq es el paso que te salva de perder state).