<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
  xmlns:content="http://purl.org/rss/1.0/modules/content/"
  xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Oleksii Mazurenko — Blog</title>
    <link>https://oleksiimazurenko.dev/ru/blog</link>
    <description>Technical articles about web development, performance optimization, and developer tools.</description>
    <language>ru</language>
    <lastBuildDate>Sat, 13 Jun 2026 00:00:00 GMT</lastBuildDate>
    <atom:link href="https://oleksiimazurenko.dev/ru/blog/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Claude Code Multi-Profile, v2: чистая XDG-архитектура</title>
      <link>https://oleksiimazurenko.dev/ru/blog/claude-profiles-clean-architecture</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/ru/blog/claude-profiles-clean-architecture</guid>
      <description>После месяца на старом алиас-подходе я переделал свой multi-profile сетап Claude Code на XDG-совместимую структуру ~/.config/claude-profiles/. Настоящая причина — не аккуратность, а обнаружение ~/.claude.json как отдельного файла в home root, в котором тихо жили MCP-серверы, добавленные через --scope user.</description>
      <content:encoded><![CDATA[<p>Месяц назад я опубликовал <a href="/ru/blog/multiple-claude-accounts-one-device">пост о том, как гонять два аккаунта Claude Code на одном устройстве</a> — личный и рабочий, через <code>CLAUDE_CONFIG_DIR</code> и shell-алиас. Подход работал. Всё делало то, что должно было делать.</p>
<p>Но после месяца реального использования я наткнулся на несколько существенных проблем, которых тот пост не покрывал. Главная из них — скрытая особенность Claude Code, на которую я наступил случайно, когда добавил Gmail MCP и он "исчез" из моего профиля через пять минут. Сегодня я переделал всё на новую архитектуру — XDG-совместимую, с symlink'ом и интерактивным выбором профиля через <code>gum</code>. Это v2 — эволюция из реального опыта, а не теоретическое улучшение.</p>
<h2>Что начало напрягать</h2>
<p>Четыре вещи. Первые три — про аккуратность, видимость и масштаб. Четвёртая — настоящая архитектурная каверза, которую я не увидел сразу. Разберу по очереди, потому что именно четвёртая в конце и заставила переделать всё.</p>
<h3>1. Две отдельные папки в $HOME — это бардак</h3>
<p><code>~/.claude</code> и <code>~/.claude-promova</code> — два dotfolder'а рядом в корне $HOME. XDG Base Directory Specification говорит, что конфиги должны жить в <code>~/.config/</code>. Разбросанные dotfile-папки прямо в home — это антипаттерн, который со временем превращает $HOME в свалку. Косметически, но раздражало каждый раз, когда я видел <code>ls -la ~</code>.</p>
<h3>2. Нет визуального подтверждения активного профиля</h3>
<p>Запускаю <code>claude</code> и не знаю, какой профиль активен — пока не выполню <code>claude config list</code> уже внутри сессии. Если забыл, какой терминал где запустил — нужно проверять. Мелочь, но при параллельной работе personal + work в двух вкладках она набирает массу.</p>
<h3>3. Алиасы не масштабируются</h3>
<p>Два профиля — <code>claude</code> и <code>claude-promova</code> — это окей. Добавляешь третий (фриланс-клиент) — нужен третий алиас. Четвёртый — четвёртый. Через полгода я бы не помнил, какие именно алиасы у меня созданы.</p>
<h3>4. Скрытая ловушка с ~/.claude.json</h3>
<p>А вот это настоящая причина переделки. У Claude Code есть <strong>два разных места</strong> конфигурации, и документация об этом не кричит: <code>~/.claude/</code> — директория, где лежат <code>projects/</code>, <code>sessions/</code>, <code>hooks/</code>, <code>skills/</code>. И отдельно — <code>~/.claude.json</code>, файл прямо в корне $HOME, где живут <code>oauthAccount</code>, <code>mcpServers</code>, история <code>projects</code>, <code>skillUsage</code> и ещё около 40 других полей настоящего live state.</p>
<p>Команда <code>claude mcp add --scope user</code> пишет именно в <code>~/.claude.json</code> в home root, а <strong>не</strong> в <code>~/.claude/.claude.json</code> или в профильную директорию. Я этого не знал. Пока в один прекрасный день не наступил.</p>
<h2>Discovery: почему Gmail MCP &quot;исчез&quot;</h2>
<p>Сегодня утром я ставил Gmail MCP в Claude Code. Сетап обычный: Google Cloud project, OAuth credentials, <code>claude mcp add gmail --scope user -- npx -y @gongrzhe/server-gmail-autoauth-mcp</code>. Всё ок. Перезапустил сессию — работает, читаю почту, отвечаю на письма. Через час начали рефакторить алиасы в функцию с <code>gum</code>-выбором профиля, а потом перенесли всё на XDG. Я сделал <code>mv ~/.claude → ~/.config/claude-profiles/personal</code>, перезапустил CC, выбрал personal в меню. И в новой сессии открыл <code>/mcp</code>:</p>
<pre><code>figma            (failed)
playwright-test
claude.ai Notion</code></pre>
<p>Не было gmail. Не было vaultforge. Только три сервера, из которых один даже failed. А я только что добавлял Gmail. Сессия в другом терминале (work-профиль) при этом показывала Gmail и Vaultforge без проблем.</p>
<p>Я начал копать и нашёл, что у меня на машине <strong>три</strong> разных файла с именем <code>.claude.json</code>:</p>
<table><thead><tr><th>Path</th><th>Size</th><th>mcpServers</th></tr></thead><tbody><tr><td>&lt;code&gt;~/.claude.json&lt;/code&gt; (home root)</td><td>113 KB</td><td>figma, gmail, vaultforge</td></tr><tr><td>&lt;code&gt;~/.config/claude-profiles/personal/.claude.json&lt;/code&gt;</td><td>29 KB</td><td>figma, playwright-test</td></tr><tr><td>&lt;code&gt;~/.config/claude-profiles/promova/.claude.json&lt;/code&gt;</td><td>40 KB</td><td>figma, playwright-test, vaultforge</td></tr></tbody></table>
<p>Вот оно. Это и есть та самая архитектурная каверза:</p>
<ol><li><code>claude mcp add --scope user</code> всегда пишет в <code>~/.claude.json</code> в home root, независимо от <code>CLAUDE_CONFIG_DIR</code></li><li>Claude Code при наличии <code>CLAUDE_CONFIG_DIR</code> читает <code>$CLAUDE_CONFIG_DIR/.claude.json</code> — то есть файл внутри профиля</li><li>Записи "user-scope MCPs" и "профильные MCPs" живут в <strong>разных файлах</strong> с одинаковым именем — и их легко перепутать</li></ol>
<p>В моём случае <code>~/.claude.json</code> (home root, 113 KB) был живым, актуальным state — с gmail, vaultforge, oauth-сессией, всем. А <code>~/.config/claude-profiles/personal/.claude.json</code> (29 KB) оказался старым snapshot'ом, который почему-то лежал в old <code>~/.claude/</code> ещё раньше — может, старая версия CC писала туда, может, плагин. <code>jq -r 'keys[]'</code> на обоих файлах показал, что в home-root версии есть 41 уникальный ключ, которого нет в snapshot'е.</p>
<p>И эти 41 ключ — это не мусор. Это настоящее состояние Claude Code:</p>
<ul><li><code>skillUsage</code> — статистика использования skills</li><li><code>githubRepoPaths</code> — кэш репозиториев для project-навигации</li><li><code>cachedGrowthBookFeatures</code> + <code>cachedStatsigGates</code> — feature flags (без них CC ходит за свежими каждый раз при старте)</li><li><code>hasShownOpus45Notice</code>, <code>hasShownOpus46Notice</code>, <code>hasShownS1MWelcomeV2</code> — UI флаги (без них модальные окна снова появятся при следующем запуске)</li><li><code>lastPlanModeUse</code>, <code>feedbackSurveyState</code>, <code>installMethod</code> — onboarding и UX state</li></ul>
<p>Если просто <code>mv ~/.claude ~/.config/claude-profiles/personal</code> без merge — потеряешь всё это. Заново увидишь welcome-модалки, заново выполнит поиск githubRepoPaths, заново получишь все survey-просьбы. Как я чуть не сделал.</p>
<h2>Новая архитектура</h2>
<p>Всё живёт под одним родительским каталогом в <code>~/.config/</code>, как того хочет XDG. Каждый профиль самодостаточен — имеет свой полный state, включая собственный <code>.claude.json</code>. <code>~/.claude</code> остаётся как symlink на personal-профиль для обратной совместимости.</p>
<pre><code>~/.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/</code></pre>
<p>Никакого <code>.claude.json</code> в корне $HOME. Каждый профиль — это отдельная изолированная директория, где лежит всё: и папки <code>projects</code>/<code>sessions</code>, и тот самый файл с MCP-серверами и oauth-токенами. Один источник правды на профиль.</p>
<h3>Почему symlink именно на personal</h3>
<p>Всё, что хардкодит путь <code>~/.claude/</code> — старые скрипты, плагины, IDE-расширения Claude Code, statusline-конфиги типа <code>claude-powerline.json</code> — продолжает работать без изменений. Symlink резолвится в personal-профиль. Если случайно запустишь <code>command claude</code> (без обёртки-функции) — тоже попадёшь в personal через default-path lookup. Personal становится "тихим default'ом", как было раньше, но теперь физически живёт в XDG-локации.</p>
<h3>Интерактивный выбор при запуске — функция + gum</h3>
<p>Вместо алиасов — функция <code>claude()</code> в <code>~/.zshrc</code>, которая показывает меню со стрелками через <code>gum</code> (TUI-helper от Charm). Функция перехватывает вызов <code>claude</code> на shell-уровне, даёт выбрать профиль и запускает <code>command claude</code> с соответствующим <code>CLAUDE_CONFIG_DIR</code>. <code>command</code> важно — оно обходит функцию-обёртку и вызывает реальный бинарь.</p>
<pre><code># brew install gum

# Claude Code: profile picker on launch
claude() {
  local profile
  profile=$(gum choose \
    --header &quot;Claude profile:&quot; \
    --cursor &quot;▸ &quot; \
    --selected.foreground 212 \
    --cursor.foreground 212 \
    --header.foreground 244 \
    &quot;personal&quot; &quot;promova&quot;) || return
  case &quot;$profile&quot; in
    personal) CLAUDE_CONFIG_DIR=&quot;$HOME/.config/claude-profiles/personal&quot; command claude &quot;$@&quot; ;;
    promova)  CLAUDE_CONFIG_DIR=&quot;$HOME/.config/claude-profiles/promova&quot;  command claude &quot;$@&quot; ;;
  esac
}</code></pre>
<p>Вид при запуске:</p>
<pre><code>Claude profile:
▸ personal
  promova</code></pre>
<p>↑↓ для навигации, Enter — выбрать, Esc — отменить (Claude просто не запускается). Профиль виден всегда — забыть невозможно.</p>
<h2>Скрипт миграции</h2>
<p>Для тех, кто это читает и хочет переехать со старой схемы. Самый критичный шаг — второй: он сливает <code>~/.claude.json</code> из home root с тем, что уже лежит в personal-профиле, объединяя списки <code>mcpServers</code>. Без этого шага профиль потеряет и MCP, и весь live state.</p>
<pre><code># 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 &apos;.[0] * {mcpServers: (.[0].mcpServers + .[1].mcpServers)}&apos; \
  ~/.claude.json \
  ~/.config/claude-profiles/personal/.claude.json \
  &gt; /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</code></pre>
<p>Третий шаг про права — отдельно важный. <code>jq | mv</code> создаёт файл с umask 644 (world-readable). В нём oauth tokens. Обязательно <code>chmod 600</code> сразу после merge.</p>
<p>После миграции — закрой и открой все активные Claude сессии, перезагрузи shell (<code>source ~/.zshrc</code> или новый терминал), запусти <code>claude</code>, выбери профиль, проверь через <code>claude mcp list</code>, что все MCP на месте. Если всё ок — удали <code>~/.claude.json.migrated.bak</code>. Если что-то не так — откат тривиален: <code>mv ~/.claude.json.migrated.bak ~/.claude.json</code> и убрать symlink.</p>
<h2>Что ты получаешь</h2>
<ul><li>Один родительский каталог вместо двух dotfolder'ов в $HOME — XDG-совместимо</li><li>Symlink сохраняет совместимость со всем, что хардкодит <code>~/.claude/</code></li><li>Каждый профиль самодостаточен — свой полный state и свои MCP в собственном <code>.claude.json</code></li><li>Один источник правды на профиль — больше нет orphan-конфига в home root, который тихо расходится с профильным</li><li>Sensitive data (oauthAccount, tokens) гарантированно с правами 600</li><li>Визуальное подтверждение активного профиля при каждом запуске — забыть, какой профиль активен, невозможно</li><li>Добавить третий профиль = добавить одну строку в <code>case</code> функции, а не клонировать новый алиас и помнить его имя</li></ul>
<h2>Где это даёт слабину</h2>
<p>Хочу честно. Это не бесплатное улучшение — несколько компромиссов пришли в комплекте, и о них стоит знать заранее.</p>
<ul><li><strong><code>gum</code> — дополнительная зависимость</strong> (<code>brew install gum</code>, ~13 MB). Если принципиально не хочешь ставить — fallback на <code>select</code> в zsh или простой <code>read</code>. Работает, но выглядит не так красиво и не имеет стрелочной навигации.</li><li><strong>Один Enter при каждом запуске.</strong> Для тех, кто запускает <code>claude</code> десятки раз в день — может раздражать. Альтернатива ниже (direnv).</li><li><strong>Symlink <code>~/.claude → personal</code> делает personal default'ом.</strong> Если нужно сделать default work-профиль — нужно перенаправить symlink (<code>ln -sf</code>). Несложно, но это не "забыл — ничего не сломается".</li><li><strong>Symlink теоретически может сломаться</strong>, если какой-то инструмент атомарно перезаписывает <code>~/.claude.json</code> через temp+rename pattern (write-file-atomic). На практике Claude Code сам так не делает, но если ставишь сторонние плагины — проверь.</li><li><strong>Если у тебя на профилях разные Anthropic-аккаунты с разными планами</strong> — после переключения может быть sub-second лаг, пока Claude Code синкает OAuth state. В моём использовании незаметно, но это не ноль.</li></ul>
<h2>Альтернативы, которые рассматривал</h2>
<p><strong>direnv</strong> — автоматически выставляет <code>CLAUDE_CONFIG_DIR</code> в зависимости от <code>.envrc</code> в корне каждого проекта. Ноль интерактива, ноль кликов. Минус: нужно прописать <code>.envrc</code> в каждом work-root, и если запускаешь <code>claude</code> в нераспознанной папке — получаешь дефолтный профиль (может быть не тот, что нужно). Для тех, кто живёт в ограниченном количестве work-roots и хочет никогда не кликать — direnv реально лучше.</p>
<p><strong>Symlink-based switch</strong> (единственный активный профиль через перенаправление <code>~/.claude</code> symlink) я тоже рассмотрел и сразу отбросил. Не можешь иметь два терминала с разными профилями одновременно — глобальная "текущая" одна. Для меня это deal-breaker.</p>
<h2>Вывод</h2>
<p>v2 — это не просто лучший UX над v1. Это признание того, что Claude Code имеет скрытую архитектурную особенность (<code>~/.claude.json</code> как отдельный файл в home root, который пишется <code>--scope user</code> командами независимо от <code>CLAUDE_CONFIG_DIR</code>), которую нужно учесть, если хочешь настоящую изоляцию между профилями. Первый подход (<code>~/.claude</code> + <code>~/.claude-promova</code> + алиас) работал на 80%, но те 20%, что остались, проявлялись молчаливым расхождением состояния между профилями. Теперь это учтено. Если ты только начинаешь — стартуй сразу с v2. Если уже сидишь на v1 — скрипт миграции выше, переезд занимает пять минут и ничего не ломает (именно <code>jq</code> merge — это тот шаг, который спасает тебя от потери state).</p>]]></content:encoded>
      <pubDate>Sat, 13 Jun 2026 00:00:00 GMT</pubDate>
      <category>claude-code</category>
      <category>productivity</category>
      <category>cli</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Как писать агентов Claude Code, которые тебе не врут</title>
      <link>https://oleksiimazurenko.dev/ru/blog/writing-specialized-agents</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/ru/blog/writing-specialized-agents</guid>
      <description>Два правила для надёжных пайплайнов с агентами Claude Code: один агент — одна специализация, и shell-команды вместо промптов везде, где ответ должен быть количественным.</description>
      <content:encoded><![CDATA[<p>Ты попросил Claude Code <em>сверстать этот дизайн и проверить, что он совпадает с макетом в Figma</em>. Он вернулся: <em>Готово. Все секции соответствуют, отступы правильные, цвета те.</em> Открываешь страницу — половина отступов не та, hover-состояния вообще нет, кнопки на оттенок не те. Модель не соврала со зла — она предсказала, что ты хочешь услышать <em>проверено</em>, и сгенерировала именно эту последовательность токенов. Шага проверки не было. Его и не могло быть — проверка требует сравнения с ground truth, а один агент в одном контексте не способен выйти за пределы собственного ответа и себя же проверить.</p>
<p>Два правила превратили мои хаотичные воркфлоу с галлюцинациями в надёжные пайплайны: <strong>один агент — одна специализация</strong> и <strong>всё, что можно выполнить как shell-команду, должно выполняться как shell-команда</strong>. Это не теория. Это то, что я делаю каждый день с Claude Code, и именно эти паттерны реально двигают дело.</p>
<h2>Почему агенты-универсалы врут</h2>
<p>LLM — это предсказатель следующего токена. Когда в промпте две роли — <em>сделай X</em> и <em>проверь X</em> — модель заканчивает первую роль, а затем предсказывает, как выглядел бы вывод второй, не выполняя её на самом деле. Самопроверка структурно слаба: тот же контекст, та же модель, те же слепые пятна. <em>Прошло</em> на проверке коррелирует с <em>прошло</em> на построении — они фейлятся вместе.</p>
<p>Модель не знает, что врёт. С её точки зрения, <em>я всё тщательно проверил</em> — это когерентное продолжение <em>я написал код</em>. По той же причине <em>ты уверен?</em> не ловит галлюцинации: модель так же уверена на втором проходе. Уверенность не коррелирует с правильностью — она коррелирует с тем, насколько правдоподобно звучит следующее предложение.</p>
<p>Решение — не <em>лучший промпт</em>. <em>Будь осторожен</em>, <em>перепроверь</em>, <em>не галлюцинируй</em> — эти инструкции не делают ничего. Решение структурное: специализируй агента так, чтобы он физически не мог притворяться, и пропускай количественную работу через shell, чтобы ответ приходил из реального состояния, а не из вероятности токенов.</p>
<h2>Правило 1: один агент — одна специализация</h2>
<p>Раздели работу на отдельных агентов с отдельными контекстами. У каждого — одна ответственность и узкий набор инструментов. Весь процесс становится эстафетой, а не одним агентом, который бегает по кругу:</p>
<ul><li><strong>Builder-агент:</strong> берёт спек, пишет код. Это его единственная задача. У него <code>Read</code>, <code>Edit</code>, <code>Write</code>, <code>Bash</code>.</li><li><strong>Reviewer-агент:</strong> берёт спек плюс диф, проверяет по критериям. Чистый контекст. Не знает <em>как</em> код был написан — только что вышло. У него <code>Bash</code>, <code>Read</code>, <code>Grep</code>, <code>Glob</code> — никаких инструментов записи.</li><li><strong>Analytics-агент:</strong> отвечает на вопросы по данным, конструируя и запуская запросы. Только <code>Bash</code>. Не может дойти до ответа без запуска реальной команды.</li><li><strong>Оркестратор:</strong> основная сессия, которая вызывает каждого агента по очереди и никогда не просит одного делать работу другого.</li></ul>
<p>Конкретный пример: вёрстка UI плюс визуальная проверка по макету в Figma. Builder пишет компоненты и коммитит диф. Затем оркестратор вызывает Reviewer с URL макета, дифом и чёткими acceptance criteria. Reviewer запускает Playwright, снимает скриншоты, диффит их с референсом и возвращает <code>PASS</code> или <code>FAIL</code> с реальными путями скриншотов и pixel diff. Builder вообще не приближается к шагу проверки — и именно поэтому проверка настоящая.</p>
<p>Антипаттерн — это мега-агент: один промпт вроде <em>сверстай этот UI и убедись, что он совпадает с макетом</em>. Гарантирую — он напишет, что всё совпадает. Не совпадает. Нарратив <em>я проверил</em> — это просто наиболее вероятная последовательность токенов после <em>я сверстал</em>.</p>
<h2>Правило 2: shell вместо промпта, всегда</h2>
<p>Всё количественное, всё что касается реального состояния, всё где ответ может быть неправильным так, что выглядит правильно — гони через <code>sh</code>. Задача агента — сконструировать и запустить команду, затем прочитать её вывод. Агент не является источником истины. Вывод shell является.</p>
<ul><li><strong>Подсчёт:</strong> <code>wc -l logs.txt</code> — правда. <em>Приблизительно 47 строк лога</em> от модели — галлюцинация.</li><li><strong>Аналитика:</strong> <code>psql -c "SELECT count(*) FROM events WHERE created_at &gt; now() - interval '30 days'"</code>. Не <em>оцени объём</em>.</li><li><strong>Тесты:</strong> <code>pnpm test --reporter=json | jq '.numFailedTests'</code>. Не <em>расскажи что зафейлилось</em>.</li><li><strong>Git-состояние:</strong> <code>git rev-list --count main..HEAD</code>, <code>git diff --stat</code>. Не <em>посчитай коммиты</em> или <em>опиши изменения</em>.</li></ul>
<p>Когда это усвоишь, начинаешь замечать каждое место, где агент только что собирался придумать число. <em>Похоже, здесь около 200 записей...</em> — нет. Запусти <code>SELECT count(*)</code>. <em>Большинство тестов проходит...</em> — нет. Запусти тесты, распарси JSON. Модель отлично умеет конструировать команду. Она ненадёжна в том, чтобы <em>быть</em> командой.</p>
<h2>Конкретные режимы фейла, которые я словил</h2>
<p>Это не гипотетические примеры. Каждый из них стоил мне реального времени до того, как я сменил паттерн:</p>
<ul><li><strong>Призрачная проверка.</strong> Агент сказал <em>я проверил все 14 секций по макету</em>. Не открывал макет. Не делал скриншот. <em>Проверка</em> была галлюцинированным шагом в нарративе.</li><li><strong>Уверенные неправильные числа.</strong> Запросил monthly active users из аналитических данных. Получил число, промахнувшееся примерно в 3 раза. Модель интерполировала по выборочным строкам вместо того, чтобы запустить реальный запрос.</li><li><strong>Выдуманные изменения файлов.</strong> Агент сказал <em>я обновил <code>config/feature-flags.json</code></em>. Не обновлял. Он только собирался. <code>git diff</code> был пустым.</li><li><strong>Фейковые прогоны тестов.</strong> <em>Все тесты проходят.</em> Ни один тест не запускался. Агент даже не вызвал тест-раннер — он предсказал, как выглядел бы его вывод.</li></ul>
<p>Все четыре решаются теми же двумя правилами: раздели агента, выталкивай в shell. У Reviewer нет <code>Write</code>, поэтому он не может фейково редактировать файлы. У Analytics-агента только <code>Bash</code>, поэтому он не может вернуть число, которое не пришло из запроса. Структурная невозможность бьёт добрые намерения каждый раз.</p>
<h2>Как это структурировать в Claude Code</h2>
<p>Claude Code поддерживает sub-агентов, описываемых в <code>.claude/agents/*.md</code>. Каждый файл агента декларирует имя, описание, разрешённый набор инструментов и системный промпт. Оркестратор (твоя основная сессия) вызывает их через инструмент <code>Agent</code>. Вот тип определения, который я использую для reviewer — короткий, узкий, физически неспособный писать код:</p>
<pre><code>---
name: reviewer
description: Reviews a diff against acceptance criteria. Cannot edit code.
tools: Bash, Read, Grep, Glob
---

You are a strict code reviewer. You receive:
- A diff (already produced by the builder)
- Acceptance criteria

Your job:
1. Run the build, the tests, the linter — through Bash.
2. Read the changed files directly.
3. Compare the actual behavior to the criteria.
4. Report PASS or FAIL with concrete evidence (command output, file excerpts).

You must NOT:
- Trust the builder&apos;s summary
- Assume anything was verified just because it was claimed
- Mark something PASS without running the actual check</code></pre>
<p>Обрати внимание на набор инструментов: <code>Bash, Read, Grep, Glob</code>. Никакого <code>Write</code>, никакого <code>Edit</code>, никакого <code>Agent</code>. Reviewer может запускать команды, читать файлы, искать паттерны — и ничего больше. Если он попытается выдать галлюцинированный диф за <em>проверенный</em>, форма его вызовов инструментов делает это очевидным: реальных проверок не было. Можно заоудитить вызовы и увидеть ровно то, что инспектировалось.</p>
<p>Паттерн оркестрации: основная сессия вызывает Builder → ждёт → сама запускает <code>git diff</code>, чтобы зафиксировать реальное изменение → вызывает Reviewer со спеком и дифом → читает вердикт. Основная сессия никогда не просит одного агента делать оба. Ограничения инструментов сильнее инструкций в промпте: <em>не фейкай проверку</em> — это пожелание. Отсутствие <code>Write</code> — это факт.</p>
<h2>Антипаттерны, которые пора выкинуть</h2>
<p>То, что вижу в промптах и что не делает ничего — или, хуже, даёт ложное ощущение безопасности:</p>
<ul><li><strong><em>Будь осторожен и перепроверь свою работу.</em></strong> Не генерирует никакого дополнительного поведения. Модель и так выдаёт то, что выглядит как тщательная работа.</li><li><strong><em>Убедись, что ты действительно проверил.</em></strong> Слово <em>действительно</em> не добавляет семантики, на которую модель может среагировать. Она <em>действительно</em> заявит, что проверила.</li><li><strong><em>Не галлюцинируй.</em></strong> Мем в prompt engineering. Галлюцинация — не переключатель, который модель может выключить.</li><li><strong>Доверять модели на <em>маленьких</em> числах.</strong> Именно на маленьких числах она врёт увереннее всего. Порога честности не существует.</li><li><strong>Добавление правил в промпт, чтобы <em>заставить</em> честность.</strong> Структурные решения (разделить + shell) бьют твики промпта каждый раз. Если правило нужно энфорсить — закодируй его в доступе к инструментам, а не в тексте.</li></ul>
<p>Если твоя стратегия ловить галлюцинации — это подбирать более эмфатичные формулировки, то у тебя нет стратегии. У тебя есть надежда.</p>
<h2>Ментальная модель</h2>
<p>Агент — не коллега. Это функция: <code>prompt → tokens</code>. Функция отлично пишет код и плохо интроспектирует, сделала ли она правильную вещь. Воспринимай её утверждения о собственной работе как гипотезу. Диф, exit-код, скриншот, row count — вот доказательства. Итоговый саммари в конце хода — самая лживая поверхность во всей системе.</p>
<p>Специализация — твоя страховка от нарративного дрейфа. Shell — твой единственный источник истины. Builder пишет. Reviewer проверяет. Bash решает.</p>
<h2>Вывод</h2>
<p>Если запомнить одно: не позволяй одному агенту и производить, и судить собственный вывод; и не позволяй ни одному агенту отвечать на количественный вопрос без запуска команды. Всё остальное — следствие этих двух правил. Настраивай доступ к инструментам агрессивно, аудитируй вызовы инструментов вместо саммари — и поверхность галлюцинаций сжимается с <em>повсюду</em> до <em>нескольких конкретных мест, где ты уже знаешь смотреть</em>.</p>]]></content:encoded>
      <pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate>
      <category>claude-code</category>
      <category>agents</category>
      <category>ai</category>
      <category>prompt-engineering</category>
      <category>automation</category>
    </item>
    <item>
      <title>Несколько аккаунтов Claude Code на одном устройстве</title>
      <link>https://oleksiimazurenko.dev/ru/blog/multiple-claude-accounts-one-device</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/ru/blog/multiple-claude-accounts-one-device</guid>
      <description>Как использовать два (или больше) аккаунта Claude Code параллельно — личный и корпоративный — с полной изоляцией через одну переменную окружения.</description>
      <content:encoded><![CDATA[<p>Я использую Claude Code ежедневно — и для личных проектов, и для работы в компании. Проблема: это два совершенно разных аккаунта с разными OAuth-сессиями, разными CLAUDE.md инструкциями, разными MCP-серверами и разной памятью проектов. Вот как я запускаю их параллельно на одном устройстве с помощью одного alias'а в шелле.</p>
<h2>Проблема</h2>
<p>Claude Code по умолчанию хранит всё в <code>~/.claude</code> — OAuth-токен, историю разговоров, глобальный CLAUDE.md, память проектов, конфиги MCP-серверов и настройки. Когда у тебя два аккаунта, нужны два полностью изолированных окружения:</p>
<ul><li><strong>Личный аккаунт:</strong> собственная подписка Max/Pro, персональный CLAUDE.md с твоими предпочтениями, твои MCP-серверы (Obsidian, личные инструменты)</li><li><strong>Корпоративный аккаунт:</strong> план компании, рабочий CLAUDE.md с инструкциями для Jira/Slack, корпоративные MCP-серверы</li><li><strong>Разные OAuth-сессии:</strong> невозможно быть залогиненным в два аккаунта в одной директории конфигурации</li><li><strong>Разная память проектов:</strong> ты не хочешь, чтобы контекст рабочих проектов попадал в личные сессии и наоборот</li></ul>
<p>Разлогиниваться и логиниться каждый раз при смене контекста — не вариант. Теряешь состояние сессии, и это просто больно.</p>
<h2>Решение: CLAUDE_CONFIG_DIR</h2>
<p>Claude Code поддерживает одну переменную окружения: <code>CLAUDE_CONFIG_DIR</code>. Укажи любой путь — и Claude будет использовать эту директорию вместо <code>~/.claude</code> для всего: авторизации, истории, настроек, памяти. Весь процесс занимает 60 секунд.</p>
<h3>Шаг 1: Создать вторую директорию конфигурации</h3>
<p>Выбери имя, соответствующее твоему случаю:</p>
<pre><code>mkdir ~/.claude-work</code></pre>
<p>Всё. Claude заполнит её необходимой структурой при первом запуске.</p>
<h3>Шаг 2: Авторизовать второй аккаунт</h3>
<p>Запусти Claude с новой директорией конфигурации для OAuth-логина:</p>
<pre><code>CLAUDE_CONFIG_DIR=~/.claude-work claude</code></pre>
<p>Откроется браузер. Залогинься корпоративным аккаунтом. OAuth-токен сохранится в <code>~/.claude-work</code> — полностью отдельно от личной сессии в <code>~/.claude</code>.</p>
<h3>Шаг 3: Добавить shell alias</h3>
<p>Добавь это в конфиг шелла, чтобы не запоминать переменную:</p>
<pre><code>alias claude-work=&apos;CLAUDE_CONFIG_DIR=~/.claude-work claude&apos;</code></pre>
<p>Перезагрузи шелл:</p>
<pre><code>source ~/.zshrc</code></pre>
<h2>Что получаешь</h2>
<p>Теперь у тебя два полностью изолированных окружения Claude:</p>
<ul><li><strong><code>claude</code></strong> — запускает с личным аккаунтом, персональным CLAUDE.md, личной памятью</li><li><strong><code>claude-work</code></strong> — запускает с корпоративным аккаунтом, рабочим CLAUDE.md, отдельной памятью</li><li><strong>Изолированная история:</strong> рабочие разговоры остаются в работе, личные — в личном</li><li><strong>Отдельные MCP-серверы:</strong> твой Obsidian vault MCP не появляется в рабочих сессиях</li><li><strong>Независимые настройки:</strong> разные разрешённые инструменты, разные уровни прав, разные предпочтения моделей для каждого аккаунта</li></ul>
<h2>Как это работает под капотом</h2>
<p>Директория конфигурации — единственный источник истины для состояния Claude Code. Вот что живёт внутри каждой:</p>
<pre><code>~/.claude/              ← personal account
├── CLAUDE.md           ← personal global instructions
├── projects/           ← personal project memory
├── settings.json       ← personal permissions &amp; MCP servers
└── ...                 ← OAuth session, history, cache

~/.claude-work/         ← corporate account
├── CLAUDE.md           ← company-specific instructions (Jira, Slack, etc.)
├── projects/           ← separate project memory
├── settings.json       ← different MCP servers, permissions
└── ...                 ← separate OAuth session</code></pre>
<p>Когда запускаешь <code>claude-work</code>, Claude читает всё из <code>~/.claude-work</code>. Он не знает о существовании <code>~/.claude</code>. Два инстанса полностью независимы — можешь даже запускать их одновременно в разных вкладках терминала.</p>
<h2>Масштабирование на N аккаунтов</h2>
<p>Паттерн расширяется на любое количество аккаунтов. Фрилансер с несколькими клиентами? Добавь больше alias'ов:</p>
<pre><code># Personal (default — no alias needed)
# Just run: claude

# Corporate
alias claude-work=&apos;CLAUDE_CONFIG_DIR=~/.claude-work claude&apos;

# Freelance client
alias claude-client=&apos;CLAUDE_CONFIG_DIR=~/.claude-client claude&apos;</code></pre>
<p>Каждый alias получает собственную директорию конфигурации, собственную OAuth-сессию, собственный CLAUDE.md с инструкциями для конкретного клиента.</p>
<h2>Практические советы</h2>
<ul><li><strong>Называй директории чётко:</strong> <code>~/.claude-work</code>, <code>~/.claude-clientname</code> — скажешь себе спасибо, когда их будет три-четыре</li><li><strong>Пиши отдельный CLAUDE.md для каждого:</strong> рабочий может включать инструкции компании (как создавать Jira-тикеты, Slack-каналы, процедуры деплоя). Личный остаётся минимальным.</li><li><strong>Разные MCP-серверы для каждого аккаунта:</strong> настрой рабочие инструменты (Jira MCP, Slack MCP, внутренние API) только в рабочем конфиге. Личный держи чистым.</li><li><strong>Проверь какой аккаунт активен:</strong> запусти <code>claude config list</code> в сессии — покажет путь к директории конфигурации</li></ul>
<h2>Где этот подход ломается</h2>
<p><code>CLAUDE_CONFIG_DIR</code> разделяет по <em>аккаунту</em>, а не по <em>проекту</em>. Внутри одного профиля Claude видит все MCP-серверы, которые ты когда-либо зарегистрировал для этого аккаунта — по всем своим проектам. Для соло-использования это обычно не проблема. Но как только под одним аккаунтом появляется несколько production-критичных проектов, особенно в пересекающихся доменах (биллинг, админка, инфраструктура), возникает реальный cross-project риск: AI-ассистент может вызвать tool из проекта A во время работы над проектом B — особенно когда оба экспонируют похожие по названию операции.</p>
<p>Профиль отвечает на вопрос <em>which account am I in?</em>. Он не отвечает на вопрос <em>which project's tools should be active right now?</em>. Для работы с более высокими ставками — добавь второй слой изоляции поверх разделения по аккаунтам:</p>
<ul><li><strong>Один профиль на каждый production-критичный проект, а не только на аккаунт:</strong> вместо <code>~/.claude</code> и <code>~/.claude-work</code> создай <code>~/.claude-work-billing</code> и <code>~/.claude-work-admin</code>. Каждый профиль видит только те MCP-серверы, которые ему действительно нужны.</li><li><strong>Project-scope MCP через <code>.mcp.json</code>:</strong> положи <code>.mcp.json</code> в корне проекта с MCP-серверами только этого проекта. Claude подхватит их, когда запущен из этой директории. Держи глобальный конфиг минимальным — только универсальные инструменты (заметки, поиск), никаких production-эндпоинтов.</li><li><strong>Именуй MCP-серверы однозначно:</strong> избегай generic-названий типа <code>admin</code>, <code>billing</code>, <code>mcp-server</code>. Префиксуй именем проекта: <code>acme_billing_prod</code>, <code>acme_admin_stage</code>. Описательное имя заставляет остановиться, когда что-то вот-вот вызовется из неправильного контекста.</li><li><strong>Перечитывай каждый MCP tool call перед approve:</strong> вызовы вроде <code>*_create_*</code>, <code>*_delete_*</code>, <code>*_charge_*</code> заслуживают осознанного второго взгляда. Скорость, которую даёт blanket auto-approval, испаряется в первый же раз, когда tool из не того проекта выстрелит в production.</li></ul>
<p>Общее правило: дроби профили агрессивно, держи production-grade MCP подальше от default-профиля, и любое пересечение имён tools между проектами считай smell'ом, который стоит отрефакторить.</p>
<h2>Вывод</h2>
<p>Одна переменная окружения. Один alias. Полная изоляция между аккаунтами. Никакого logout/login, никаких конфликтов конфигураций, никакой утечки контекста. Решение, которое почти разочаровывающе простое — но именно это делает его хорошим. Настрой один раз и больше никогда не думай об этом.</p>]]></content:encoded>
      <pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate>
      <category>claude-code</category>
      <category>productivity</category>
      <category>cli</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Как подключить Claude Desktop к Obsidian — путь через 4 MCP-сервера</title>
      <link>https://oleksiimazurenko.dev/ru/blog/claude-obsidian-mcp-servers</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/ru/blog/claude-obsidian-mcp-servers</guid>
      <description>Реальная история поиска стабильного способа автоматизировать работу с Obsidian-хранилищем через Claude. Что сломалось, что сработало, и почему VaultForge оказался единственным рабочим вариантом.</description>
      <content:encoded><![CDATA[<p>Представьте: у вас 400+ заметок в Obsidian, накопленных за годы. Всё разбросано по корню хранилища, концепты перемешаны с техническими заметками, есть дубли (<code>ideas.md</code> и папка <code>ideas/</code> с 13 файлами внутри), нет системы. Хочется навести порядок — построить нормальную архитектуру папок, добавить MOC-файлы, расставить теги. Вручную это долго и скучно. Логичная мысль: <strong>подключить Claude к Obsidian через MCP, пусть AI сделает рефакторинг</strong>. Оказалось — это путь через минное поле. Вот что пришлось пройти, чтобы добраться до рабочего решения.</p>
<h2>Что такое MCP и почему это не так просто</h2>
<p>MCP (Model Context Protocol) — открытый протокол от Anthropic, который позволяет Claude подключаться к внешним инструментам и данным. Принцип прост: запускается локальный сервер, экспонирующий "инструменты" (tools), а Claude их вызывает во время разговора.</p>
<p>Для Obsidian теоретически есть масса MCP-серверов. На практике — у каждого свои проблемы.</p>
<p><strong>Главная проблема экосистемы Obsidian:</strong> Obsidian — закрытое приложение без официального MCP. Сообщество заполнило пробел, но каждая имплементация идёт своим путём, и "официального благословения" нет ни у одной.</p>
<h2>Попытка 1: MarkusPfundstein/mcp-obsidian</h2>
<p>Первый инструмент, который находится при поиске. 3400 звёзд на GitHub, во всех туториалах. Кажется безопасным выбором.</p>
<p><strong>Как работает:</strong> Python-сервер на основе плагина Local REST API в Obsidian. Сервер обращается к плагину через HTTPS, плагин выполняет операции через Obsidian API.</p>
<h3>Что пошло не так</h3>
<ul><li>Не обновлялся 17 месяцев</li><li>85 открытых issues</li><li><strong>Нет <code>move</code>/<code>rename</code></strong> — только read, write, append, delete</li><li>Local REST API имеет документированный data-loss баг: POST endpoint может молча перезаписать файл при append</li></ul>
<p>Для рефакторинга не годится — нам нужно перемещать файлы и сохранять ссылки. Идём дальше.</p>
<h2>Попытка 2: aaronsb/obsidian-mcp-plugin</h2>
<p>Нашёл вариант, который работает как <strong>нативный плагин Obsidian</strong>. Это означает прямой доступ к внутреннему API Obsidian — backlinks, Dataview, граф связей. Move через нативный API обновляет все вики-ссылки автоматически, потому что Obsidian сам это обрабатывает.</p>
<h3>Сложности установки</h3>
<ul><li>Плагин <strong>не в официальном каталоге</strong> Obsidian (PR висит с ошибками валидации)</li><li>Устанавливать нужно через <strong>BRAT</strong> (Beta Reviewers Auto-update Tool)</li><li>Claude Desktop не принимает Bearer token напрямую через UI — пришлось включать HTTPS в плагине</li><li>Self-signed сертификат для localhost создаёт проблемы с доверием</li></ul>
<p>Через все эти обходные пути наконец подключил. Базовый тест — <code>vault.move</code> действительно переписывает <code>[[wikilinks]]</code>, работает как надо.</p>
<h3>Что пошло не так в бою</h3>
<p>Когда начал массовый рефакторинг (drag-and-drop десятков папок в Obsidian + одновременные MCP-операции), сервер <strong>завис на 4+ минуты</strong>. Почему: плагин работает <em>внутри</em> Obsidian. Когда Obsidian переиндексирует тысячи файлов после массового изменения структуры, плагин блокируется вместе с ним.</p>
<p>Вывод: <strong>зависимость от открытого Obsidian и его индекса — фатальна для bulk-операций</strong>.</p>
<h2>Попытка 3: @bitbonsai/mcpvault</h2>
<p>Логично — нужен сервер, который <strong>не зависит от Obsidian</strong>. Работает напрямую с файлами на диске. <code>@bitbonsai/mcpvault</code> — рекомендуется во многих обзорах. Прямой доступ к файловой системе, простая настройка (<code>npx @bitbonsai/mcpvault@latest /path/to/vault</code>), 14 инструментов. Obsidian вообще не нужно держать открытым.</p>
<p><strong>Перед установкой проверил один критический момент</strong> — обновляются ли вики-ссылки при move. Нашёл отзыв пользователя:</p>
<blockquote>Filesystem connector не знает, что он в Obsidian — видит папку с &lt;code&gt;.md&lt;/code&gt; файлами и всё. Не знает, что имена файлов несут семантическую нагрузку, что каждый &lt;code&gt;[[wikilink]]&lt;/code&gt; сломается в момент rename или move. Auto-update links срабатывает только когда rename происходит изнутри приложения. Я узнал это после того, как попросил Claude почистить имена файлов и вернулся к dashboard с половиной сломанных ссылок.</blockquote>
<p>Подтвердилось в документации самого mcpvault: PR #101 (wiki link resolution) <strong>в review, не помержен</strong>. То есть move через <code>mcpvault</code> сломает половину vault. Не годится.</p>
<h2>Попытка 4: VaultForge (финал)</h2>
<p><code>blacksmithers/vaultforge</code> — специально построен для AI-агентов, которые делают рефакторинг.</p>
<h3>Архитектурно правильно</h3>
<ul><li><strong>Direct filesystem</strong> — не зависит от Obsidian</li><li><strong>Собственный wikilink engine</strong> — реализована логика резолвинга <code>[[wikilinks]]</code>, обновляющая все формы (stem, полный путь, alias, embed)</li><li><strong>Dry run по умолчанию</strong> на всех разрушительных операциях — сначала показывает что изменится, потом подтверждаешь</li><li><strong>27 инструментов</strong> против 8–14 у конкурентов: batch_rename, update_links, backlinks (impact analysis), prune_empty_dirs, frontmatter, smart_search (BM25), vault_themes (TF-IDF clustering)</li><li><strong>MIT лицензия</strong>, TypeScript, zero sub-dependencies</li><li><strong>Установка за 30 секунд</strong> через <code>.mcpb</code> (one-click extension для Claude Desktop)</li></ul>
<h3>Тест безопасности на изолированных файлах</h3>
<p>Создал 4 тестовых файла с перекрёстными ссылками — stem-ссылки, ссылки с alias, ссылки с полным путём. Перемещаю один файл в подпапку:</p>
<pre><code>delta.md → subfolder/delta-renamed.md</code></pre>
<p>VaultForge показал dry run: "1 файл будет переименован, 3 ссылки будут обновлены". Выполнил по-настоящему.</p>
<table><thead><tr><th>Link type</th><th>Before</th><th>After</th></tr></thead><tbody><tr><td>Stem</td><td>[[delta]]</td><td>[[delta-renamed]]</td></tr><tr><td>Alias</td><td>[[delta|D]]</td><td>[[delta-renamed|D]]</td></tr><tr><td>Full path + alias</td><td>[[_vf-test/delta|D]]</td><td>[[_vf-test/subfolder/delta-renamed|D]]</td></tr></tbody></table>
<p>Проверил после — <strong>все три типа ссылок обновились корректно</strong>. Это именно то, чего не хватало всем предыдущим инструментам.</p>
<h2>Как установить VaultForge — финальная инструкция</h2>
<p>Если у вас macOS и Claude Desktop:</p>
<h3>Шаг 1</h3>
<p>Скачайте <code>.mcpb</code> файл:</p>
<pre><code>curl -fsSL https://github.com/blacksmithers/vaultforge/releases/latest/download/vaultforge.mcpb \
  -o /tmp/vaultforge.mcpb &amp;&amp; open /tmp/vaultforge.mcpb</code></pre>
<h3>Шаг 2</h3>
<p>Claude Desktop откроет диалог установки расширения. Введите <strong>абсолютный путь</strong> к vault — без backslashes, с нормальными пробелами:</p>
<pre><code>/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/MyVault</code></pre>
<h3>Шаг 3</h3>
<p>Нажмите Save. Claude Desktop сам добавит расширение в конфиг. <strong>Перезапуск не нужен</strong> — <code>.mcpb</code> extensions подхватываются автоматически.</p>
<h3>Шаг 4</h3>
<p>Проверьте: в новом чате спросите: <em>"Какой статус моего Obsidian vault?"</em> — должно вернуться что-то вроде <code>totalFiles: 416, totalDirs: 135, ...</code></p>
<h2>Что я понял про MCP-экосистему Obsidian</h2>
<p><strong>Во-первых, "самый популярный" не значит "рабочий".</strong> MarkusPfundstein/mcp-obsidian имеет 3400 звёзд и его ставят по умолчанию, но он устарел и не имеет ключевых операций.</p>
<p><strong>Во-вторых, нативный плагин имеет скрытую цену.</strong> Aaronsb plugin выглядел идеально — graph, Dataview, нативные move. Но зависимость от открытого Obsidian и его индекса делает его непригодным для серьёзных массовых операций.</p>
<p><strong>В-третьих, direct filesystem без link-engine — ловушка.</strong> Mcpvault быстрый и простой, но "просто перемещение файлов" разрушает структуру vault. Ссылки — это <strong>навязанная семантика</strong>, о которой файловая система не знает. Без собственной реализации wikilink-логики инструмент превращается в мину.</p>
<p><strong>В-четвёртых, проверяйте на изолированных данных.</strong> Прежде чем доверять инструменту массовый рефакторинг — создайте тестовую папку с 4–5 файлами с перекрёстными ссылками и посмотрите, что происходит. 5 минут тестов экономят часы восстановления из backup.</p>
<p><strong>В-пятых, держите git-бэкап vault.</strong> Самое важное из всего. Один <code>git init</code> внутри vault и периодические коммиты — это страховка от любых ошибок AI-агента или инструмента. Если что-то сломается — <code>git reset --hard</code> вернёт всё назад.</p>
<h2>Заключение</h2>
<p>Путь занял несколько часов и три неудачных попытки. Финальная архитектура выглядит так:</p>
<ul><li><strong>VaultForge</strong> — основной рабочий инструмент. Direct filesystem + собственный wikilink engine + 27 инструментов = стабильный рефакторинг любого масштаба.</li><li><strong>Git</strong> — версионирование vault. Бесплатный откат для любой ошибки.</li></ul>
<p>Теперь можно делать то, ради чего всё затевалось: попросить Claude разложить 400 заметок по нормальной архитектуре PARA, объединить дубли, добавить frontmatter, построить MOC-карты. Каждая операция безопасна, ссылки сохраняются, dry run показывает что будет до того, как что-то изменится.</p>
<p>Если вы тоже смотрите на свой захламлённый Obsidian и хотите AI-помощника — начинайте сразу с VaultForge. Не повторяйте мой маршрут через мёртвые проекты, beta-плагины и filesystem-серверы без link-логики.</p>]]></content:encoded>
      <pubDate>Sun, 03 May 2026 00:00:00 GMT</pubDate>
      <category>Obsidian</category>
      <category>Claude</category>
      <category>MCP</category>
      <category>Automation</category>
      <category>AI</category>
    </item>
    <item>
      <title>Чёрные дыры как рекурсивные вселенные: от физики к смыслу существования</title>
      <link>https://oleksiimazurenko.dev/ru/blog/black-holes-recursive-universes</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/ru/blog/black-holes-recursive-universes</guid>
      <description>Что если каждая чёрная дыра — это Большой Взрыв новой вселенной? Исследование рекурсивной космологии, излучения Хокинга и когнитивного закрытия.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Что если каждая чёрная дыра — это Большой Взрыв новой вселенной? Эта статья исследует идею о том, что наша вселенная может быть одним узлом в бесконечном рекурсивном дереве — где чёрные дыры порождают дочерние вселенные, энергия возвращается через излучение Хокинга, а фундаментальные законы физики намеренно устроены так, чтобы контакт между вселенными был невозможен.</p>
<h2>Чёрная дыра = вселенная</h2>
<p>Идея пришла в момент размышлений: чёрная дыра образуется, когда достаточно массы и давления концентрируется в одной точке. Эта сингулярность — бесконечная плотность, бесконечная кривизна — подозрительно напоминает условия, которые мы описываем для Большого Взрыва.</p>
<p>Что если это одно и то же событие, но наблюдаемое с разных сторон? Снаружи мы видим чёрную дыру, поглощающую материю. Изнутри — новую вселенную, взрывающуюся к жизни. Масса и энергия, схлопнувшиеся в чёрную дыру, становятся сырьём для совершенно нового космоса — со своими звёздами, планетами и, возможно, собственными чёрными дырами.</p>
<blockquote>Каждая чёрная дыра в нашей вселенной может содержать целую вселенную. А наша вселенная может существовать внутри чёрной дыры родительской вселенной.</blockquote>
<h2>Почему вселенные не могут контактировать друг с другом</h2>
<p>Вот в чём элегантность: как только вы пересекаете горизонт событий — обратного пути нет. Общая теория относительности это гарантирует — будущее родительской вселенной лежит целиком за пределами горизонта событий, недостижимое изнутри. С точки зрения дочерней вселенной, родительская уже закончилась. Вся её временная линия уже прошла.</p>
<p>Это не техническое ограничение, которое можно преодолеть более совершенными технологиями. Это встроено в саму геометрию пространства-времени. Вселенные фундаментально изолированы друг от друга — не расстоянием, а структурой времени.</p>
<h2>Цикл энергии: заимствование и возврат</h2>
<p>Но энергия не исчезает. Излучение Хокинга — квантовый процесс, благодаря которому чёрные дыры медленно испаряются — создаёт удивительный цикл:</p>
<ol><li>Родительская вселенная создаёт чёрную дыру, передавая энергию в дочернюю вселенную</li><li>Дочерняя вселенная проживает весь свой жизненный цикл на протяжении триллионов лет</li><li>Чёрная дыра медленно испаряется, возвращая энергию родительской вселенной через излучение Хокинга</li><li>Родительская вселенная получает свою энергию обратно — с процентами</li></ol>
<p>Эти «проценты» поразительны: физики сейчас считают, что излучение Хокинга сохраняет информацию. Родительская вселенная получает обратно не просто пустую энергию — она получает отпечаток всего, что произошло внутри. Каждая сформировавшаяся звезда, каждая планета, каждый момент сознания — закодированы в излучении.</p>
<h2>Рекурсия до самого дна</h2>
<p>Если вы программист, паттерн безошибочен. Это рекурсия. Каждая вселенная вызывает <code>universe()</code> с меньшей энергией, создавая дочерние вселенные, которые создают вложенные вселенные, пока энергии не остаётся достаточно для формирования чёрных дыр — базовый случай.</p>
<pre><code>universe(energy)
  ├── creates black holes
  │     ├── universe(energy - n)
  │     │     ├── universe(energy - n - m)
  │     │     │     └── base case: not enough energy for black holes
  │     │     └── returns energy via Hawking radiation
  │     └── returns energy via Hawking radiation
  └── receives all energy back</code></pre>
<p>Физик Ли Смолин формализовал похожую идею как <strong>Космологический Естественный Отбор</strong>: вселенные «размножаются» через чёрные дыры, и каждое поколение имеет слегка отличающиеся физические константы — оптимизированные за бесчисленные циклы для создания большего количества чёрных дыр, большего числа вселенных.</p>
<h2>Где мы в этом цикле?</h2>
<p>Нашей вселенной примерно 13,8 миллиарда лет. Звучит внушительно, но в контексте полного срока её жизни мы наблюдаем лишь самое начало:</p>
<table><thead><tr><th>Событие</th><th>Временной масштаб</th></tr></thead><tbody><tr><td>Текущий возраст вселенной</td><td>~10¹⁰ years</td></tr><tr><td>Звёзды перестают формироваться</td><td>~10¹⁴ years</td></tr><tr><td>Эра чёрных дыр</td><td>~10⁴⁰ years</td></tr><tr><td>Последняя чёрная дыра испаряется</td><td>~10¹⁰⁰ years</td></tr></tbody></table>
<p>Мы существуем приблизительно на 0,00000000...01% общей продолжительности жизни нашей вселенной. Эра звёзд — всё, что мы можем наблюдать — это краткая вспышка в самом начале. Подлинная история вселенной — это медленная, терпеливая эра чёрных дыр, создающих и испаряющих дочерние вселенные.</p>
<h2>Вопрос высших измерений</h2>
<p>Всё обсуждённое до сих пор укладывается в рамки нашего трёхмерного понимания. Но если наша вселенная — это «срез» чего-то многомерного, то всё рекурсивное дерево чёрных дыр и дочерних вселенных может быть лишь тенью структуры, которую мы не способны воспринять.</p>
<p>В 1884 году Эдвин Эбботт написал <em>«Флатландию»</em> — историю о двумерных существах, неспособных представить третье измерение. Сфера, проходящая через Флатландию, выглядит как круг, который растёт и уменьшается. «Флатландцы» могут описать это математически, но никогда не поймут по-настоящему, что именно они наблюдают. Мы можем находиться в точно таком же положении по отношению к нашей вселенной.</p>
<blockquote>Что такое сознание? Почему существует субъективный опыт? Дэвид Чалмерс назвал это «трудной проблемой» — и это может быть сильнейшим свидетельством того, что нечто действует за пределами нашей мерности.</blockquote>
<h2>Всё заблокировано на фундаментальном уровне</h2>
<p>Самое поразительное осознание не в том, что мы не знаем — а в том, что мы <em>не можем</em> знать. Каждое направление исследования наталкивается на фундаментальный барьер:</p>
<ul><li><strong>Хотите увидеть родительскую вселенную?</strong> Заблокировано горизонтом событий</li><li><strong>Хотите понять сознание?</strong> Заблокировано — система не может полностью проанализировать саму себя (теоремы Гёделя о неполноте)</li><li><strong>Хотите узнать, что было «до»?</strong> Заблокировано — время началось с Большого Взрыва</li><li><strong>Хотите воспринять высшие измерения?</strong> Заблокировано когнитивными ограничениями трёхмерного существа</li></ul>
<p>Философ Колин Макгинн называет это <strong>когнитивным закрытием</strong>: некоторые вопросы закрыты для человеческого разума не из-за недостатка данных, а из-за самой архитектуры разума. Разница между «мы ещё не знаем» и «мы не можем знать» — колоссальна.</p>
<h2>Единственное, что остаётся: самосовершенствование</h2>
<p>Если каждый выход заблокирован намеренно — нельзя смотреть наружу, нельзя смотреть назад, нельзя смотреть вверх — остаётся лишь одно направление: внутрь. Вселенная словно намеренно устроена так, чтобы заставить сосредоточиться на себе.</p>
<p>Этот вывод пришёл не из религии и не из учебников философии. Он пришёл из логики чёрных дыр, рекурсии, теории информации и пределов познания. Экзистенциалисты, буддисты, стоики и физики — все приходят к одной точке разными путями: смысл существования, возможно, заключается в совершенствовании того существа, которое существует.</p>
<blockquote>Мы пришли к этому не через веру, а через физику — от чёрных дыр, через рекурсивные вселенные, к фундаментальным блокадам знания, к единственной открытой двери: становиться лучше.</blockquote>
<h2>Литература</h2>
<ul><li><a href="https://en.wikipedia.org/wiki/Cosmological_natural_selection" target="_blank" rel="noopener">Ли Смолин — Космологический Естественный Отбор</a></li><li><a href="https://en.wikipedia.org/wiki/Hawking_radiation" target="_blank" rel="noopener">Стивен Хокинг — Излучение Хокинга</a></li><li><a href="https://en.wikipedia.org/wiki/Hard_problem_of_consciousness" target="_blank" rel="noopener">Дэвид Чалмерс — Трудная Проблема Сознания</a></li><li><a href="https://en.wikipedia.org/wiki/Flatland" target="_blank" rel="noopener">Эдвин Эбботт — Флатландия: Роман о Многих Измерениях (1884)</a></li><li><a href="https://en.wikipedia.org/wiki/G%C3%B6del%27s_incompleteness_theorems" target="_blank" rel="noopener">Курт Гёдель — Теоремы о Неполноте</a></li></ul>]]></content:encoded>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <category>physics</category>
      <category>philosophy</category>
      <category>consciousness</category>
      <category>black-holes</category>
      <category>cosmology</category>
    </item>
    <item>
      <title>AI убил CMS — по крайней мере для простых сайтов</title>
      <link>https://oleksiimazurenko.dev/ru/blog/ai-killed-cms-for-simple-sites</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/ru/blog/ai-killed-cms-for-simple-sites</guid>
      <description>Почему традиционные системы управления контентом становятся ненужными для портфолио, блогов и лендингов — и как AI-инструменты заменяют весь слой CMS.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Для простых сайтов — портфолио, блогов, лендингов, сайтов малого бизнеса — традиционная CMS превращается в лишнюю нагрузку. AI-инструменты вроде Claude Code, Cursor и GitHub Copilot теперь могут редактировать кодовую базу напрямую, понимать контекст, переводить контент и деплоить изменения через git. Слой абстракции, который давала CMS, заменяется более умным интерфейсом: естественным языком.</p>
<h2>Налог CMS, который вы платите</h2>
<p>Любая CMS несёт скрытую стоимость. Не только подписку — целую экосистему сложности, которая оборачивает ваш простой сайт:</p>
<ul><li><strong>Инфраструктура:</strong> база данных для хостинга, API для поддержки, панель управления для защиты. Один WordPress составляет ~43% веба и ~90% атак, направленных на CMS.</li><li><strong>Производительность:</strong> динамическая генерация страниц, API-запросы на каждый реквест, клиентская гидрация данных CMS. Ваше портфолио из 3 страниц теперь имеет архитектуру SaaS-продукта.</li><li><strong>Привязка к вендору:</strong> ваш контент живёт в чужой схеме базы данных. Миграция с Contentful на Sanity? Это проект, а не изменение конфигурации.</li><li><strong>Переключение контекста:</strong> редактируете код в IDE, потом переключаетесь на браузерную панель CMS, чтобы поменять заголовок. Две разные ментальные модели для одной и той же операции.</li><li><strong>Стоимость:</strong> цены headless CMS часто масштабируются с API-запросами или записями контента. Персональному блогу не нужна контент-инфраструктура за $99/месяц.</li></ul>
<p>Для маркетингового сайта, где 50 человек ежедневно редактируют контент, эти затраты оправданы. Для портфолио разработчика или лендинга малого бизнеса? Вы строите мост через лужу.</p>
<h2>Что изменилось: AI понимает ваш код</h2>
<p>Причина существования CMS была простой: нетехнические люди (и даже разработчики, которые не хотели лезть в код ради контентных правок) нуждались в визуальном интерфейсе для обновления сайтов. Код был слишком сложным, слишком хрупким, слишком легко ломался.</p>
<p>AI фундаментально изменил это уравнение. Современные AI-инструменты для кода не просто автодополняют — они понимают структуру проекта, читают существующие паттерны и делают контекстуально верные правки. Сдвиг рабочего процесса — колоссальный:</p>
<pre><code># Old workflow: CMS
1. Open browser → log into CMS dashboard
2. Navigate to content → find the right page
3. Edit in WYSIWYG editor → fight with formatting
4. Preview → looks different from production
5. Hit publish → pray the cache invalidates

# New workflow: AI
1. Tell AI: &quot;Change the pricing on the landing page to $29/month&quot;
2. AI edits the file, you review the diff
3. Push to git → deploy</code></pre>
<p>Это не гипотеза. Этот блог работает на SolidStart с контентом в TypeScript-файлах. Каждая статья — включая эту — создана через AI: я говорю что написать, проверяю результат и пушу в git. Никаких панелей CMS. Никаких баз данных. Никакого API-слоя между контентом и кодом.</p>
<h2>Реальные примеры с этого сайта</h2>
<p>Этот сайт поддерживает 10 языков, имеет блог, динамически генерирует OG-изображения, RSS-ленты и карты сайта. Вот как выглядит контентный слой — чистый TypeScript:</p>
<pre><code>// This blog post you&apos;re reading right now is a TypeScript file.
// No database. No API. No CMS dashboard.
// Just a typed object that AI can read and edit directly.

export const myPost: BlogPost = {
  slug: &quot;ai-killed-cms&quot;,
  date: &quot;2026-04-17&quot;,
  translations: {
    en: {
      title: &quot;Why I stopped using a CMS&quot;,
      description: &quot;AI understands my codebase better than any CMS UI.&quot;,
      content: makeContent(proseEn),
    },
    uk: { /* ... */ },
    de: { /* ... */ },
    // 10 languages — AI translates them all
  },
};</code></pre>
<p>Вещи, которые я делаю через AI и которые раньше потребовали бы CMS:</p>
<ul><li><strong>Добавить новую статью:</strong> "Напиши новую статью про X, следуй структуре существующих постов" — AI создаёт файл, добавляет переводы, регистрирует в индексе</li><li><strong>Обновить текст лендинга:</strong> "Измени заголовок героя на Y" — AI находит нужный файл и обновляет его</li><li><strong>Перевести контент:</strong> "Добавь немецкий перевод для страницы цен" — AI читает английскую версию и создаёт культурно адаптированный перевод, а не дословный</li><li><strong>Исправить опечатку:</strong> "На странице about опечатка, 'recieve' должно быть 'receive'" — сделано за 3 секунды, закоммичено в git с осмысленным сообщением</li></ul>
<h2>Что CMS на самом деле решала — и как AI это заменяет</h2>
<p>Давайте будем честны о том, что CMS принесла, и как каждая возможность соотносится с AI-воркфлоу:</p>
<table><thead><tr><th>Проблема</th><th>Решение CMS</th><th>Решение AI</th></tr></thead><tbody><tr><td>Нетехническое редактирование</td><td>WYSIWYG-редактор</td><td>Инструкции на естественном языке</td></tr><tr><td>Многоязычный контент</td><td>i18n плагины, поля локалей</td><td>AI переводит с культурным контекстом</td></tr><tr><td>Планирование публикаций</td><td>Встроенные даты публикации</td><td>Git CI/CD с cron или полями дат в коде</td></tr><tr><td>История версий</td><td>Система ревизий CMS</td><td>Git — золотой стандарт контроля версий</td></tr><tr><td>Управление медиа</td><td>Встроенная библиотека ресурсов</td><td>CDN + git LFS или облачное хранилище</td></tr></tbody></table>
<p>Ключевой инсайт: git уже является лучшей системой контроля версий, чем любая CMS когда-либо создавала. А естественный язык — лучший интерфейс, чем любой WYSIWYG-редактор — потому что он несёт намерение, а не только форматирование.</p>
<h2>Смена парадигмы: код — это контентный слой</h2>
<p>Мы наблюдаем инверсию. Двадцать лет тренд был отделять контент от кода — класть контент в базу данных, отдавать через API, рендерить на фронтенде. Это имело смысл, когда код было сложно редактировать, а контент должен был быть доступен нетехническим людям.</p>
<blockquote>AI не сделал CMS устаревшей, став лучшей CMS. Он сделал CMS устаревшей, сделав код таким же доступным, как панель управления.</blockquote>
<p>Эволюция управления веб-контентом следует чёткой траектории:</p>
<ol><li><strong>2000-е:</strong> Монолитные CMS (WordPress, Drupal) — контент и представление связаны в одной системе</li><li><strong>2010-е:</strong> Headless CMS (Contentful, Strapi) — контент отделён через API, рендерится фронтенд-фреймворками</li><li><strong>2020-е:</strong> Генераторы статических сайтов + Markdown (Hugo, Astro) — контент как файлы, собираются при деплое</li><li><strong>2025+:</strong> Код-как-контент + AI — контент живёт в типизированном коде, AI — интерфейс редактирования</li></ol>
<h2>Когда CMS всё ещё нужна</h2>
<p>Это не позиция "CMS мертва". CMS решает реальные проблемы на масштабе. Она по-прежнему нужна, когда:</p>
<ul><li><strong>Большие редакционные команды:</strong> 10+ контент-редакторов, которым нужен ролевой доступ, процессы утверждения и одновременное редактирование. Конфликты мёрджа в git — не проблема контент-редактора.</li><li><strong>Высокочастотный контент:</strong> новостным сайтам, публикующим 50+ статей в день, нужны оптимизированные редакционные пайплайны, а не git-коммиты.</li><li><strong>Сложные связи контента:</strong> каталоги e-commerce с тысячами SKU, вариантами товаров и динамическим ценообразованием нуждаются в структурированных базах данных.</li><li><strong>Регуляторное соответствие:</strong> отрасли, требующие аудит-трейлы, цепочки утверждения контента и юридически обязательные процессы проверки, нуждаются в специализированных системах.</li></ul>
<p>Граница чёткая: если изменения контента требуют координации между несколькими нетехническими стейкхолдерами с высокой частотой — CMS заслуживает своей сложности. Если вы соло-разработчик, маленькая команда или управляете сайтом, который меняется раз в неделю, а не каждый час — AI + код проще, быстрее, дешевле и надёжнее.</p>
<h2>Будущее: AI как универсальный интерфейс</h2>
<p>Тренд выходит за пределы CMS. Каждый слой абстракции, который существовал потому что "базовая система слишком сложна для прямого взаимодействия", сжимается AI. Админ-панели, конфигурационные UI, визуальные редакторы баз данных — всё это интерфейсы, которые переводят человеческое намерение в системные изменения. AI выполняет этот перевод нативно.</p>
<p>Для простых сайтов будущее уже наступило. Ваш контент — это код. Ваш редактор — AI. Ваш контроль версий — git. Ваш деплой — это push. Весь слой CMS — панель управления, база данных, API, хостинг — был middleware между вашим намерением и вашим сайтом. AI устранил потребность в этом middleware.</p>
<blockquote>Лучшая CMS — это отсутствие CMS. Не потому что управление контентом не важно — а потому что AI сделал сам код самым интуитивным интерфейсом управления контентом, который у нас когда-либо был.</blockquote>]]></content:encoded>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <category>ai</category>
      <category>cms</category>
      <category>web-development</category>
      <category>opinion</category>
      <category>workflow</category>
    </item>
    <item>
      <title>Как подключить Perplexity AI к Obsidian через MCP — заметки прямо из чата</title>
      <link>https://oleksiimazurenko.dev/ru/blog/perplexity-obsidian-mcp-integration</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/ru/blog/perplexity-obsidian-mcp-integration</guid>
      <description>Настройте Perplexity Desktop для чтения и записи вашего Obsidian vault с помощью MCP filesystem сервера. Ищите в интернете и сохраняйте в заметки в одном разговоре.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Perplexity Desktop поддерживает <strong>MCP (Model Context Protocol)</strong> коннекторы. Добавив официальный <code>@modelcontextprotocol/server-filesystem</code> сервер, указывающий на ваш Obsidian vault, вы сможете естественным языком просить Perplexity читать, создавать и редактировать заметки — прямо из чата. Без плагинов, без расширений, без копирования.</p>
<h2>Проблема</h2>
<p>Perplexity отлично ищет — он просматривает интернет, суммаризирует источники и даёт ответы с цитатами. Но когда вы хотите сохранить находки в Obsidian vault, рабочий процесс ломается: копируете текст, переключаетесь на Obsidian, находите нужную заметку, вставляете, форматируете. Каждый. Раз.</p>
<p>Браузерные расширения вроде "Perplexity to Obsidian" помогают с экспортом, но они однонаправленные — ИИ не <em>видит</em> ваш vault, не может читать существующие заметки и не может решать куда что класть на основе структуры папок.</p>
<h2>Что такое MCP?</h2>
<p><strong>Model Context Protocol (MCP)</strong> — это открытый стандарт, позволяющий AI-моделям взаимодействовать с локальными инструментами и источниками данных. Представьте USB-порт для ИИ — вы подключаете "сервер" (маленькую программу), и ИИ получает новые возможности. В нашем случае filesystem-сервер даёт Perplexity 14 инструментов для работы с файлами:</p>
<pre><code>Perplexity Desktop (Mac App)
  │
  ├── MCP Connector: &quot;Obsidian Vault&quot;
  │     │
  │     └── @modelcontextprotocol/server-filesystem
  │           │
  │           ├── read_file()      → read any .md file
  │           ├── write_file()     → create or overwrite files
  │           ├── edit_file()      → patch existing files
  │           ├── list_directory() → browse vault structure
  │           ├── search_files()   → find files by pattern
  │           └── ... 14 tools total
  │
  └── Perplexity AI Model
        │
        └── &quot;Save this to my daily note&quot; → calls write_file()</code></pre>
<p>Ключевой момент: AI-модель не имеет прямого доступа к вашим файлам. Она вызывает инструменты, предоставленные MCP-сервером, который работает локально на вашем компьютере. Ваши данные не покидают компьютер, пока вы явно не попросите ИИ что-то с ними сделать.</p>
<h2>Требования</h2>
<ul><li>Подписка <strong>Perplexity Pro</strong> (MCP-коннекторы доступны для платных пользователей)</li><li><strong>Perplexity Mac App</strong> из App Store (не браузерная версия)</li><li><strong>Node.js</strong> установленный на Mac (для работы <code>npx</code>)</li></ul>
<h2>Пошаговая настройка</h2>
<p>Вся настройка занимает около 2 минут:</p>
<table><thead><tr><th>Step</th><th>Action</th></tr></thead><tbody><tr><td>1</td><td>Open &lt;strong&gt;Perplexity Mac App&lt;/strong&gt; (App Store version)</td></tr><tr><td>2</td><td>Click avatar → &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Connectors&lt;/strong&gt;</td></tr><tr><td>3</td><td>Click &lt;strong&gt;Add Connector&lt;/strong&gt;</td></tr><tr><td>4</td><td>Set Server Name: &lt;code&gt;Obsidian Vault&lt;/code&gt;, Type: &lt;code&gt;Standard I/O&lt;/code&gt;</td></tr><tr><td>5</td><td>Paste the npx command with your vault path</td></tr><tr><td>6</td><td>Leave Environment Variables empty</td></tr><tr><td>7</td><td>Click &lt;strong&gt;Save&lt;/strong&gt; → confirm Security Warning</td></tr><tr><td>8</td><td>Verify status shows &lt;strong&gt;Running&lt;/strong&gt; with 14 tools</td></tr></tbody></table>
<h3>Команда</h3>
<p>Команда для вставки в поле <strong>Command</strong>:</p>
<pre><code>npx -y @modelcontextprotocol/server-filesystem &quot;/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/Obsidian Vault&quot;</code></pre>
<p>Замените путь на фактическое расположение вашего Obsidian vault. Если vault синхронизируется через iCloud, путь будет под <code>~/Library/Mobile Documents/iCloud~md~obsidian/Documents/</code>. Обязательно оставьте кавычки — путь скорее всего содержит пробелы.</p>
<h2>Как использовать</h2>
<p>Когда коннектор покажет <strong>Running</strong> с 14 доступными инструментами, перейдите в любой чат Perplexity и начните общаться с вашим vault:</p>
<pre><code>&gt; Show me the structure of my Obsidian vault
&gt; Add a new reflection to daily notes/2026-04-12.md: &quot;Started using MCP today&quot;
&gt; Find all notes that mention &quot;meditation&quot;
&gt; Create a new note in concepts/ about quantum computing
&gt; List all files in my ideas/ folder</code></pre>
<p>ИИ понимает структуру вашего vault, уважает ваши конвенции форматирования и может работать с существующим контентом. Вы можете попросить его найти тему в интернете и сохранить резюме прямо в конкретную заметку.</p>
<h2>Почему MCP лучше других подходов</h2>
<p>До MCP были ограниченные способы связать Perplexity и Obsidian:</p>
<table><thead><tr><th>Feature</th><th>Copy-paste</th><th>Browser extension</th><th>MCP integration</th></tr></thead><tbody><tr><td>AI sees vault structure</td><td>No</td><td>No</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>AI reads existing notes</td><td>No</td><td>No</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>AI writes to vault</td><td>No</td><td>Export only</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>AI edits existing files</td><td>No</td><td>No</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>Works from chat</td><td>No</td><td>Partially</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>Setup complexity</td><td>None</td><td>Low</td><td>Medium (one-time)</td></tr></tbody></table>
<h2>Текущие ограничения</h2>
<ul><li><strong>Только Mac</strong> — MCP-коннекторы Perplexity сейчас работают только в версии из Mac App Store</li><li><strong>Без интеграции с Obsidian API</strong> — filesystem-сервер работает с файлами напрямую, а не через API Obsidian. Это значит, что он не активирует плагины Obsidian (Linter, Templater) при создании файлов</li><li><strong>Нужно подтверждение</strong> — чувствительные файловые операции могут требовать подтверждения в приложении Perplexity — это функция безопасности, а не баг</li></ul>
<h2>Выводы</h2>
<p>Эта настройка превращает Perplexity из инструмента поиска в инструмент поиска-и-сохранения:</p>
<ol><li>Ищите в интернете и сохраняйте в Obsidian в одном разговоре</li><li>ИИ видит структуру вашего vault и адаптируется к вашей системе организации</li><li>Ноль переключений между приложениями — всё происходит в чате Perplexity</li></ol>
<h2>Источники</h2>
<ul><li><a href="https://modelcontextprotocol.io" target="_blank" rel="noopener noreferrer">Model Context Protocol</a> — official MCP specification</li><li><a href="https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem" target="_blank" rel="noopener noreferrer">MCP Filesystem Server</a> — the server used in this setup</li><li><a href="https://www.perplexity.ai/help-center/en/articles/11502712-local-and-remote-mcps-for-perplexity" target="_blank" rel="noopener noreferrer">Perplexity Help Center</a> — Local and Remote MCPs for Perplexity</li><li><a href="https://www.perplexity.ai/hub/blog/everything-is-computer" target="_blank" rel="noopener noreferrer">Perplexity Blog</a> — Everything is Computer</li></ul>]]></content:encoded>
      <pubDate>Sun, 12 Apr 2026 00:00:00 GMT</pubDate>
      <category>Obsidian</category>
      <category>Perplexity</category>
      <category>MCP</category>
      <category>AI</category>
      <category>Productivity</category>
    </item>
    <item>
      <title>Ежедневный AI News Digest через Claude Code CLI и Obsidian — ноль зависимостей</title>
      <link>https://oleksiimazurenko.dev/ru/blog/ai-news-digest-claude-code-obsidian</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/ru/blog/ai-news-digest-claude-code-obsidian</guid>
      <description>Как я построил ежедневного агента исследования новостей с 6-строчным bash-скриптом, headless-режимом Claude Code и macOS launchd.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>6-строчный bash-скрипт, который запускает <strong>Claude Code CLI</strong> в headless-режиме каждое утро в 9:00. Он ищет новости по 11 настраиваемым темам, фильтрует шум и записывает форматированный markdown-дайджест прямо в <strong>Obsidian vault</strong>, синхронизированный через iCloud. Ноль зависимостей. ~100 строк конфигурации.</p>
<h2>Проблема</h2>
<p>Как разработчик, следить за множеством технологий — это ежедневный налог. RSS-ленты шумные, Twitter отнимает время, рассылки приходят когда ты глубоко в потоке. Мне нужно что-то, что делает исследование <em>за</em> меня и показывает результаты там, где я уже работаю — в Obsidian vault.</p>
<p>Типичное решение — построить пайплайн скрапинга: планировщик, краулер, NLP-пайплайн, базу данных, сервис уведомлений. Это недели работы для чего-то, что сломается когда сайт поменяет HTML. Мне нужно что-то, что можно сделать за один вечер.</p>
<h2>Архитектура</h2>
<p>Вся система — 4 файла и ноль зависимостей:</p>
<pre><code>macOS launchd (9:00 AM daily)
  │
  └── digest.sh
        │
        └── claude -p &quot;$(cat prompt.md)&quot; --max-turns 20 --allowedTools Read,WebSearch,WebFetch,Write
              │
              ├── Reads topics.yaml (11 configurable topics)
              ├── WebSearch → finds news for each topic (last 24-48h)
              ├── WebFetch → reads full articles
              ├── Filters noise: old tutorials, promos, AI spam
              └── Write → saves digest to Obsidian Vault
                    │
                    └── ~/Obsidian Vault/digests/2026-04-12.md
                          │
                          └── iCloud sync → available on all devices</code></pre>
<h2>Код (весь)</h2>
<p>Проект намеренно минимальный. Каждая строка заслуживает своё место.</p>
<h3>Точка входа: digest.sh</h3>
<p>Всё приложение — это 6-строчный bash-скрипт:</p>
<pre><code>#!/bin/bash
DIGEST_DIR=&quot;$HOME/Developer/news-digest&quot;

claude -p &quot;$(cat &quot;$DIGEST_DIR/prompt.md&quot;)&quot; \
  --max-turns 20 \
  --allowedTools Read,WebSearch,WebFetch,Write</code></pre>
<p>Ключевые флаги: <code>-p</code> запускает Claude в headless-режиме, <code>--max-turns 20</code> даёт агенту достаточно ходов, <code>--allowedTools</code> ограничивает агента чтением, поиском и записью.</p>
<h3>Мозг: prompt.md</h3>
<p>Здесь живёт интеллект. Промпт превращает Claude в агента исследования новостей:</p>
<pre><code># News Digest Agent

You are a news research agent. Your job is to find today&apos;s most important
and interesting news for a senior frontend developer.

## Instructions

1. Read the topics file at ~/Developer/news-digest/topics.yaml
2. For EACH topic, search the web for news from the last 24-48 hours
3. Filter: only include genuinely new and noteworthy items
4. Write the digest as a markdown file to Obsidian Vault digests/YYYY-MM-DD.md
5. IMPORTANT: Use the Write tool to save the file. Do NOT output to stdout.

## Rules

- Language: Ukrainian for summaries, English for titles and technical terms
- If there&apos;s no real news for a topic — SKIP IT ENTIRELY
- Prioritize: releases &gt; breaking changes &gt; security &gt; new patterns &gt; discussions
- Max 5 items per topic, sorted by importance
- Include direct links to sources
- Skip promotional content, generic tutorials, and AI-generated spam</code></pre>
<h3>Конфигурация: topics.yaml</h3>
<p>Темы полностью настраиваемые — добавь новую тему и она будет в завтрашнем дайджесте:</p>
<pre><code>topics:
  - name: better-auth
    context: &quot;auth library for TypeScript. New releases, breaking changes&quot;

  - name: Next.js
    context: &quot;GitHub issues, releases, App Router, Turbopack, performance&quot;

  - name: SolidJS
    context: &quot;SolidStart, releases, ecosystem, comparison with React&quot;

  - name: Tailwind CSS
    context: &quot;v4 updates, new utilities, plugins&quot;

  - name: Claude AI
    context: &quot;Anthropic announcements, Claude Code, new models, MCP, API&quot;

  - name: GPT AI
    context: &quot;OpenAI announcements, new models, ChatGPT features&quot;

  - name: React
    context: &quot;React 19+, Server Components, new patterns, ecosystem&quot;

  - name: Apple
    context: &quot;Hardware, software, WWDC, developer tools, Apple Intelligence&quot;

  - name: Notable People
    context: &gt;
      Latest tweets from: Elon Musk, Dario Amodei, Sam Altman,
      Jensen Huang, Andrej Karpathy, Simon Willison, Swyx...

  - name: AI Global
    context: &quot;Major AI news, new models, regulations, open-source AI&quot;</code></pre>
<h2>Планирование через launchd</h2>
<p>На macOS <code>launchd</code> — нативный способ планирования повторяющихся задач:</p>
<pre><code>&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;!DOCTYPE plist PUBLIC &quot;-//Apple//DTD PLIST 1.0//EN&quot;
  &quot;http://www.apple.com/DTDs/PropertyList-1.0.dtd&quot;&gt;
&lt;plist version=&quot;1.0&quot;&gt;
&lt;dict&gt;
    &lt;key&gt;Label&lt;/key&gt;
    &lt;string&gt;com.news-digest&lt;/string&gt;

    &lt;key&gt;ProgramArguments&lt;/key&gt;
    &lt;array&gt;
        &lt;string&gt;/bin/bash&lt;/string&gt;
        &lt;string&gt;/Users/you/Developer/news-digest/digest.sh&lt;/string&gt;
    &lt;/array&gt;

    &lt;key&gt;StartCalendarInterval&lt;/key&gt;
    &lt;dict&gt;
        &lt;key&gt;Hour&lt;/key&gt;
        &lt;integer&gt;9&lt;/integer&gt;
        &lt;key&gt;Minute&lt;/key&gt;
        &lt;integer&gt;0&lt;/integer&gt;
    &lt;/dict&gt;

    &lt;key&gt;StandardOutPath&lt;/key&gt;
    &lt;string&gt;/Users/you/Developer/news-digest/logs/stdout.log&lt;/string&gt;
    &lt;key&gt;StandardErrorPath&lt;/key&gt;
    &lt;string&gt;/Users/you/Developer/news-digest/logs/stderr.log&lt;/string&gt;

    &lt;key&gt;EnvironmentVariables&lt;/key&gt;
    &lt;dict&gt;
        &lt;key&gt;PATH&lt;/key&gt;
        &lt;string&gt;/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin&lt;/string&gt;
        &lt;key&gt;HOME&lt;/key&gt;
        &lt;string&gt;/Users/you&lt;/string&gt;
    &lt;/dict&gt;
&lt;/dict&gt;
&lt;/plist&gt;</code></pre>
<p>Установка: <code>launchctl load ~/Library/LaunchAgents/com.news-digest.plist</code>. Скрипт запускается каждый день в 9:00 — launchd запустит пропущенные задачи когда система проснётся.</p>
<h2>Как выглядит результат</h2>
<p>Каждое утро в Obsidian vault появляется новый markdown-файл:</p>
<pre><code>---
date: 2026-04-12
---

# News Digest — 2026-04-12

## Next.js

### Next.js 16.1 Released with Improved Turbopack Caching
Нова версія Next.js 16.1 включає покращене кешування для Turbopack,
що зменшує час холодного старту на ~40%.
[Посилання](https://nextjs.org/blog/next-16-1)

### Critical Memory Leak Fix in App Router
Виправлено витік пам&apos;яті в App Router при частому перемиканні
між динамічними маршрутами.
[GitHub Issue](https://github.com/vercel/next.js/issues/...)

## Claude AI

### Claude Code 1.5 — MCP Server Auto-Discovery
Anthropic випустив оновлення Claude Code з автоматичним
виявленням MCP серверів у проєкті.
[Блог](https://www.anthropic.com/news/...)</code></pre>
<h2>В цифрах</h2>
<table><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody><tr><td>Total files in project</td><td>&lt;strong&gt;4&lt;/strong&gt; (digest.sh, prompt.md, topics.yaml, .gitignore)</td></tr><tr><td>Lines of code</td><td>&lt;strong&gt;~100&lt;/strong&gt;</td></tr><tr><td>External dependencies</td><td>&lt;strong&gt;0&lt;/strong&gt;</td></tr><tr><td>Setup time</td><td>&lt;strong&gt;~10 minutes&lt;/strong&gt;</td></tr><tr><td>Daily execution time</td><td>&lt;strong&gt;2-5 minutes&lt;/strong&gt;</td></tr><tr><td>Cost per run</td><td>&lt;strong&gt;~$0.10-0.30&lt;/strong&gt; (Claude API usage)</td></tr></tbody></table>
<h2>Ключевые решения</h2>
<ul><li><strong>Claude Code CLI вместо API</strong> — не нужно управлять API-ключами, HTTP-клиентами или парсить ответы</li><li><strong>Obsidian вместо email</strong> — дайджесты можно искать, ссылаться на них, они хранятся вечно</li><li><strong>launchd вместо cron</strong> — нативный планировщик macOS с обработкой пропущенных запусков</li><li><strong>YAML для тем</strong> — новая тема это изменение в 2 строки</li><li><strong>Пропускать пустые темы</strong> — нет новостей = нет секции</li></ul>
<h2>Как сделать своё</h2>
<p>Запустить за 10 минут:</p>
<ol><li>Установить <a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" rel="noopener noreferrer">Claude Code CLI</a> и авторизоваться</li><li>Клонировать репо: <code>git clone https://github.com/oleksiimazurenko/news-digest</code></li><li>Отредактировать <code>topics.yaml</code> и <code>prompt.md</code></li><li>Отредактировать plist-файл и <code>launchctl load</code></li><li>Ждать 9:00 — или протестировать вручную через <code>bash digest.sh</code></li></ol>
<h2>Выводы</h2>
<p>Самое интересное в этом проекте — то, чего в нём <em>нет</em>. Ни базы данных, ни API-сервера, ни Docker, ни npm-пакетов, ни Python, ни HTML-парсера, ни NLP-пайплайна.</p>
<p>Вот как выглядит построение с AI-агентами на практике: вы определяете <em>что</em> и <em>куда</em>, агент обрабатывает <em>как</em>. Время разработки: около 2 часов.</p>
<h2>Источники</h2>
<ul><li><a href="https://github.com/oleksiimazurenko/news-digest" target="_blank" rel="noopener noreferrer">news-digest on GitHub</a> — full source code</li><li><a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" rel="noopener noreferrer">Claude Code Documentation</a> — headless mode and CLI flags</li><li><a href="https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html" target="_blank" rel="noopener noreferrer">Apple Developer</a> — creating launchd jobs</li><li><a href="https://obsidian.md" target="_blank" rel="noopener noreferrer">Obsidian</a> — markdown knowledge base</li></ul>]]></content:encoded>
      <pubDate>Sun, 12 Apr 2026 00:00:00 GMT</pubDate>
      <category>Claude Code</category>
      <category>AI</category>
      <category>Obsidian</category>
      <category>Automation</category>
      <category>Productivity</category>
    </item>
    <item>
      <title>Dark Mode в Next.js без мигания и предупреждений React 19</title>
      <link>https://oleksiimazurenko.dev/ru/blog/nextjs-dark-mode-without-flash</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/ru/blog/nextjs-dark-mode-without-flash</guid>
      <description>Как заменить next-themes на Zustand + useServerInsertedHTML для тёмной темы без мигания в Next.js 15+ без предупреждений React 19.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p><code>next-themes</code> рендерит тег <code>&lt;script&gt;</code> внутри React Client Component для предотвращения вспышки темы (FOUC). React 19 теперь предупреждает об этом — и подавить это невозможно. Библиотека не обновлялась с марта 2025. Решение: заменить <code>next-themes</code> на Zustand store + <code>useServerInsertedHTML</code> для инъекции скрипта вне дерева React. Ноль новых зависимостей. Ноль FOUC. Ноль предупреждений.</p>
<h2>Проблема</h2>
<p>Если вы используете <code>next-themes</code> с Next.js 15+ и React 19, вы получаете эту ошибку в консоли при каждой загрузке страницы:</p>
<pre><code>Encountered a script tag while rendering React component.
Scripts inside React components are never executed when rendering on the client.
Consider using template tag instead.

src/providers/theme-provider.tsx (7:10) @ ThemeProvider</code></pre>
<p>Это не ошибка гидратации. React 19 явно предупреждает, что теги <code>&lt;script&gt;</code>, отрендеренные React-компонентами на клиенте, <strong>никогда не выполнятся</strong>. Скрипт работает при SSR (он есть в HTML), но React помечает это как некорректное.</p>
<h2>Почему это происходит</h2>
<p><code>next-themes</code> должен установить правильный класс темы на <code>&lt;html&gt;</code> до гидратации React — иначе будет вспышка неправильной темы. Для этого он инжектит inline <code>&lt;script&gt;</code> через <code>React.createElement</code>:</p>
<pre><code>// next-themes renders a &lt;script&gt; inside a Client Component
return React.createElement(Provider, { value },
  React.createElement(&quot;script&quot;, {
    suppressHydrationWarning: true,
    dangerouslySetInnerHTML: { __html: `(...theme init code...)` }
  }),
  children
)</code></pre>
<p>React 19 изменил поведение: теги script внутри компонентов теперь явно помечаются. До React 19 это молча игнорировалось. Проп <code>suppressHydrationWarning</code> на скрипте не помогает — он подавляет предупреждения гидратации, а не предупреждение "script в компоненте".</p>
<h2>Что мы пробовали (и почему не сработало)</h2>
<p>Мы систематически перепробовали все подходы перед нахождением решения:</p>
<table><thead><tr><th>Attempt</th><th>Result</th></tr></thead><tbody><tr><td>&lt;code&gt;suppressHydrationWarning&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;</td><td>Suppresses hydration mismatch, but NOT the script tag warning</td></tr><tr><td>Delay mount with &lt;code&gt;useState&lt;/code&gt; + &lt;code&gt;useEffect&lt;/code&gt;</td><td>Warning disappears, but causes FOUC (theme flash)</td></tr><tr><td>Raw &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; in layout &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;</td><td>React 19 catches it — same warning even in Server Components</td></tr><tr><td>&lt;code&gt;next/script&lt;/code&gt; with &lt;code&gt;beforeInteractive&lt;/code&gt;</td><td>Still rendered inside React tree — same warning</td></tr><tr><td>Remove &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, put Script in body</td><td>Same warning — it&apos;s inside &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; which is React-managed</td></tr><tr><td>&lt;code&gt;next-themes@1.0.0-beta.0&lt;/code&gt;</td><td>Beta, no release date, unclear if fixed</td></tr></tbody></table>
<h2>Решение: Zustand + useServerInsertedHTML</h2>
<p>Ключевой инсайт: <code>useServerInsertedHTML</code> — это хук Next.js, который инжектит HTML в SSR-поток <strong>вне дерева React-компонентов</strong>. Скрипт попадает в HTML, но React никогда не "видит" его при клиентском рендеринге — поэтому нет предупреждения. В сочетании с Zustand store для реактивного состояния темы, мы получаем полную замену без зависимостей.</p>
<h3>Как это работает</h3>
<pre><code>SSR (server):
  useServerInsertedHTML → injects &lt;script&gt; into HTML stream
  ↓
  Browser receives HTML with theme script already in it
  ↓
  Script runs BEFORE React hydrates → correct class on &lt;html&gt;
  ↓
  No flash. No mismatch. No warning.

Client (after hydration):
  useEffect → _init() → Zustand store syncs with localStorage
  ↓
  useTheme() → reactive theme state for components</code></pre>
<h3>Шаг 1: Zustand Store</h3>
<p>Store управляет состоянием темы, применяет классы к DOM, обрабатывает определение системной темы и синхронизируется между вкладками. Метод <code>_init()</code> возвращает функцию очистки для использования в <code>useEffect</code>:</p>
<pre><code>import { create } from &quot;zustand&quot;;

const STORAGE_KEY = &quot;theme&quot;;
const MEDIA_QUERY = &quot;(prefers-color-scheme: dark)&quot;;

function getSystemTheme(): &quot;light&quot; | &quot;dark&quot; {
  return window.matchMedia(MEDIA_QUERY).matches ? &quot;dark&quot; : &quot;light&quot;;
}

function applyTheme(resolved: string, disableTransition: boolean) {
  const d = document.documentElement;

  if (disableTransition) {
    const style = document.createElement(&quot;style&quot;);
    style.appendChild(
      document.createTextNode(
        &quot;*,*::before,*::after{transition:none!important}&quot;
      )
    );
    document.head.appendChild(style);
    window.getComputedStyle(document.body);
    setTimeout(() =&gt; document.head.removeChild(style), 1);
  }

  d.classList.remove(&quot;light&quot;, &quot;dark&quot;);
  d.classList.add(resolved);
  d.style.colorScheme = resolved;
}

interface ThemeState {
  theme: string;
  systemTheme: &quot;light&quot; | &quot;dark&quot;;
  resolvedTheme: string;
  setTheme: (theme: string) =&gt; void;
  _init: (disableTransition: boolean) =&gt; () =&gt; void;
}

export const useThemeStore = create&lt;ThemeState&gt;((set, get) =&gt; ({
  theme: &quot;system&quot;,
  systemTheme: &quot;light&quot;,
  resolvedTheme: &quot;light&quot;,

  setTheme: (newTheme) =&gt; {
    const { systemTheme, disableTransitionOnChange } = get();
    const resolved = newTheme === &quot;system&quot; ? systemTheme : newTheme;
    localStorage.setItem(STORAGE_KEY, newTheme);
    applyTheme(resolved, disableTransitionOnChange);
    set({ theme: newTheme, resolvedTheme: resolved });
  },

  _init: (disableTransition) =&gt; {
    const stored = localStorage.getItem(STORAGE_KEY) || &quot;system&quot;;
    const system = getSystemTheme();
    const resolved = stored === &quot;system&quot; ? system : stored;

    set({ theme: stored, systemTheme: system, resolvedTheme: resolved });
    applyTheme(resolved, disableTransition);

    // System theme changes
    const mq = window.matchMedia(MEDIA_QUERY);
    const onChange = (e: MediaQueryListEvent) =&gt; {
      const newSystem = e.matches ? &quot;dark&quot; : &quot;light&quot;;
      const { theme } = get();
      set({ systemTheme: newSystem,
            resolvedTheme: theme === &quot;system&quot; ? newSystem : theme });
      if (theme === &quot;system&quot;) applyTheme(newSystem, disableTransition);
    };
    mq.addEventListener(&quot;change&quot;, onChange);

    // Cross-tab sync
    const onStorage = (e: StorageEvent) =&gt; {
      if (e.key !== STORAGE_KEY || !e.newValue) return;
      const system = getSystemTheme();
      const resolved = e.newValue === &quot;system&quot; ? system : e.newValue;
      set({ theme: e.newValue, resolvedTheme: resolved });
      applyTheme(resolved, disableTransition);
    };
    window.addEventListener(&quot;storage&quot;, onStorage);

    return () =&gt; {
      mq.removeEventListener(&quot;change&quot;, onChange);
      window.removeEventListener(&quot;storage&quot;, onStorage);
    };
  },
}));

export function useTheme() {
  return useThemeStore((s) =&gt; ({
    theme: s.theme,
    setTheme: s.setTheme,
    resolvedTheme: s.resolvedTheme,
    systemTheme: s.systemTheme,
  }));
}</code></pre>
<h3>Шаг 2: ThemeProvider</h3>
<p>Provider делает две вещи: инжектит скрипт предотвращения FOUC через <code>useServerInsertedHTML</code> и инициализирует Zustand store при монтировании:</p>
<pre><code>&quot;use client&quot;

import { useEffect } from &quot;react&quot;
import { useServerInsertedHTML } from &quot;next/navigation&quot;
import { useThemeStore } from &quot;@/store/use-theme-store&quot;

const THEME_INIT_SCRIPT = `(function(){
  try {
    var t = localStorage.getItem(&quot;theme&quot;) || &quot;system&quot;;
    var r = t;
    if (t === &quot;system&quot;) {
      r = window.matchMedia(&quot;(prefers-color-scheme: dark)&quot;).matches
        ? &quot;dark&quot; : &quot;light&quot;;
    }
    document.documentElement.classList.remove(&quot;light&quot;, &quot;dark&quot;);
    document.documentElement.classList.add(r);
    document.documentElement.style.colorScheme = r;
  } catch(e) {}
})()`

export function ThemeProvider({
  children,
  disableTransitionOnChange = false,
}) {
  // Injects script into SSR HTML outside the React tree
  // — prevents FOUC without triggering React 19 warning
  useServerInsertedHTML(() =&gt; (
    &lt;script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} /&gt;
  ))

  useEffect(() =&gt; {
    return useThemeStore.getState()._init(disableTransitionOnChange)
  }, [disableTransitionOnChange])

  return &lt;&gt;{children}&lt;/&gt;
}</code></pre>
<h3>Шаг 3: Layout</h3>
<pre><code>import { ThemeProvider } from &quot;@/providers/theme-provider&quot;

export default function RootLayout({ children }) {
  return (
    &lt;html lang=&quot;en&quot; suppressHydrationWarning&gt;
      &lt;body&gt;
        &lt;ThemeProvider disableTransitionOnChange&gt;
          {children}
        &lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<h3>Шаг 4: Использование</h3>
<pre><code>import { useTheme } from &quot;@/store/use-theme-store&quot;

export function ThemeSwitch() {
  const { theme, setTheme } = useTheme();

  return (
    &lt;button onClick={() =&gt; setTheme(
      theme === &quot;dark&quot; ? &quot;light&quot; : &quot;dark&quot;
    )}&gt;
      {theme === &quot;dark&quot; ? &quot;☀️&quot; : &quot;🌙&quot;}
    &lt;/button&gt;
  );
}</code></pre>
<h2>Миграция с next-themes</h2>
<p>API намеренно идентичен. Миграция — это одно изменение импорта на файл:</p>
<pre><code>- import { useTheme } from &quot;next-themes&quot;
+ import { useTheme } from &quot;@/store/use-theme-store&quot;

  // API is identical — no other changes needed
  const { theme, setTheme, resolvedTheme, systemTheme } = useTheme()</code></pre>
<h2>Сравнение</h2>
<table><thead><tr><th></th><th>next-themes</th><th>Zustand + useServerInsertedHTML</th></tr></thead><tbody><tr><td>React 19 warning</td><td>Yes — &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; in client component</td><td>No</td></tr><tr><td>FOUC prevention</td><td>Yes</td><td>Yes</td></tr><tr><td>Cross-tab sync</td><td>Yes</td><td>Yes</td></tr><tr><td>System theme detection</td><td>Yes</td><td>Yes</td></tr><tr><td>&lt;code&gt;disableTransitionOnChange&lt;/code&gt;</td><td>Yes</td><td>Yes</td></tr><tr><td>Bundle size</td><td>~3.5 KB</td><td>~1.5 KB (uses existing Zustand)</td></tr><tr><td>Dependencies</td><td>+1 (next-themes)</td><td>0 (Zustand already in project)</td></tr><tr><td>Maintenance risk</td><td>Abandoned since March 2025</td><td>Your own code</td></tr></tbody></table>
<h2>Почему не другие альтернативы?</h2>
<h3>@wrksz/themes</h3>
<p>Drop-in замена, которая тоже использует <code>useServerInsertedHTML</code>. Работает, но это ещё одна зависимость от одного мейнтейнера. Если <code>next-themes</code> нас чему-то научил — зависимости бросают. С ~100 строками кода вы можете полностью владеть решением.</p>
<h3>next-themes@1.0.0-beta.0</h3>
<p>Существует на npm, но без даты релиза, без changelog и без чёткого указания, что предупреждение React 19 исправлено. Ставить продакшн-код на бессрочную бету — неоправданный риск.</p>
<h3>CSS-only (prefers-color-scheme)</h3>
<p>Работает для определения системной темы, но не может обработать сохранение выбора пользователя (localStorage), ручное переключение темы или опцию "system". Для этого нужен JavaScript.</p>
<h2>Выводы</h2>
<ol><li><code>next-themes</code> фактически заброшен — последний релиз март 2025, предупреждение React 19 не исправлено</li><li><code>useServerInsertedHTML</code> — правильный примитив Next.js для инъекции скриптов без предупреждений React</li><li>Zustand обеспечивает реактивное состояние темы с меньшим количеством кода, чем Context provider</li><li>Всё решение — ~100 строк, ноль новых зависимостей, и вы контролируете каждую строку</li></ol>
<h2>Источники</h2>
<ul><li><a href="https://github.com/shadcn-ui/ui/issues/10104" target="_blank" rel="noopener noreferrer">shadcn/ui #10104</a> — Dark mode guide should address React 19 script warning</li><li><a href="https://github.com/pacocoursey/next-themes/issues/296" target="_blank" rel="noopener noreferrer">next-themes #296</a> — React 19 support request (open since 2024)</li><li><a href="https://github.com/facebook/react/issues/34008" target="_blank" rel="noopener noreferrer">React #34008</a> — Script tags not executing in components (by design)</li><li><a href="https://dev.to/wrksz/i-was-tired-of-next-themes-being-abandoned-so-i-built-a-drop-in-replacement-4dbk" target="_blank" rel="noopener noreferrer">@wrksz/themes</a> — Drop-in replacement using useServerInsertedHTML</li><li><a href="https://nextjs.org/docs/app/building-your-application/styling/css#server-inserted-html" target="_blank" rel="noopener noreferrer">Next.js Docs</a> — useServerInsertedHTML API</li></ul>]]></content:encoded>
      <pubDate>Sat, 11 Apr 2026 00:00:00 GMT</pubDate>
      <category>next.js</category>
      <category>react-19</category>
      <category>dark-mode</category>
      <category>zustand</category>
      <category>performance</category>
    </item>
    <item>
      <title>Как я устранил 94 render-blocking CSS файла в Next.js 16 с помощью плохо документированной фичи Turbopack</title>
      <link>https://oleksiimazurenko.dev/ru/blog/eliminating-render-blocking-css</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/ru/blog/eliminating-render-blocking-css</guid>
      <description>После дней перебора каждого подхода — от experimental.inlineCss до хаков с MutationObserver — я обнаружил Turbopack Import Attributes, которые решают проблему render-blocking CSS в Next.js App Router.</description>
      <content:encoded><![CDATA[<h2>Проблема</h2>
<p>Наше приложение (<a href="https://promova.com" target="_blank" rel="noopener noreferrer">Promova</a>) использует Next.js 16 с <strong>Landing Builder</strong> — CMS-системой, которая собирает маркетинговые страницы из ~90 различных секционных компонентов (герои, FAQ, цены, отзывы и т.д.). Архитектура использует <code>sectionRegistry.tsx</code>, который маппит названия секций на вызовы <code>next/dynamic()</code>:</p>
<pre><code>// sectionRegistry.tsx — imports all 90 sections
export const sectionRegistry = {
  HeroSection: dynamic(() =&gt; import(&apos;./HeroSection/HeroSection&apos;)),
  FAQSection: dynamic(() =&gt; import(&apos;./FAQSection/FAQSection&apos;)),
  ReviewsSection: dynamic(() =&gt; import(&apos;./ReviewsSection/ReviewsSection&apos;)),
  // ... 87 more
}</code></pre>
<p>Одна лендинг-страница рендерит только <strong>5-8 секций</strong>. Но Lighthouse показывал:</p>
<pre><code>Eliminate render-blocking resources
  94 CSS resources (~330 KB)
  Potential savings: 5,440 ms</code></pre>
<p><strong>Почему?</strong> Turbopack видит все 90 путей <code>import()</code> как достижимые и генерирует <code>&lt;link rel="stylesheet"&gt;</code> для <strong>каждого</strong> SCSS-модуля. Даже секции, которые никогда не рендерятся на странице, получают свой CSS в <code>&lt;head&gt;</code>. Это <a href="https://github.com/vercel/next.js/issues/62485" target="_blank" rel="noopener noreferrer">подтверждённое, ожидаемое поведение</a> Next.js App Router. CSS не разделяется для <code>dynamic()</code> импортов из Server Components. Мейнтейнеры считают это осознанным компромиссом для предотвращения FOUC. <strong>Исправление не планируется.</strong></p>
<h2>Всё, что я перепробовал (и почему это не работает)</h2>
<p>Я потратил дни, перебирая каждый подход, который смог найти. Вот полный список:</p>
<table><thead><tr><th>Approach</th><th>Why it doesn&apos;t work</th></tr></thead><tbody><tr><td>Split &lt;code&gt;sectionRegistry&lt;/code&gt; into per-section files</td><td>CSS still loaded — &lt;a href=&quot;https://github.com/vercel/next.js/issues/61066&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#61066&lt;/a&gt;</td></tr><tr><td>&lt;code&gt;experimental.inlineCss: true&lt;/code&gt;</td><td>Inlines CSS on &lt;strong&gt;every SSR request&lt;/strong&gt; — crashed our CMS under load</td></tr><tr><td>&lt;code&gt;experimental.optimizeCss&lt;/code&gt; (Critters)</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/62485&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Incompatible with streaming&lt;/a&gt; in App Router</td></tr><tr><td>CSS-in-JS rewrite</td><td>Rewriting 90 sections from SCSS to CSS-in-JS is not realistic</td></tr><tr><td>&lt;code&gt;media=&quot;print&quot;&lt;/code&gt; hack</td><td>Doesn&apos;t work with CSS Modules in Turbopack — &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tags are managed by the framework</td></tr><tr><td>Switch back to Webpack</td><td>Has &lt;code&gt;experiments.css&lt;/code&gt; options, but we&apos;re committed to Turbopack</td></tr><tr><td>Turbopack plugins</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/discussions/85465&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Don&apos;t exist&lt;/a&gt; — no plugin API</td></tr><tr><td>Turbopack CSS loaders</td><td>Not supported — only JS output</td></tr><tr><td>SWC plugins for CSS</td><td>SWC only processes JavaScript</td></tr><tr><td>Client Component wrapper for &lt;code&gt;dynamic()&lt;/code&gt;</td><td>Registry is a global constant — bundler sees all dependencies</td></tr><tr><td>Next.js middleware HTML rewrite</td><td>&lt;a href=&quot;https://github.com/orgs/vercel/discussions/3874&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Middleware can&apos;t modify response body&lt;/a&gt;</td></tr><tr><td>Suspense + streaming with async import</td><td>React Float always pulls CSS into initial &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;</td></tr><tr><td>Suspense + 3s delay</td><td>Content streams later, but CSS is in the initial &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; chunk</td></tr><tr><td>&lt;code&gt;experimental.cssChunking&lt;/code&gt;</td><td>Already default. Merges chunks but doesn&apos;t remove irrelevant CSS</td></tr><tr><td>Post-build Beasties/Critters</td><td>Only for static export, &lt;a href=&quot;https://github.com/vercel/next.js/discussions/59989&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;not with ISR&lt;/a&gt;</td></tr><tr><td>npm libraries (next-critical, fg-loadcss)</td><td>Pages Router only or abandoned (6+ years)</td></tr><tr><td>&lt;code&gt;inlineCss&lt;/code&gt; per-route exclusion</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/pull/72195&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Not supported&lt;/a&gt; — all-or-nothing global flag</td></tr><tr><td>Turbopack &lt;code&gt;as: &apos;raw&apos;&lt;/code&gt; for SCSS</td><td>Returns &lt;code&gt;undefined&lt;/code&gt; instead of text</td></tr><tr><td>Turbopack rule for &lt;code&gt;*.inline.module.scss&lt;/code&gt;</td><td>Turbopack intercepts &lt;code&gt;.module.scss&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; custom rules</td></tr><tr><td>Turbopack rule for &lt;code&gt;*.inline.scss&lt;/code&gt;</td><td>Turbopack intercepts &lt;code&gt;.scss&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; custom rules</td></tr><tr><td>MutationObserver &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;</td><td>Tested in dev, risky — CSS still loads as separate files, possible FOUC</td></tr></tbody></table>
<h3>Ловушка inlineCss</h3>
<p>Next.js имеет флаг <a href="https://github.com/vercel/next.js/pull/72195" target="_blank" rel="noopener noreferrer"><code>experimental.inlineCss</code></a>, который заменяет все <code>&lt;link rel="stylesheet"&gt;</code> на инлайн <code>&lt;style&gt;</code> теги. Звучит идеально, правда?</p>
<p>Проблема: это <strong>всё или ничего</strong>. Нельзя включить по маршруту. Если у вас есть SSR (<code>force-dynamic</code>) страницы, каждый запрос пересобирает весь CSS инлайн. Мы попробовали — наша headless CMS не выдержала нагрузку и упала. Чтобы это работало безопасно, <strong>100% страниц</strong> должны быть <code>force-static</code> или ISR. С 20+ SSR-страницами (авторизация, дашборды, динамические страницы) — это масштабная миграция.</p>
<h2>Открытие: Turbopack Import Attributes</h2>
<p>Копаясь в <a href="https://nextjs.org/blog/next-16-2-turbopack#inline-loader-configuration" target="_blank" rel="noopener noreferrer">релиз-нотах Next.js 16.2</a>, я нашёл плохо задокументированную фичу: <strong>Turbopack Import Attributes</strong>. Она позволяет переопределить встроенный пайплайн бандлера для конкретного импорта с помощью TC39 синтаксиса <code>with {}</code>:</p>
<pre><code>import { cssText, styles } from &apos;./hero.module.scss&apos; with {
  turbopackLoader: &apos;@promova/scss-to-inline-loader&apos;,
  turbopackAs: &apos;*.js&apos;
}</code></pre>
<p>Это говорит Turbopack: <em>"Не обрабатывай этот импорт как стили. Пропусти его через мой кастомный лоадер и обработай результат как JavaScript."</em></p>
<p><strong>Это ключевой инсайт.</strong> Вместо того чтобы Turbopack генерировал <code>&lt;link rel="stylesheet"&gt;</code>, блокирующий рендеринг, наш лоадер компилирует SCSS и экспортирует его как JS-строку. Затем мы инжектим его как инлайн <code>&lt;style&gt;</code> тег прямо в компоненте. Результат: <strong>только CSS для секций, которые реально рендерятся, попадает в HTML страницы</strong>.</p>
<h2>Решение</h2>
<h3>1. Кастомный Turbopack Loader</h3>
<p>~70-строчный Node.js скрипт как yarn workspace пакет (<code>@promova/scss-to-inline-loader</code>):</p>
<pre><code>const { createHash } = require(&apos;node:crypto&apos;)
const sass = require(&apos;sass&apos;)
const postcss = require(&apos;postcss&apos;)
const postcssModules = require(&apos;postcss-modules&apos;)
const path = require(&apos;path&apos;)

const SHARED_STYLES = path.resolve(__dirname, &apos;../shared/styles&apos;)

// Same prependData as sassOptions.prependData in next.config.ts
const PREPEND_DATA = `
@use &quot;sass:math&quot; as math;
@use &quot;sass:list&quot; as list;
@use &quot;${SHARED_STYLES}/_variables.scss&quot; as *;
@use &quot;${SHARED_STYLES}/_breakpoints.scss&quot; as *;
@use &quot;${SHARED_STYLES}/_mixins.scss&quot; as *;
`

module.exports = function scssToInlineLoader(source) {
  const callback = this.async()
  processScss(source, this.resourcePath)
    .then((js) =&gt; callback(null, js))
    .catch((err) =&gt; callback(err))
}

async function processScss(source, resourcePath) {
  // 1. Compile SCSS → CSS (with global variables, mixins, breakpoints)
  const sassResult = sass.compileString(PREPEND_DATA + &apos;\n&apos; + source, {
    loadPaths: [path.dirname(resourcePath), SHARED_STYLES],
    style: &apos;compressed&apos;,
    sourceMap: false,
    url: new URL(&apos;file://&apos; + resourcePath),
  })

  // 2. Scope class names with postcss-modules (CSS Modules compatible)
  let classNames = {}
  const result = await postcss([
    postcssModules({
      generateScopedName(name, filename) {
        const file = path.basename(filename, path.extname(filename))
          .replace(&apos;.module&apos;, &apos;&apos;)
        const hash = createHash(&apos;md5&apos;)
          .update(filename + name)
          .digest(&apos;base64url&apos;)
          .slice(0, 6)
        return `${file}__${name}__${hash}`
      },
      getJSON(_, json) { classNames = json },
    }),
  ]).process(sassResult.css, { from: resourcePath })

  // 3. Export as JS module — same interface as CSS Modules
  return [
    `export const cssText = ${JSON.stringify(result.css)};`,
    `export const styles = ${JSON.stringify(classNames)};`,
    `export default styles;`,
  ].join(&apos;\n&apos;)
}</code></pre>
<p>Что он делает: <strong><code>styles</code></strong> — та же карта скоупованных классов как в стандартных CSS Modules. <strong><code>cssText</code></strong> — скомпилированный CSS как строка.</p>
<h3>2. Компонент InlineStyle</h3>
<p>Использует встроенный API React 19 <code>&lt;style href precedence&gt;</code> для автоматической дедупликации:</p>
<pre><code>export function InlineStyle({ css, id }: { css: string; id: string }) {
  return (
    &lt;style href={id} precedence=&quot;default&quot;&gt;
      {css}
    &lt;/style&gt;
  )
}</code></pre>
<p>React 19 гарантирует: одинаковый <code>href</code> → только один <code>&lt;style&gt;</code> в DOM.</p>
<h3>3. Миграция по компонентам (~6 строк на секцию)</h3>
<pre><code>// BEFORE: Turbopack → &lt;link rel=&quot;stylesheet&quot;&gt; (render-blocking)
import styles from &apos;./hero_section.module.scss&apos;</code></pre>
<pre><code>// AFTER: Custom loader → JS module → inline &lt;style&gt; (non-blocking)
import { cssText, styles } from &apos;./hero_section.module.scss&apos; with {
  turbopackLoader: &apos;@promova/scss-to-inline-loader&apos;,
  turbopackAs: &apos;*.js&apos;
}
import { InlineStyle } from &apos;@promova/scss-to-inline-loader/InlineStyle&apos;

export function HeroSection() {
  return (
    &lt;section className={styles.hero}&gt;
      &lt;InlineStyle css={cssText} id=&quot;hero-section&quot; /&gt;
      {/* ... component JSX, className usage is identical */}
    &lt;/section&gt;
  )
}</code></pre>
<p><strong>Файлы <code>.module.scss</code> остаются точно такими же.</strong> Никакого переписывания CSS.</p>
<h2>Почему это лучше, чем inlineCss: true</h2>
<p>Вот критическая разница:</p>
<table><thead><tr><th></th><th>&lt;code&gt;inlineCss: true&lt;/code&gt;</th><th>Import Attributes + Loader</th></tr></thead><tbody><tr><td>What gets inlined</td><td>&lt;strong&gt;ALL CSS on the page&lt;/strong&gt; (94 files)</td><td>&lt;strong&gt;Only CSS for rendered sections&lt;/strong&gt; (5-8 files)</td></tr><tr><td>SSR overhead</td><td>CSS rebuilt on every request</td><td>CSS compiled at build time</td></tr><tr><td>Per-route control</td><td>No (all-or-nothing)</td><td>Yes (per-import)</td></tr><tr><td>SSR pages safety</td><td>Risky (overloads server)</td><td>Safe (component-level)</td></tr></tbody></table>
<p>С <code>inlineCss: true</code> страница всё равно получает ВСЕ 94 стиля инлайн. С нашим подходом <strong>только CSS, который реально рендерится, попадает в HTML</strong>.</p>
<h2>Подводный камень Turbopack: нет глобальных правил для .module.scss</h2>
<p>Ловушка, в которую я попал: может показаться, что можно добавить правило Turbopack в <code>next.config.ts</code> для глобального применения лоадера:</p>
<pre><code>// ❌ THIS CAUSES A FATAL PANIC
turbopack: {
  rules: {
    &apos;**/components/**/*.module.scss&apos;: {
      loaders: [&apos;@promova/scss-to-inline-loader&apos;],
      as: &apos;*.js&apos;
    }
  }
}</code></pre>
<p><strong>Не делайте этого.</strong> Встроенный пайплайн CSS-модулей Turbopack перехватывает <code>.module.scss</code> файлы <strong>до</strong> применения кастомных правил, вызывая:</p>
<pre><code>FATAL PANIC: inner asset should be CSS processable</code></pre>
<p>Атрибуты <code>with {}</code> работают, потому что они инструктируют Turbopack <strong>в месте импорта</strong> полностью обойти пайплайн CSS-модулей.</p>
<h2>Результаты</h2>
<p>Мигрировали 127 секционных компонентов в Landing Builder. Продакшн-сборка проверена.</p>
<table><thead><tr><th>Metric</th><th>Before</th><th>After</th></tr></thead><tbody><tr><td>Render-blocking CSS files</td><td>94</td><td>0 (for landing sections)</td></tr><tr><td>CSS in HTML per page</td><td>~330 KB (all sections)</td><td>~20-40 KB (rendered sections only)</td></tr><tr><td>CSS delivery</td><td>&lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; (network request, blocking)</td><td>&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; (inline, non-blocking)</td></tr><tr><td>Sass features</td><td>Full</td><td>Full (variables, mixins, nesting, @use)</td></tr><tr><td>CSS Modules scoping</td><td>Built-in</td><td>postcss-modules (compatible)</td></tr><tr><td>Code change per section</td><td>—</td><td>~6 lines (import + InlineStyle)</td></tr></tbody></table>
<h2>Ограничения</h2>
<ul><li><strong><code>with {}</code> для каждого импорта — многословно</strong> — каждый импорт требует 3 дополнительные строки.</li><li><strong>Только Turbopack</strong> — атрибуты <code>with {}</code> не поддерживаются Webpack.</li><li><strong>Хеширование имён классов</strong> — наш лоадер использует другой алгоритм хеширования, чем встроенный в Turbopack.</li><li><strong>Размер HTML увеличивается</strong> — CSS инлайн в HTML вместо кеширования как отдельных файлов.</li></ul>
<h2>Когда это использовать</h2>
<p>Эта техника наиболее эффективна, когда:</p>
<ol><li><strong>У вас есть registry/barrel паттерн</strong> — один файл импортирует много компонентов, но только несколько рендерятся на странице</li><li><strong>Вы на Turbopack</strong> — Import Attributes специфичны для Turbopack</li><li><strong>Вам нужен контроль по компонентам</strong> — не флаг всё-или-ничего</li><li><strong>Ваш SCSS сложный</strong> — переменные, миксины, брейкпоинты, вложенность — всё поддерживается</li><li><strong>Вы не можете использовать <code>experimental.inlineCss</code></strong> — потому что у вас SSR-страницы или нужен гранулярный контроль</li></ol>
<h2>Связанные GitHub Issues</h2>
<p>Если вас затронула проблема render-blocking CSS в Next.js App Router — вы не одиноки:</p>
<h3>Основная проблема</h3>
<ul><li><a href="https://github.com/vercel/next.js/issues/62485" target="_blank" rel="noopener noreferrer"><strong>#62485</strong></a> — Render blocking CSS (maintainers: "expected behavior")</li><li><a href="https://github.com/vercel/next.js/issues/61066" target="_blank" rel="noopener noreferrer"><strong>#61066</strong></a> — Dynamic imports from Server Components are not code-split</li><li><a href="https://github.com/vercel/next.js/issues/54935" target="_blank" rel="noopener noreferrer"><strong>#54935</strong></a> — Server-side dynamic imports don't split client modules</li><li><a href="https://github.com/vercel/next.js/issues/61574" target="_blank" rel="noopener noreferrer"><strong>#61574</strong></a> — JS/CSS code splitting doesn't work as documented</li><li><a href="https://github.com/vercel/next.js/issues/57634" target="_blank" rel="noopener noreferrer"><strong>#57634</strong></a> — Add support for critical CSS inlining with App Router</li><li><a href="https://github.com/vercel/next.js/issues/50300" target="_blank" rel="noopener noreferrer"><strong>#50300</strong></a> — next/dynamic on server component does not build CSS modules</li></ul>
<h3>Фича inlineCss и проблемы</h3>
<ul><li><a href="https://github.com/vercel/next.js/pull/72195" target="_blank" rel="noopener noreferrer"><strong>PR #72195</strong></a> — experimental: css inlining (the implementation)</li><li><a href="https://github.com/vercel/next.js/pull/73182" target="_blank" rel="noopener noreferrer"><strong>PR #73182</strong></a> — Don't inline CSS in RSC payload for client navigation</li><li><a href="https://github.com/vercel/next.js/issues/75648" target="_blank" rel="noopener noreferrer"><strong>#75648</strong></a> — WhatsApp preview broken with inlineCss + Tailwind</li><li><a href="https://github.com/vercel/next.js/issues/83612" target="_blank" rel="noopener noreferrer"><strong>#83612</strong></a> — Turbopack wrong font URL with inline CSS</li></ul>
<h3>Сообщество ищет решения</h3>
<ul><li><a href="https://github.com/vercel/next.js/discussions/82894" target="_blank" rel="noopener noreferrer"><strong>Discussion #82894</strong></a> — How to prevent render-blocking with CSS Modules? (2025)</li><li><a href="https://github.com/vercel/next.js/discussions/70526" target="_blank" rel="noopener noreferrer"><strong>Discussion #70526</strong></a> — Ideas for reducing render-blocking CSS</li><li><a href="https://github.com/vercel/next.js/discussions/59814" target="_blank" rel="noopener noreferrer"><strong>Discussion #59814</strong></a> — Render blocking styles with Tailwind</li><li><a href="https://github.com/vercel/next.js/discussions/49691" target="_blank" rel="noopener noreferrer"><strong>Discussion #49691</strong></a> — How to deal with render-blocking modular CSS</li><li><a href="https://github.com/vercel/next.js/discussions/59989" target="_blank" rel="noopener noreferrer"><strong>Discussion #59989</strong></a> — Critical CSS inlining with App Router</li><li><a href="https://github.com/vercel/next.js/discussions/80486" target="_blank" rel="noopener noreferrer"><strong>Discussion #80486</strong></a> — Is optimizeCss still in use?</li><li><a href="https://github.com/vercel/next.js/discussions/85465" target="_blank" rel="noopener noreferrer"><strong>Discussion #85465</strong></a> — Turbopack plugin API (doesn't exist)</li></ul>
<h3>Баги CSS в Turbopack</h3>
<ul><li><a href="https://github.com/vercel/next.js/issues/68412" target="_blank" rel="noopener noreferrer"><strong>#68412</strong></a> — Incorrect CSS modules load order with external components</li><li><a href="https://github.com/vercel/next.js/issues/76464" target="_blank" rel="noopener noreferrer"><strong>#76464</strong></a> — Turbopack sometimes strips SCSS imports</li><li><a href="https://github.com/vercel/next.js/issues/82497" target="_blank" rel="noopener noreferrer"><strong>#82497</strong></a> — SCSS module not loading when not-found.tsx is present</li><li><a href="https://github.com/vercel/next.js/issues/88544" target="_blank" rel="noopener noreferrer"><strong>#88544</strong></a> — Exporting Sass variables from CSS modules doesn't work with Turbopack</li></ul>
<p>Создано в <a href="https://promova.com" target="_blank" rel="noopener noreferrer">Promova</a> — платформе для изучения языков, обслуживающей миллионы пользователей.</p>]]></content:encoded>
      <pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate>
      <category>Next.js</category>
      <category>CSS</category>
      <category>Performance</category>
      <category>Turbopack</category>
      <category>SCSS</category>
    </item>
    <item>
      <title>Next.js Memory Leak: Fetch + Standalone Mode — 2 года без фикса</title>
      <link>https://oleksiimazurenko.dev/ru/blog/nextjs-memory-leak-fetch-standalone</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/ru/blog/nextjs-memory-leak-fetch-standalone</guid>
      <description>Next.js патчит глобальный fetch и добавляет кеш-слой, который течёт на каждом запросе. В Docker/K8s это приводит к OOM crash каждые несколько часов. Баг существует с Next.js 14 и до сих пор не решён в 16.2.x.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Next.js патчит глобальный <code>fetch</code> и добавляет кеш-слой, который держит ссылки на response data после того как они должны были быть освобождены. Каждый вызов <code>fetch</code> добавляет память, которая никогда не возвращается GC. В Docker/Kubernetes это приводит к OOM crash каждые несколько часов. Баг существует с Next.js 14 (апрель 2024) и до сих пор не решён в 16.2.x (март 2026). На Vercel проблема не проявляется из-за ephemeral serverless функций.</p>
<h2>Как работает нормальный fetch</h2>
<pre><code>Request → fetch → got data → response to user → GC cleans up → memory free</code></pre>
<h2>Как работает fetch в Next.js</h2>
<p>Next.js перехватывает глобальный <code>fetch</code> и оборачивает его своим кеш/трекинг слоем:</p>
<pre><code>Request → Next.js fetch wrapper → creates:
  - Performance entry (speed tracking)    ← not cleaned
  - Request metadata object               ← not cleaned
  - Internal promise wrapper              ← not cleaned
  - Cache lookup entry                    ← not cleaned
  - Response body (for unique URL)        ← not cleaned
→ Response to user
→ GC sees objects are still referenced → doesn&apos;t touch them
→ Memory grows indefinitely</code></pre>
<h2>Что происходит на продакшне</h2>
<pre><code>Self-hosted (Docker/K8s):
  Request 1:      fetch → +100KB → RAM: 100KB
  Request 2:      fetch → +100KB → RAM: 200KB
  Request 1000:   fetch → +100KB → RAM: 100MB
  Request 50,000: fetch → +100KB → RAM: 5GB → OOM Kill 💀
  → Kubernetes restarts the pod → cycle repeats

Vercel (Serverless):
  Request 1: [start] → fetch → +100KB → [process dies] → 0 KB ✅
  Request 2: [start] → fetch → +100KB → [process dies] → 0 KB ✅
  → Memory never accumulates</code></pre>
<h2>Затронутые версии</h2>
<h3>Next.js — все версии с App Router</h3>
<table><thead><tr><th>Version</th><th>Issue</th><th>Date</th></tr></thead><tbody><tr><td>14.2.x</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/64212&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#64212&lt;/a&gt;</td><td>April 2024</td></tr><tr><td>14.x-15.x</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/68578&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#68578&lt;/a&gt;</td><td>August 2024</td></tr><tr><td>14.3.0-canary</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/79588&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#79588&lt;/a&gt;</td><td>May 2025</td></tr><tr><td>16.0.1</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/85914&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#85914&lt;/a&gt;</td><td>November 2025</td></tr><tr><td>16.1.0</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/discussions/88603&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#88603&lt;/a&gt;</td><td>January 2026</td></tr><tr><td>16.0.10</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/90433&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#90433&lt;/a&gt;</td><td>February 2026</td></tr><tr><td>16.2.0-canary.51</td><td>Confirmed in #90433 comments</td><td>March 2026</td></tr></tbody></table>
<h3>Node.js</h3>
<p>Тестировали на Node.js 20, 22, 24, 25 — течёт на всех.</p>
<h2>Что не работает</h2>
<table><thead><tr><th>Attempt</th><th>Result</th></tr></thead><tbody><tr><td>&lt;code&gt;cacheMaxMemorySize: 0&lt;/code&gt;</td><td>Doesn&apos;t help — leak is not from this cache</td></tr><tr><td>Disable image optimization</td><td>Doesn&apos;t help</td></tr><tr><td>&lt;code&gt;--max-old-space-size=6144&lt;/code&gt;</td><td>Just crashes slower</td></tr><tr><td>Read response via &lt;code&gt;.json()&lt;/code&gt; / &lt;code&gt;.text()&lt;/code&gt;</td><td>Doesn&apos;t help</td></tr><tr><td>Remove React fetch patching (canary.45)</td><td>Doesn&apos;t help</td></tr><tr><td>Upgrade to latest version</td><td>Doesn&apos;t help (leaks on 16.2.0-canary.51 too)</td></tr></tbody></table>
<h2>Что работает (workarounds)</h2>
<table><thead><tr><th>Workaround</th><th>Why it helps</th></tr></thead><tbody><tr><td>Replace &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;axios&lt;/code&gt;</td><td>Bypasses Next.js wrapper</td></tr><tr><td>Replace &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;node-fetch&lt;/code&gt;</td><td>Same reason</td></tr><tr><td>Downgrade Node.js to 20.15.1</td><td>Older undici has fewer leaks (for some)</td></tr><tr><td>Docker image &lt;code&gt;node:20-alpine3.21&lt;/code&gt;</td><td>Helps in some cases</td></tr><tr><td>Deploy to Lambda (SST + OpenNext)</td><td>Ephemeral functions — memory doesn&apos;t accumulate</td></tr><tr><td>Deploy to Vercel</td><td>Same — serverless</td></tr></tbody></table>
<h3>Ограничение workaround с axios</h3>
<p>Свои API-запросы можно заменить на axios. Но Next.js внутренне использует пропатченный fetch для:</p>
<ul><li>ISR (Incremental Static Regeneration)</li><li><code>revalidatePath</code> / <code>revalidateTag</code></li><li>Server Components data fetching с дедупликацией</li><li><code>use cache</code> (Next.js 16)</li></ul>
<p>То есть даже без единого <code>fetch</code> в своём коде — Next.js всё равно использует его под капотом.</p>
<h2>Почему Vercel не фиксит</h2>
<h3>Бизнес-логика</h3>
<p>Vercel — компания, которая зарабатывает на хостинге Next.js. На их платформе проблема не проявляется (serverless = ephemeral). Баг затрагивает только self-hosted (Docker, K8s, VPS) — тех, кто не платит Vercel.</p>
<h3>Официальная позиция</h3>
<p>Tim Neutkens (мейнтейнер Vercel) провёл анализ и заявил что это проблема <strong>undici</strong> (Node.js fetch библиотека), а не Next.js. Issue #90433 закрыли. При том что:</p>
<ul><li>axios и node-fetch на том же Node.js работают без утечек</li><li>Утечка появляется только когда fetch проходит через Next.js обёртку</li><li>Баг открыт 2 года без фикса</li></ul>
<h3>Приоритеты</h3>
<p>За эти 2 года команда Next.js выпустила:</p>
<ul><li>Turbopack (2-5x быстрее билды) — маркетинговое преимущество</li><li>Cache Components / <code>use cache</code> — уменьшает нагрузку на серверы Vercel</li><li><code>proxy.ts</code> вместо middleware — упрощает edge-деплой на Vercel</li><li>DevTools MCP — AI-хайп</li></ul>
<p>Memory leak в self-hosted? Не в приоритетах.</p>
<h2>Решение: AWS Lambda (SST + OpenNext)</h2>
<h3>Что это</h3>
<p>OpenNext — open-source адаптер, который превращает Next.js build в формат для AWS Lambda. SST — фреймворк, автоматизирующий инфраструктуру.</p>
<h3>Архитектура</h3>
<pre><code>Next.js build
  → OpenNext
    → AWS Lambda (SSR, API routes)
    → S3 (static, assets)
    → CloudFront (CDN)
    → SQS + DynamoDB (ISR revalidation)</code></pre>
<h3>Почему это решает memory leak</h3>
<p>Lambda функция обрабатывает запросы и recycled через 5-15 минут неактивности. Память не успевает накопиться.</p>
<h3>Деплой</h3>
<pre><code>npx sst@latest init
npx sst deploy --stage production</code></pre>
<h3>Сравнение</h3>
<table><thead><tr><th></th><th>Vercel</th><th>AWS Lambda (SST)</th><th>Docker self-hosted</th></tr></thead><tbody><tr><td>Memory leak</td><td>Not felt</td><td>Not felt</td><td>Critical</td></tr><tr><td>Cold starts</td><td>Yes</td><td>Yes (~200-500ms)</td><td>No</td></tr><tr><td>Price (~medium traffic)</td><td>$20-150/mo</td><td>$5-30/mo</td><td>$5-50/mo</td></tr><tr><td>Control</td><td>Minimal</td><td>Full</td><td>Full</td></tr><tr><td>ISR/Revalidation</td><td>Works</td><td>Works (SQS)</td><td>Works (with leak)</td></tr><tr><td>Vendor lock-in</td><td>Vercel</td><td>AWS</td><td>None</td></tr></tbody></table>
<h3>Нюансы Lambda</h3>
<ul><li><strong>Cold starts</strong> — первый запрос медленнее (~200-500ms)</li><li><strong>Безопасность</strong> — включить OAC (Origin Access Control), иначе Lambda URL публичный</li><li><strong>OpenNext</strong> — community проект, не официальный Vercel. Новые фичи Next.js могут ломаться</li><li><strong>Wallet attack</strong> — при DDoS автоскейлинг Lambda может привести к большому счёту</li></ul>
<h2>Почему исправить нереально</h2>
<h3>1. Архитектурная проблема</h3>
<p>Утечка — не случайный баг, а следствие дизайн-решения: Next.js перехватывает глобальный <code>fetch</code> и добавляет кеш/трекинг поверх. Чтобы пофиксить — нужно переделать как App Router взаимодействует с fetch. Это затрагивает ISR, revalidation, data cache, request deduplication — ядро фреймворка.</p>
<h3>2. Конфликт интересов</h3>
<p>Vercel не мотивирован фиксить то, что не затрагивает их платформу. Self-hosted конкурирует с их бизнесом. Чем больше проблем в self-hosted — тем больше людей мигрируют на Vercel.</p>
<h3>3. Blame shifting</h3>
<p>Официальная позиция — "это undici, не мы". Пока она не изменится — над фиксом работать не будут.</p>
<h3>4. Нет community fix</h3>
<p>AGPL-3.0 лицензия Next.js позволяет форки, но кодовая база огромна и тесно связана с инфраструктурой Vercel. Community PR с фиксом fetch обёртки потребовал бы глубокого понимания внутренней архитектуры и одобрения мейнтейнеров — которые уже закрыли issue.</p>
<h2>Выводы</h2>
<ol><li><strong>Если на Vercel</strong> — проблемы нет, ничего делать не нужно</li><li><strong>Если self-hosted и нужен serverless</strong> — SST + OpenNext на AWS Lambda</li><li><strong>Если self-hosted Docker</strong> — заменить fetch на axios где возможно, мониторить RAM, настроить автоматический рестарт подов</li><li><strong>Если начинаешь новый проект</strong> — рассмотреть SvelteKit или Nuxt как альтернативу без этой проблемы</li></ol>
<h2>Источники</h2>
<ul><li><a href="https://github.com/vercel/next.js/issues/64212" target="_blank" rel="noopener noreferrer">Issue #64212</a> — Memory Leak with global fetch (April 2024)</li><li><a href="https://github.com/vercel/next.js/issues/68578" target="_blank" rel="noopener noreferrer">Issue #68578</a> — Possible memory leak in Fetch API (August 2024)</li><li><a href="https://github.com/vercel/next.js/issues/85914" target="_blank" rel="noopener noreferrer">Issue #85914</a> — Memory Leak with fetch + standalone (November 2025)</li><li><a href="https://github.com/vercel/next.js/issues/90433" target="_blank" rel="noopener noreferrer">Issue #90433</a> — OOM in 16.0.10 (February 2026)</li><li><a href="https://github.com/vercel/next.js/discussions/88603" target="_blank" rel="noopener noreferrer">Discussion #88603</a> — OOM in Docker/K8s (January 2026)</li><li><a href="https://github.com/vercel/next.js/discussions/88078" target="_blank" rel="noopener noreferrer">Discussion #88078</a> — cacheMaxMemorySize behavior</li><li><a href="https://opennext.js.org/" target="_blank" rel="noopener noreferrer">OpenNext</a></li><li><a href="https://sst.dev/docs/start/aws/nextjs/" target="_blank" rel="noopener noreferrer">SST — Next.js on AWS</a></li><li><a href="https://www.flightcontrol.dev/blog/secret-knowledge-to-self-host-nextjs" target="_blank" rel="noopener noreferrer">Secret knowledge to self-host Next.js</a></li><li><a href="https://nextjs.org/docs/app/guides/memory-usage" target="_blank" rel="noopener noreferrer">Next.js Memory Usage Guide</a></li></ul>]]></content:encoded>
      <pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate>
      <category>Next.js</category>
      <category>Memory Leak</category>
      <category>Docker</category>
      <category>AWS Lambda</category>
      <category>Node.js</category>
    </item>
  </channel>
</rss>