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 root, де живе 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функції, а не клонувати новий аліас і пам'ятати його ім'я
Where it falls short
Я хочу чесно. Це не безплатне покращення — деякі компроміси прийшли в комплекті, і їх варто знати наперед.
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).