From 9341853479321f4b46f83de7953f1fbfbee340e2 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 31 Mar 2026 10:28:41 +0300 Subject: [PATCH] =?UTF-8?q?refactor(claude):=20settings.json=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B4=D0=B0=D0=BD=20Claude=20Code,=20=D0=B1=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B8=20=D0=B2=20settings.local.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - Claude Code перезаписывает settings.json при каждом «always allow», что создавало конфликт с managed-копией из dotfiles при повторном запуске install.sh. - Что: - settings.json удалён из репозитория, Claude Code владеет им полностью. - создан settings.local.json (базовые permissions + statusLine), деплоится через симлинк. - install.sh: копирование + шаблон заменены на ln -sf с бэкапом и guard для edge cases. - check.sh: проверка обновлена под симлинк вместо «файл + chmod 444». - тесты: старый тест копирования заменён на 4 новых (симлинк, бэкап, директория, stow-миграция). - Проверка: - bash tests/test_install.sh (15 тестов). - bash agents/.agents/skills/dotfiles-check/check.sh. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 10 +-- agents/.agents/skills/dotfiles-check/check.sh | 35 ++++----- claude/.claude/.gitignore | 2 +- .../{settings.json => settings.local.json} | 16 ++++- install.sh | 51 ++++++------- tests/test_install.sh | 72 ++++++++++++++++--- 6 files changed, 117 insertions(+), 69 deletions(-) rename claude/.claude/{settings.json => settings.local.json} (84%) diff --git a/README.md b/README.md index 3b18283..34c5db1 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Chezmoi использует отдельный source dir `chezmoi/` внутр | `zed` | Конфигурация Zed редактора | | `vscode` | `server-env-setup` для VS Code Server в WSL: секреты + авто-прокси | -`install.sh` дополнительно настраивает opencode (симлинки, MCP-конфиг), claude (копирует `settings.json` с `chmod 444`, создаёт `settings.local.json`) и подключает bash_custom. +`install.sh` дополнительно настраивает opencode (симлинки, MCP-конфиг), claude (симлинкает `settings.local.json` из репозитория) и подключает bash_custom. ### Windows (chezmoi) @@ -70,7 +70,7 @@ Chezmoi использует отдельный source dir `chezmoi/` внутр | Инструмент | Что настраивается | |------------|-------------------| | **Agents** | Каноничное хранилище скиллов: `~/.agents/skills//SKILL.md` | -| **Claude** | `~/.claude/skills` → симлинк на agents; `settings.json` (read-only) + `settings.local.json` (локальный) | +| **Claude** | `~/.claude/skills` → симлинк на agents; `settings.local.json` → симлинк на репозиторий (базовые permissions + statusLine); `settings.json` — управляется Claude Code | | **[Opencode](https://github.com/anomalyco/opencode)** | MCP-серверы (context7, web-reader, playwright и др.), `~/.config/opencode/skills` → симлинк на agents | | **Codex** | `~/.codex/skills/` → симлинки на agents | | **Gemini** | Политики для CLI: pre-approved readonly команды | @@ -198,7 +198,7 @@ echo '127.0.0.1 hproxy.local' | sudo tee -a /etc/hosts | `~/.gitconfig.local` | Персональные Git-настройки (`user.name`, `core.editor` и т.д.) | | `~/.config/powershell/profile.extra.ps1` | Локальные дополнения к pwsh-профилю | | `~/.config/powershell/profile.d/*.ps1` | Фрагменты, загружаемые по алфавиту | -| `~/.claude/settings.local.json` | Машинно-специфичные настройки Claude Code (`model`, `effortLevel`) | +| `~/.claude/settings.json` | Управляется Claude Code: `model`, `effortLevel`, «always allow» permissions | ## 💡 Дизайн-решения @@ -208,5 +208,5 @@ stow проще и покрывает весь Linux/WSL workflow. chezmoi ну **Почему Windows PowerShell 5.1 не трогается?** Форсирование UTF-8 и изменение профиля PS 5.1 может сломать старые скрипты, `.bat`/`.cmd` и консольные утилиты. PS 7 — основной shell, PS 5.1 — режим совместимости. -**Почему Claude `settings.json` копируется, а не симлинкается?** -Claude Code перезаписывает `settings.json` при любом изменении настроек. Если файл — симлинк на tracked файл в git, это создаёт нежелательные diff-ы. Копия с `chmod 444` защищает от перезаписи; машинно-специфичные настройки уходят в `settings.local.json`. +**Почему Claude `settings.local.json` — симлинк, а `settings.json` — нет?** +Claude Code перезаписывает `settings.json` при каждом «always allow» — этот файл принадлежит Claude Code. Базовые permissions и statusLine живут в `settings.local.json`, который Claude Code только читает и мержит с `settings.json`. Симлинк из `~/.claude/settings.local.json` на файл в репозитории безопасен. Машинно-специфичные настройки (`model`, `effortLevel`) — в `settings.json`, управляются через Claude Code. diff --git a/agents/.agents/skills/dotfiles-check/check.sh b/agents/.agents/skills/dotfiles-check/check.sh index 3131afd..24c21cb 100755 --- a/agents/.agents/skills/dotfiles-check/check.sh +++ b/agents/.agents/skills/dotfiles-check/check.sh @@ -28,7 +28,7 @@ echo "════════════════════════ for pkg_dir in "$DOTFILES_DIR"/*/; do pkg=$(basename "$pkg_dir") [[ "$pkg" == .* ]] && continue - # claude управляется вручную (копирование settings.json), не через stow + # claude управляется вручную (симлинк settings.local.json), не через stow [[ "$pkg" == "claude" ]] && continue # Точки входа — элементы первого уровня внутри пакета @@ -110,30 +110,21 @@ if [ -f "$install_script" ]; then err "~/.claude — не найден" fi - # Claude Code: settings.json должен быть копией, не симлинком - if [ -L "$HOME/.claude/settings.json" ]; then - err "~/.claude/settings.json — симлинк (должна быть копия)" - elif [ -f "$HOME/.claude/settings.json" ]; then - pass "~/.claude/settings.json — файл (копия)" - else - err "~/.claude/settings.json — не найден" - fi - - # chmod 444 на settings.json в репозитории - if [ -f "$DOTFILES_DIR/claude/.claude/settings.json" ]; then - perms=$(stat -c %a "$DOTFILES_DIR/claude/.claude/settings.json" 2>/dev/null || true) - if [ "$perms" = "444" ]; then - pass "claude/settings.json — chmod 444" + # Claude Code: settings.local.json должен быть симлинком на репозиторий + if [ -L "$HOME/.claude/settings.local.json" ]; then + local_target=$(readlink -f "$HOME/.claude/settings.local.json" 2>/dev/null || true) + local_expected=$(readlink -f "$DOTFILES_DIR/claude/.claude/settings.local.json" 2>/dev/null || true) + if [ "$local_target" = "$local_expected" ]; then + pass "~/.claude/settings.local.json → репозиторий" else - skip "claude/settings.json — chmod $perms (ожидался 444)" + err "~/.claude/settings.local.json → $local_target (ожидался $local_expected)" fi - fi - - # settings.local.json - if [ -f "$HOME/.claude/settings.local.json" ]; then - pass "~/.claude/settings.local.json существует" + elif [ -f "$HOME/.claude/settings.local.json" ]; then + err "~/.claude/settings.local.json — обычный файл (должен быть симлинк). Запустите install.sh" + elif [ -e "$HOME/.claude/settings.local.json" ]; then + err "~/.claude/settings.local.json — не файл и не симлинк. Удалите вручную и запустите install.sh" else - skip "~/.claude/settings.local.json — не найден (создайте через install.sh)" + err "~/.claude/settings.local.json — не найден (запустите install.sh)" fi # skills симлинк diff --git a/claude/.claude/.gitignore b/claude/.claude/.gitignore index e5985d3..fc0b753 100644 --- a/claude/.claude/.gitignore +++ b/claude/.claude/.gitignore @@ -2,4 +2,4 @@ * !skills !.gitignore -!settings.json +!settings.local.json diff --git a/claude/.claude/settings.json b/claude/.claude/settings.local.json similarity index 84% rename from claude/.claude/settings.json rename to claude/.claude/settings.local.json index c43de1d..3cbf0a7 100644 --- a/claude/.claude/settings.json +++ b/claude/.claude/settings.local.json @@ -69,6 +69,12 @@ "Bash(docker version*)", "Bash(docker compose ps*)", "Bash(docker compose logs*)", + "Bash(docker compose:*)", + "Bash(docker restart:*)", + "Bash(docker exec:*)", + "Bash(docker run:*)", + "Bash(docker stop:*)", + "Bash(docker rm:*)", "Bash(curl *)", "Bash(wget *)", "Bash(mkdir *)", @@ -92,7 +98,15 @@ "Bash(pytest *)", "Bash(node *)", "Bash(deno *)", - "Bash(stow *)" + "Bash(stow *)", + "Bash(gh api:*)", + "Bash(gh search:*)", + "Bash(gh issue:*)", + "Bash(gh repo:*)", + "Bash(codex *)", + "WebSearch", + "mcp__claude_ai_Context7__resolve-library-id", + "Write(/tmp/claude-plan-*)" ] }, "statusLine": { diff --git a/install.sh b/install.sh index 2df94d1..daf304a 100755 --- a/install.sh +++ b/install.sh @@ -160,7 +160,7 @@ migrate_claude_stow_symlink() { local base base=$(basename "$item") # git-tracked файлы остаются в репозитории - case "$base" in .gitignore|settings.json|skills) continue ;; esac + case "$base" in .gitignore|settings.local.json|skills) continue ;; esac mv "$item" "$HOME/.claude/" 2>/dev/null || true done info "Рантайм-данные перенесены в ~/.claude/" @@ -271,8 +271,10 @@ if [ -f "$OPENCODE_JSON_SRC" ]; then fi # ── Claude Code ────────────────────────────────────────────────────── -# settings.json КОПИРУЕТСЯ (не линкуется), чтобы Claude Code не модифицировал -# файл в репозитории. Локальные настройки — в settings.local.json. +# settings.local.json ЛИНКУЕТСЯ из репозитория (базовые permissions + statusLine). +# Claude Code не модифицирует settings.local.json — только читает и мержит +# с settings.json, который Claude Code создаёт и обновляет самостоятельно. +# Машинно-специфичные настройки (model, effortLevel) — в settings.json. migrate_claude_stow_symlink mkdir -p "$HOME/.claude" @@ -282,35 +284,24 @@ if [ -d "$SKILLS_SRC" ]; then ensure_symlink "$SKILLS_SRC" "$HOME/.claude/skills" fi -# settings.json — копия из репозитория (не симлинк!) -CLAUDE_SETTINGS_SRC="$DOTFILES_DIR/claude/.claude/settings.json" -CLAUDE_SETTINGS_DST="$HOME/.claude/settings.json" -if [ -f "$CLAUDE_SETTINGS_SRC" ]; then - chmod 444 "$CLAUDE_SETTINGS_SRC" - if "$GIT_BIN" -C "$DOTFILES_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - if ! "$GIT_BIN" -C "$DOTFILES_DIR" show "HEAD:claude/.claude/settings.json" > "$CLAUDE_SETTINGS_DST"; then - warn "Не удалось прочитать committed settings.json, копирую текущий файл из репозитория" - cp -f "$CLAUDE_SETTINGS_SRC" "$CLAUDE_SETTINGS_DST" - fi +# settings.local.json — базовые настройки из репозитория (симлинк) +CLAUDE_LOCAL_SRC="$DOTFILES_DIR/claude/.claude/settings.local.json" +CLAUDE_LOCAL_DST="$HOME/.claude/settings.local.json" +if [ -f "$CLAUDE_LOCAL_SRC" ]; then + if [ -L "$CLAUDE_LOCAL_DST" ]; then + ln -sfnT "$CLAUDE_LOCAL_SRC" "$CLAUDE_LOCAL_DST" + info "settings.local.json → репозиторий" + elif [ -f "$CLAUDE_LOCAL_DST" ]; then + backup_existing_path "$CLAUDE_LOCAL_DST" "settings.local.json" + ln -s "$CLAUDE_LOCAL_SRC" "$CLAUDE_LOCAL_DST" + info "settings.local.json → репозиторий" + info "Машинно-специфичные настройки (model, effortLevel) теперь задаются через Claude Code" + elif [ -d "$CLAUDE_LOCAL_DST" ] || [ -e "$CLAUDE_LOCAL_DST" ]; then + warn "$CLAUDE_LOCAL_DST уже существует (не файл и не симлинк), удалите вручную" else - cp -f "$CLAUDE_SETTINGS_SRC" "$CLAUDE_SETTINGS_DST" + ln -s "$CLAUDE_LOCAL_SRC" "$CLAUDE_LOCAL_DST" + info "settings.local.json → репозиторий" fi - chmod 644 "$CLAUDE_SETTINGS_DST" - info "settings.json скопирован (оригинал защищён chmod 444)" -fi - -# settings.local.json — локальные переопределения (не в git) -CLAUDE_LOCAL="$HOME/.claude/settings.local.json" -if [ ! -f "$CLAUDE_LOCAL" ]; then - info "Создаю $CLAUDE_LOCAL (локальные настройки Claude)..." - cat > "$CLAUDE_LOCAL" <<'EOF' -{ - "model": "opus", - "effortLevel": "high" -} -EOF -else - warn "$CLAUDE_LOCAL уже существует, пропускаю" fi # ── Bash: .bashrc ──────────────────────────────────────────────────── diff --git a/tests/test_install.sh b/tests/test_install.sh index b7971cd..dfedf89 100755 --- a/tests/test_install.sh +++ b/tests/test_install.sh @@ -101,23 +101,72 @@ test_fresh_install_uses_home_target() { assert_not_exists "$fixture/.bash_custom" } -test_dirty_repo_settings_copy_uses_committed_version() { +test_settings_local_is_symlinked_to_repo() { local fixture repo_root home_dir fixture="$(setup_fixture)" repo_root="$fixture/repo" home_dir="$fixture/home" mkdir -p "$home_dir" - chmod u+w "$repo_root/claude/.claude/settings.json" - cat > "$repo_root/claude/.claude/settings.json" <<'EOF' -{"source":"working-tree"} -EOF + run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out - FAKE_GIT_SHOW_CONTENT='{"source":"head"}' \ - run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out + [ -L "$home_dir/.claude/settings.local.json" ] || fail "Expected settings.local.json to be a symlink" + local target + target="$(readlink -f "$home_dir/.claude/settings.local.json")" + local expected + expected="$(readlink -f "$repo_root/claude/.claude/settings.local.json")" + [ "$target" = "$expected" ] || fail "settings.local.json points to $target, expected $expected" +} - assert_file_contains "$home_dir/.claude/settings.json" '"source":"head"' - assert_file_contains "$repo_root/claude/.claude/settings.json" '"source":"working-tree"' +test_settings_local_backup_on_existing_file() { + local fixture repo_root home_dir + fixture="$(setup_fixture)" + repo_root="$fixture/repo" + home_dir="$fixture/home" + mkdir -p "$home_dir/.claude" + echo '{"model":"sonnet"}' > "$home_dir/.claude/settings.local.json" + + run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out + + [ -L "$home_dir/.claude/settings.local.json" ] || fail "Expected settings.local.json to become a symlink" + local backup + backup="$(find "$home_dir/.claude" -maxdepth 1 -name 'settings.local.json.bak.*' -print -quit)" + [ -n "$backup" ] || fail "Expected backup for existing settings.local.json" + assert_file_contains "$backup" '"model":"sonnet"' +} + +test_settings_local_dir_is_skipped() { + local fixture repo_root home_dir + fixture="$(setup_fixture)" + repo_root="$fixture/repo" + home_dir="$fixture/home" + mkdir -p "$home_dir/.claude/settings.local.json" + + run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out + + [ -d "$home_dir/.claude/settings.local.json" ] || fail "Expected directory to be preserved (not replaced)" +} + +test_stow_migration_preserves_legacy_settings_local() { + local fixture repo_root home_dir stow_target + fixture="$(setup_fixture)" + repo_root="$fixture/repo" + home_dir="$fixture/home" + mkdir -p "$home_dir" + + # Simulate old stow layout: ~/.claude → repo dir + stow_target="$repo_root/claude/.claude" + ln -s "$stow_target" "$home_dir/.claude" + # Put a legacy settings.local.json in the stow target (would have been gitignored before) + echo '{"model":"opus"}' > "$stow_target/legacy-local.json" + + run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out + + [ -d "$home_dir/.claude" ] || fail "Expected ~/.claude to become a directory" + [ ! -L "$home_dir/.claude" ] || fail "Expected ~/.claude to stop being a symlink" + [ -L "$home_dir/.claude/settings.local.json" ] || fail "Expected settings.local.json symlink after migration" + # legacy-local.json should have been moved from stow dir to real ~/.claude + [ -f "$home_dir/.claude/legacy-local.json" ] || fail "Expected legacy file to be migrated" } test_rerun_does_not_duplicate_bashrc_line() { @@ -332,7 +381,10 @@ main() { command -v stow >/dev/null 2>&1 || fail "stow is required for tests" test_fresh_install_uses_home_target - test_dirty_repo_settings_copy_uses_committed_version + test_settings_local_is_symlinked_to_repo + test_settings_local_backup_on_existing_file + test_settings_local_dir_is_skipped + test_stow_migration_preserves_legacy_settings_local test_rerun_does_not_duplicate_bashrc_line test_repairs_nested_skills_directory_bug test_preserves_nonempty_skills_directory