refactor(claude): settings.json отдан Claude Code, базовые настройки в settings.local.json

- Зачем:
  - 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) <noreply@anthropic.com>
This commit is contained in:
ddadminandClaude Opus 4.6 committed 2026-03-31 10:28:41 +03:00
1 parent bdccd889fe
commit 9341853479
6 files changed
+117 -69

No files matched your search

+5 -5
View File
@@ -53,7 +53,7 @@ Chezmoi использует отдельный source dir `chezmoi/` внутр
| `zed` | Конфигурация Zed редактора | | `zed` | Конфигурация Zed редактора |
| `vscode` | `server-env-setup` для VS Code Server в WSL: секреты + авто-прокси | | `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) ### Windows (chezmoi)
@@ -70,7 +70,7 @@ Chezmoi использует отдельный source dir `chezmoi/` внутр
| Инструмент | Что настраивается | | Инструмент | Что настраивается |
|------------|-------------------| |------------|-------------------|
| **Agents** | Каноничное хранилище скиллов: `~/.agents/skills/<name>/SKILL.md` | | **Agents** | Каноничное хранилище скиллов: `~/.agents/skills/<name>/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 | | **[Opencode](https://github.com/anomalyco/opencode)** | MCP-серверы (context7, web-reader, playwright и др.), `~/.config/opencode/skills` → симлинк на agents |
| **Codex** | `~/.codex/skills/` → симлинки на agents | | **Codex** | `~/.codex/skills/` → симлинки на agents |
| **Gemini** | Политики для CLI: pre-approved readonly команды | | **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` и т.д.) | | `~/.gitconfig.local` | Персональные Git-настройки (`user.name`, `core.editor` и т.д.) |
| `~/.config/powershell/profile.extra.ps1` | Локальные дополнения к pwsh-профилю | | `~/.config/powershell/profile.extra.ps1` | Локальные дополнения к pwsh-профилю |
| `~/.config/powershell/profile.d/*.ps1` | Фрагменты, загружаемые по алфавиту | | `~/.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 не трогается?** **Почему Windows PowerShell 5.1 не трогается?**
Форсирование UTF-8 и изменение профиля PS 5.1 может сломать старые скрипты, `.bat`/`.cmd` и консольные утилиты. PS 7 — основной shell, PS 5.1 — режим совместимости. Форсирование UTF-8 и изменение профиля PS 5.1 может сломать старые скрипты, `.bat`/`.cmd` и консольные утилиты. PS 7 — основной shell, PS 5.1 — режим совместимости.
**Почему Claude `settings.json` копируется, а не симлинкается?** **Почему Claude `settings.local.json` — симлинк, а `settings.json` — нет?**
Claude Code перезаписывает `settings.json` при любом изменении настроек. Если файл — симлинк на tracked файл в git, это создаёт нежелательные diff-ы. Копия с `chmod 444` защищает от перезаписи; машинно-специфичные настройки уходят в `settings.local.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.
+13 -22
View File
@@ -28,7 +28,7 @@ echo "════════════════════════
for pkg_dir in "$DOTFILES_DIR"/*/; do for pkg_dir in "$DOTFILES_DIR"/*/; do
pkg=$(basename "$pkg_dir") pkg=$(basename "$pkg_dir")
[[ "$pkg" == .* ]] && continue [[ "$pkg" == .* ]] && continue
# claude управляется вручную (копирование settings.json), не через stow # claude управляется вручную (симлинк settings.local.json), не через stow
[[ "$pkg" == "claude" ]] && continue [[ "$pkg" == "claude" ]] && continue
# Точки входа — элементы первого уровня внутри пакета # Точки входа — элементы первого уровня внутри пакета
@@ -110,30 +110,21 @@ if [ -f "$install_script" ]; then
err "~/.claude — не найден" err "~/.claude — не найден"
fi fi
# Claude Code: settings.json должен быть копией, не симлинком # Claude Code: settings.local.json должен быть симлинком на репозиторий
if [ -L "$HOME/.claude/settings.json" ]; then if [ -L "$HOME/.claude/settings.local.json" ]; then
err "~/.claude/settings.json — симлинк (должна быть копия)" local_target=$(readlink -f "$HOME/.claude/settings.local.json" 2>/dev/null || true)
elif [ -f "$HOME/.claude/settings.json" ]; then local_expected=$(readlink -f "$DOTFILES_DIR/claude/.claude/settings.local.json" 2>/dev/null || true)
pass "~/.claude/settings.json — файл (копия)" if [ "$local_target" = "$local_expected" ]; then
pass "~/.claude/settings.local.json → репозиторий"
else else
err "~/.claude/settings.json — не найден" err "~/.claude/settings.local.json → $local_target (ожидался $local_expected)"
fi fi
elif [ -f "$HOME/.claude/settings.local.json" ]; then
# chmod 444 на settings.json в репозитории err "~/.claude/settings.local.json — обычный файл (должен быть симлинк). Запустите install.sh"
if [ -f "$DOTFILES_DIR/claude/.claude/settings.json" ]; then elif [ -e "$HOME/.claude/settings.local.json" ]; then
perms=$(stat -c %a "$DOTFILES_DIR/claude/.claude/settings.json" 2>/dev/null || true) err "~/.claude/settings.local.json — не файл и не симлинк. Удалите вручную и запустите install.sh"
if [ "$perms" = "444" ]; then
pass "claude/settings.json — chmod 444"
else else
skip "claude/settings.json — chmod $perms (ожидался 444)" err "~/.claude/settings.local.json — не найден (запустите install.sh)"
fi
fi
# settings.local.json
if [ -f "$HOME/.claude/settings.local.json" ]; then
pass "~/.claude/settings.local.json существует"
else
skip "~/.claude/settings.local.json — не найден (создайте через install.sh)"
fi fi
# skills симлинк # skills симлинк
+1 -1
View File
@@ -2,4 +2,4 @@
* *
!skills !skills
!.gitignore !.gitignore
!settings.json !settings.local.json
@@ -69,6 +69,12 @@
"Bash(docker version*)", "Bash(docker version*)",
"Bash(docker compose ps*)", "Bash(docker compose ps*)",
"Bash(docker compose logs*)", "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(curl *)",
"Bash(wget *)", "Bash(wget *)",
"Bash(mkdir *)", "Bash(mkdir *)",
@@ -92,7 +98,15 @@
"Bash(pytest *)", "Bash(pytest *)",
"Bash(node *)", "Bash(node *)",
"Bash(deno *)", "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": { "statusLine": {
+21 -30
View File
@@ -160,7 +160,7 @@ migrate_claude_stow_symlink() {
local base local base
base=$(basename "$item") base=$(basename "$item")
# git-tracked файлы остаются в репозитории # 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 mv "$item" "$HOME/.claude/" 2>/dev/null || true
done done
info "Рантайм-данные перенесены в ~/.claude/" info "Рантайм-данные перенесены в ~/.claude/"
@@ -271,8 +271,10 @@ if [ -f "$OPENCODE_JSON_SRC" ]; then
fi fi
# ── Claude Code ────────────────────────────────────────────────────── # ── Claude Code ──────────────────────────────────────────────────────
# settings.json КОПИРУЕТСЯ (не линкуется), чтобы Claude Code не модифицировал # settings.local.json ЛИНКУЕТСЯ из репозитория (базовые permissions + statusLine).
# файл в репозитории. Локальные настройки — в settings.local.json. # Claude Code не модифицирует settings.local.json — только читает и мержит
# с settings.json, который Claude Code создаёт и обновляет самостоятельно.
# Машинно-специфичные настройки (model, effortLevel) — в settings.json.
migrate_claude_stow_symlink migrate_claude_stow_symlink
mkdir -p "$HOME/.claude" mkdir -p "$HOME/.claude"
@@ -282,35 +284,24 @@ if [ -d "$SKILLS_SRC" ]; then
ensure_symlink "$SKILLS_SRC" "$HOME/.claude/skills" ensure_symlink "$SKILLS_SRC" "$HOME/.claude/skills"
fi fi
# settings.json — копия из репозитория (не симлинк!) # settings.local.json — базовые настройки из репозитория (симлинк)
CLAUDE_SETTINGS_SRC="$DOTFILES_DIR/claude/.claude/settings.json" CLAUDE_LOCAL_SRC="$DOTFILES_DIR/claude/.claude/settings.local.json"
CLAUDE_SETTINGS_DST="$HOME/.claude/settings.json" CLAUDE_LOCAL_DST="$HOME/.claude/settings.local.json"
if [ -f "$CLAUDE_SETTINGS_SRC" ]; then if [ -f "$CLAUDE_LOCAL_SRC" ]; then
chmod 444 "$CLAUDE_SETTINGS_SRC" if [ -L "$CLAUDE_LOCAL_DST" ]; then
if "$GIT_BIN" -C "$DOTFILES_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then ln -sfnT "$CLAUDE_LOCAL_SRC" "$CLAUDE_LOCAL_DST"
if ! "$GIT_BIN" -C "$DOTFILES_DIR" show "HEAD:claude/.claude/settings.json" > "$CLAUDE_SETTINGS_DST"; then info "settings.local.json → репозиторий"
warn "Не удалось прочитать committed settings.json, копирую текущий файл из репозитория" elif [ -f "$CLAUDE_LOCAL_DST" ]; then
cp -f "$CLAUDE_SETTINGS_SRC" "$CLAUDE_SETTINGS_DST" backup_existing_path "$CLAUDE_LOCAL_DST" "settings.local.json"
fi 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 else
cp -f "$CLAUDE_SETTINGS_SRC" "$CLAUDE_SETTINGS_DST" ln -s "$CLAUDE_LOCAL_SRC" "$CLAUDE_LOCAL_DST"
info "settings.local.json → репозиторий"
fi 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 fi
# ── Bash: .bashrc ──────────────────────────────────────────────────── # ── Bash: .bashrc ────────────────────────────────────────────────────
+62 -10
View File
@@ -101,23 +101,72 @@ test_fresh_install_uses_home_target() {
assert_not_exists "$fixture/.bash_custom" 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 local fixture repo_root home_dir
fixture="$(setup_fixture)" fixture="$(setup_fixture)"
repo_root="$fixture/repo" repo_root="$fixture/repo"
home_dir="$fixture/home" home_dir="$fixture/home"
mkdir -p "$home_dir" 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
FAKE_GIT_SHOW_CONTENT='{"source":"head"}' \
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
assert_file_contains "$home_dir/.claude/settings.json" '"source":"head"' [ -L "$home_dir/.claude/settings.local.json" ] || fail "Expected settings.local.json to be a symlink"
assert_file_contains "$repo_root/claude/.claude/settings.json" '"source":"working-tree"' 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"
}
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() { 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" command -v stow >/dev/null 2>&1 || fail "stow is required for tests"
test_fresh_install_uses_home_target 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_rerun_does_not_duplicate_bashrc_line
test_repairs_nested_skills_directory_bug test_repairs_nested_skills_directory_bug
test_preserves_nonempty_skills_directory test_preserves_nonempty_skills_directory