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

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.

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