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 просто не запускается). Профиль виден всегда — забыть невозможно.
С нуля (нет v1, который нужно мигрировать)
Если ты уже накрутил старый хак с двумя папками + алиасом — прыгай сразу к скрипту миграции ниже. Этот раздел — чистая сборка с нуля: свежая машина или просто дефолтный ~/.claude от обычного использования. Сначала определись с формой: один аккаунт с несколькими профилями (чтобы изолировать проекты и их наборы MCP — типичный случай) или реально разные аккаунты на профиль (личный vs рабочий).
1. Разложи директории
# 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По одной директории на профиль под единым XDG-родителем. Если ты уже пользовался Claude Code, сниппет вкладывает твой существующий ~/.claude как personal и тащит вместе с ним live state из home-root ~/.claude.json; на реально свежей машине эти строки просто ничего не делают. Всё остальное — пустые директории, которые сами наполнятся при первом запуске, — и chmod 600 важен, потому что эти файлы в итоге держат OAuth-токены.
2. Авторизуй каждый профиль
# 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.Запусти каждый профиль по разу с его собственным CLAUDE_CONFIG_DIR и выполни /login. Ключевой нюанс: если все профили используют один и тот же аккаунт — логинишься только раз, macOS держит credential в единой общей записи Keychain, так что каждый остальной профиль авторизуется автоматически.
Разные аккаунты на профиль — путь похитрее: та общая запись Keychain держит один логин, так что вход вторым аккаунтом в другой профиль может перетереть первый. Если это твой случай — следуй отдельному гайду в запуске двух аккаунтов Claude на одной машине и проверь изоляцию кредов на своей версии Claude Code, прежде чем на это полагаться.
3. Symlink и выбор профиля
# 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Направь ~/.claude на personal-профиль, чтобы всё, что хардкодит этот путь, продолжало работать, а потом закинь claude() gum-выбор из раздела про архитектуру выше в ~/.zshrc. После перезагрузки claude показывает меню профилей при каждом запуске — то же конечное состояние, что и у мигрированного сетапа, только собранное с нуля.
4. Курируй MCP каждого профиля
Напоследок дай каждому профилю только те инструменты, которые он должен видеть: поставь disableClaudeAiConnectors: true в settings.json этого профиля и добавь обратно то, что ему нужно, через claude mcp add — ровно как в разделе про коннекторы выше. Именно это превращает десять взаимозаменяемых профилей в десять заточенных под задачу.
Скрипт миграции
Для тех, кто это читает и хочет переехать со старой схемы. Самый критичный шаг — второй: он сливает ~/.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.
Бонус: MCP-коннекторы на профиль (когда их раздаёт твоя организация)
Архитектура выше открывает вторую ось изоляции — и именно ей я в итоге пользуюсь чаще всего. Если твой аккаунт Claude принадлежит организации, она может раздать общий набор remote MCP-коннекторов (Figma, трекер задач, бэкенд метрик, шлюз к базе, CMS…). Claude Code автоматически подтягивает каждый коннектор, который организация включила, в каждую сессию — потому что они привязаны к аккаунту, а не к директории, из которой ты запустился.
С двумя-тремя коннекторами это нормально. С десятью проектами и дюжиной коннекторов организации — уже нет: каждая сессия грузит инструменты, к которым ей вообще не положено прикасаться. Это шум в списке инструментов модели, это растрата контекста, и — что реально кусается — это позволяет сессии дотянуться до системы, которая принадлежит другому проекту. Коннектора проекта A не должно даже существовать, пока ты работаешь над проектом B.
Отключи авто-подтягивание, а потом курируй
Рычаг — одна-единственная настройка, disableClaudeAiConnectors (Claude Code v2.1.182+). Она живёт в любом scope настроек — включая собственный settings.json профиля — то есть она на каждый CLAUDE_CONFIG_DIR. Поставь true, и этот профиль вообще перестаёт авто-подтягивать коннекторы организации:
{
"disableClaudeAiConnectors": true
}А дальше добавляешь обратно только те коннекторы, которые этому профилю реально нужны. Серверы, которые ты добавил явно, флаг не трогает, так что они грузятся как обычно — на выходе курированное подмножество, профиль за профилем:
# 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| Профиль | Что грузит |
|---|---|
frontend | Figma, Linear, Sentry |
backend | Postgres, Grafana, Linear |
content | Notion, Figma, Linear |
Тот же аккаунт, тот же каталог организации — но каждый профиль видит только свой срез плюс те общие коннекторы, которые ты сам решил повторить (трекер задач обычно заслуживает место везде).
Я проверил это, прежде чем довериться
Я не хотел верить документации на слово, поэтому прогнал чистый A/B на одном профиле — тот же аккаунт, тот же OAuth-токен, единственная разница — тот самый settings.json:
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)Делает ровно то, что заявлено. Две оговорки, о которых стоит знать, прежде чем на это опираться:
- Чтобы заново добавить OAuth-коннектор, нужен один интерактивный логин. Коннекторы, которые авторизуются ключом в URL, возвращаются полностью автоматически через
claude mcp add. Коннекторы за OAuth попросят разовую браузерную авторизацию при первом использовании в этом профиле. claude mcp listвообще не показывает коннекторы организации — только локально определённые и явно добавленные серверы. Авто-подтянутые видны лишь внутри живой сессии через/mcp. Не паникуй, когдаmcp listвыглядит пустым от них.
Что ты получаешь
- Один родительский каталог вместо двух 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).