Claude Code Multi-Profile, v2: чистая XDG-архитектура
После месяца на старом алиас-подходе я переделал свой multi-profile сетап Claude Code на XDG-совместимую структуру ~/.config/claude-profiles/. Настоящая причина — не аккуратность, а обнаружение ~/.claude.json как отдельного файла в home root, в котором тихо жили MCP-серверы, добавленные через --scope user.
Месяц назад я опубликовал пост о том, как гонять два аккаунта Claude Code на одном устройстве — личный и рабочий, через CLAUDE_CONFIG_DIR и shell-алиас. Подход работал. Всё делало то, что должно было делать.
Но после месяца реального использования я наткнулся на несколько существенных проблем, которых тот пост не покрывал. Главная из них — скрытая особенность Claude Code, на которую я наступил случайно, когда добавил Gmail MCP и он "исчез" из моего профиля через пять минут. Сегодня я переделал всё на новую архитектуру — XDG-совместимую, с symlink'ом и интерактивным выбором профиля через gum. Это v2 — эволюция из реального опыта, а не теоретическое улучшение.
Что начало напрягать
Четыре вещи. Первые три — про аккуратность, видимость и масштаб. Четвёртая — настоящая архитектурная каверза, которую я не увидел сразу. Разберу по очереди, потому что именно четвёртая в конце и заставила переделать всё.
1. Две отдельные папки в $HOME — это бардак
~/.claude и ~/.claude-promova — два dotfolder'а рядом в корне $HOME. XDG Base Directory Specification говорит, что конфиги должны жить в ~/.config/. Разбросанные dotfile-папки прямо в home — это антипаттерн, который со временем превращает $HOME в свалку. Косметически, но раздражало каждый раз, когда я видел ls -la ~.
2. Нет визуального подтверждения активного профиля
Запускаю claude и не знаю, какой профиль активен — пока не выполню claude config list уже внутри сессии. Если забыл, какой терминал где запустил — нужно проверять. Мелочь, но при параллельной работе personal + work в двух вкладках она набирает массу.
3. Алиасы не масштабируются
Два профиля — claude и claude-promova — это окей. Добавляешь третий (фриланс-клиент) — нужен третий алиас. Четвёртый — четвёртый. Через полгода я бы не помнил, какие именно алиасы у меня созданы.
4. Скрытая ловушка с ~/.claude.json
А вот это настоящая причина переделки. У Claude Code есть два разных места конфигурации, и документация об этом не кричит: ~/.claude/ — директория, где лежат projects/, sessions/, hooks/, skills/. И отдельно — ~/.claude.json, файл прямо в корне $HOME, где живут oauthAccount, mcpServers, история projects, skillUsage и ещё около 40 других полей настоящего live state.
Команда claude mcp add --scope user пишет именно в ~/.claude.json в home root, а не в ~/.claude/.claude.json или в профильную директорию. Я этого не знал. Пока в один прекрасный день не наступил.
Discovery: почему Gmail MCP "исчез"
Сегодня утром я ставил Gmail MCP в Claude Code. Сетап обычный: Google Cloud project, OAuth credentials, claude mcp add gmail --scope user -- npx -y @gongrzhe/server-gmail-autoauth-mcp. Всё ок. Перезапустил сессию — работает, читаю почту, отвечаю на письма. Через час начали рефакторить алиасы в функцию с gum-выбором профиля, а потом перенесли всё на XDG. Я сделал mv ~/.claude → ~/.config/claude-profiles/personal, перезапустил CC, выбрал personal в меню. И в новой сессии открыл /mcp:
figma (failed)
playwright-test
claude.ai NotionНе было gmail. Не было vaultforge. Только три сервера, из которых один даже failed. А я только что добавлял Gmail. Сессия в другом терминале (work-профиль) при этом показывала Gmail и Vaultforge без проблем.
Я начал копать и нашёл, что у меня на машине три разных файла с именем .claude.json:
| Path | Size | mcpServers |
|---|---|---|
~/.claude.json (home root) | 113 KB | figma, gmail, vaultforge |
~/.config/claude-profiles/personal/.claude.json | 29 KB | figma, playwright-test |
~/.config/claude-profiles/promova/.claude.json | 40 KB | figma, playwright-test, vaultforge |
Вот оно. Это и есть та самая архитектурная каверза:
claude mcp add --scope userвсегда пишет в~/.claude.jsonв home root, независимо отCLAUDE_CONFIG_DIR- Claude Code при наличии
CLAUDE_CONFIG_DIRчитает$CLAUDE_CONFIG_DIR/.claude.json— то есть файл внутри профиля - Записи "user-scope MCPs" и "профильные MCPs" живут в разных файлах с одинаковым именем — и их легко перепутать
В моём случае ~/.claude.json (home root, 113 KB) был живым, актуальным state — с gmail, vaultforge, oauth-сессией, всем. А ~/.config/claude-profiles/personal/.claude.json (29 KB) оказался старым snapshot'ом, который почему-то лежал в old ~/.claude/ ещё раньше — может, старая версия CC писала туда, может, плагин. jq -r 'keys[]' на обоих файлах показал, что в home-root версии есть 41 уникальный ключ, которого нет в snapshot'е.
И эти 41 ключ — это не мусор. Это настоящее состояние Claude Code:
skillUsage— статистика использования skillsgithubRepoPaths— кэш репозиториев для project-навигацииcachedGrowthBookFeatures+cachedStatsigGates— feature flags (без них CC ходит за свежими каждый раз при старте)hasShownOpus45Notice,hasShownOpus46Notice,hasShownS1MWelcomeV2— UI флаги (без них модальные окна снова появятся при следующем запуске)lastPlanModeUse,feedbackSurveyState,installMethod— onboarding и UX state
Если просто mv ~/.claude ~/.config/claude-profiles/personal без merge — потеряешь всё это. Заново увидишь welcome-модалки, заново выполнит поиск githubRepoPaths, заново получишь все survey-просьбы. Как я чуть не сделал.
Новая архитектура
Всё живёт под одним родительским каталогом в ~/.config/, как того хочет XDG. Каждый профиль самодостаточен — имеет свой полный state, включая собственный .claude.json. ~/.claude остаётся как symlink на personal-профиль для обратной совместимости.
~/.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/Никакого .claude.json в корне $HOME. Каждый профиль — это отдельная изолированная директория, где лежит всё: и папки projects/sessions, и тот самый файл с MCP-серверами и oauth-токенами. Один источник правды на профиль.
Почему symlink именно на personal
Всё, что хардкодит путь ~/.claude/ — старые скрипты, плагины, IDE-расширения Claude Code, statusline-конфиги типа claude-powerline.json — продолжает работать без изменений. Symlink резолвится в personal-профиль. Если случайно запустишь command claude (без обёртки-функции) — тоже попадёшь в personal через default-path lookup. Personal становится "тихим default'ом", как было раньше, но теперь физически живёт в XDG-локации.
Интерактивный выбор при запуске — функция + gum
Вместо алиасов — функция claude() в ~/.zshrc, которая показывает меню со стрелками через gum (TUI-helper от Charm). Функция перехватывает вызов claude на shell-уровне, даёт выбрать профиль и запускает command claude с соответствующим CLAUDE_CONFIG_DIR. command важно — оно обходит функцию-обёртку и вызывает реальный бинарь.
# 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
}Вид при запуске:
Claude profile:
▸ personal
promova↑↓ для навигации, Enter — выбрать, Esc — отменить (Claude просто не запускается). Профиль виден всегда — забыть невозможно.
Скрипт миграции
Для тех, кто это читает и хочет переехать со старой схемы. Самый критичный шаг — второй: он сливает ~/.claude.json из home root с тем, что уже лежит в personal-профиле, объединяя списки mcpServers. Без этого шага профиль потеряет и MCP, и весь live state.
# 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Третий шаг про права — отдельно важный. jq | mv создаёт файл с umask 644 (world-readable). В нём oauth tokens. Обязательно chmod 600 сразу после merge.
После миграции — закрой и открой все активные Claude сессии, перезагрузи shell (source ~/.zshrc или новый терминал), запусти claude, выбери профиль, проверь через claude mcp list, что все MCP на месте. Если всё ок — удали ~/.claude.json.migrated.bak. Если что-то не так — откат тривиален: mv ~/.claude.json.migrated.bak ~/.claude.json и убрать symlink.
Что ты получаешь
- Один родительский каталог вместо двух dotfolder'ов в $HOME — XDG-совместимо
- Symlink сохраняет совместимость со всем, что хардкодит
~/.claude/ - Каждый профиль самодостаточен — свой полный state и свои MCP в собственном
.claude.json - Один источник правды на профиль — больше нет orphan-конфига в home root, который тихо расходится с профильным
- Sensitive data (oauthAccount, tokens) гарантированно с правами 600
- Визуальное подтверждение активного профиля при каждом запуске — забыть, какой профиль активен, невозможно
- Добавить третий профиль = добавить одну строку в
caseфункции, а не клонировать новый алиас и помнить его имя
Где это даёт слабину
Хочу честно. Это не бесплатное улучшение — несколько компромиссов пришли в комплекте, и о них стоит знать заранее.
gum— дополнительная зависимость (brew install gum, ~13 MB). Если принципиально не хочешь ставить — fallback наselectв zsh или простойread. Работает, но выглядит не так красиво и не имеет стрелочной навигации.- Один Enter при каждом запуске. Для тех, кто запускает
claudeдесятки раз в день — может раздражать. Альтернатива ниже (direnv). - Symlink
~/.claude → personalделает personal default'ом. Если нужно сделать default work-профиль — нужно перенаправить symlink (ln -sf). Несложно, но это не "забыл — ничего не сломается". - Symlink теоретически может сломаться, если какой-то инструмент атомарно перезаписывает
~/.claude.jsonчерез temp+rename pattern (write-file-atomic). На практике Claude Code сам так не делает, но если ставишь сторонние плагины — проверь. - Если у тебя на профилях разные Anthropic-аккаунты с разными планами — после переключения может быть sub-second лаг, пока Claude Code синкает OAuth state. В моём использовании незаметно, но это не ноль.
Альтернативы, которые рассматривал
direnv — автоматически выставляет CLAUDE_CONFIG_DIR в зависимости от .envrc в корне каждого проекта. Ноль интерактива, ноль кликов. Минус: нужно прописать .envrc в каждом work-root, и если запускаешь claude в нераспознанной папке — получаешь дефолтный профиль (может быть не тот, что нужно). Для тех, кто живёт в ограниченном количестве work-roots и хочет никогда не кликать — direnv реально лучше.
Symlink-based switch (единственный активный профиль через перенаправление ~/.claude symlink) я тоже рассмотрел и сразу отбросил. Не можешь иметь два терминала с разными профилями одновременно — глобальная "текущая" одна. Для меня это deal-breaker.
Вывод
v2 — это не просто лучший UX над v1. Это признание того, что Claude Code имеет скрытую архитектурную особенность (~/.claude.json как отдельный файл в home root, который пишется --scope user командами независимо от CLAUDE_CONFIG_DIR), которую нужно учесть, если хочешь настоящую изоляцию между профилями. Первый подход (~/.claude + ~/.claude-promova + алиас) работал на 80%, но те 20%, что остались, проявлялись молчаливым расхождением состояния между профилями. Теперь это учтено. Если ты только начинаешь — стартуй сразу с v2. Если уже сидишь на v1 — скрипт миграции выше, переезд занимает пять минут и ничего не ломает (именно jq merge — это тот шаг, который спасает тебя от потери state).