Files
dotfiles/install.sh
T
ddadminandClaude Opus 4.6 9341853479 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>
2026-03-31 10:28:41 +03:00

325 lines
13 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
# ── Переменные ────────────────────────────────────────────────────────
DOTFILES_DIR="$(cd "$(dirname "$0")" && pwd)"
STOW_BIN="${STOW_BIN:-stow}"
GIT_BIN="${GIT_BIN:-git}"
SUDO_BIN="${SUDO_BIN:-sudo}"
WSL_CONF_PATH="${WSL_CONF_PATH:-/etc/wsl.conf}"
INSTALL_INTERACTIVE_MODE="${INSTALL_INTERACTIVE_MODE:-auto}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# ── Утилиты ───────────────────────────────────────────────────────────
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error() { echo -e "${RED}[✗]${NC} $*"; }
# Интерактивный yes/no промпт (принимает английскую y и русскую у).
ask_yes_no() {
local answer=""
echo -ne "${YELLOW}[?]${NC} $1 [y/N] "
read -r answer || answer=""
[[ "$answer" =~ ^[YyУу]$ ]]
}
is_interactive() {
case "$INSTALL_INTERACTIVE_MODE" in
always) return 0 ;;
never) return 1 ;;
auto) [ -t 0 ] ;;
*)
warn "Неизвестное значение INSTALL_INTERACTIVE_MODE=$INSTALL_INTERACTIVE_MODE, использую auto"
[ -t 0 ]
;;
esac
}
# Вывести stow-лог с отступом.
stow_package() {
"$STOW_BIN" --target="$HOME" -v "$@" 2>&1 | sed "s/^/ /"
}
# Создать симлинк src → dst. Обрабатывает миграцию с директории на симлинк.
ensure_symlink() {
local src="$1"
local dst="$2"
if [ -L "$dst" ]; then
ln -sfnT "$src" "$dst"
return 0
fi
if [ -d "$dst" ]; then
# Миграция: директория с единственным симлинком skills → заменяем целиком
if [ -L "$dst/skills" ] && [ "$(find "$dst" -mindepth 1 -maxdepth 1 | wc -l)" -eq 1 ]; then
rm -rf "$dst"
ln -s "$src" "$dst"
return 0
fi
# Пустая директория → заменяем на симлинк
if [ -z "$(find "$dst" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
rmdir "$dst"
ln -s "$src" "$dst"
return 0
fi
warn "$dst уже существует как директория, пропускаю замену"
return 0
fi
if [ -e "$dst" ]; then
warn "$dst уже существует и не является симлинком, пропускаю замену"
return 0
fi
ln -s "$src" "$dst"
}
# Сохранить бэкап с временной меткой (и PID при коллизии).
backup_existing_path() {
local path="$1"
local label="$2"
local backup_path
backup_path="${path}.bak.$(date +%Y%m%d%H%M%S)"
if [ -e "$backup_path" ] || [ -L "$backup_path" ]; then
backup_path="${backup_path}.$$"
fi
mv "$path" "$backup_path"
warn "$label уже существовал, сохранил резервную копию: $backup_path"
}
# Копировать файл с бэкапом. Не трогает директории и спецфайлы.
copy_managed_file() {
local src="$1"
local dst="$2"
local label="$3"
local src_real=""
local dst_real=""
mkdir -p "$(dirname "$dst")"
src_real="$(readlink -f "$src" 2>/dev/null || printf '%s' "$src")"
if [ -L "$dst" ]; then
# Симлинк на тот же источник → просто удаляем (заменим копией)
dst_real="$(readlink -f "$dst" 2>/dev/null || true)"
if [ "$dst_real" = "$src_real" ]; then
rm "$dst"
else
backup_existing_path "$dst" "$label"
fi
elif [ -f "$dst" ]; then
# Контент совпадает → ничего не делаем
if cmp -s "$src" "$dst"; then
return 0
fi
backup_existing_path "$dst" "$label"
elif [ -d "$dst" ]; then
warn "$dst уже существует как директория, пропускаю замену"
return 0
elif [ -e "$dst" ]; then
warn "$dst уже существует и не является обычным файлом, пропускаю замену"
return 0
fi
cp -f "$src" "$dst"
}
# Парсит [boot] секцию wsl.conf, проверяет наличие command = ...
wsl_conf_has_boot_command() {
awk '
/^\[boot\]$/ { in_boot=1; next }
/^\[/ { in_boot=0 }
in_boot && /^[[:space:]]*command[[:space:]]*=/ { found=1 }
END { exit !found }
' "$1" 2>/dev/null
}
# Миграция: если ~/.claude — stow-симлинк, раскрыть в директорию.
migrate_claude_stow_symlink() {
[ -L "$HOME/.claude" ] || return 0
info "Миграция: ~/.claude — симлинк, преобразую в директорию..."
local stow_dir
stow_dir="$(readlink -f "$HOME/.claude" 2>/dev/null || true)"
rm "$HOME/.claude"
mkdir -p "$HOME/.claude"
if [ -n "$stow_dir" ] && [ -d "$stow_dir" ]; then
for item in "$stow_dir"/* "$stow_dir"/.[!.]*; do
[ -e "$item" ] || continue
local base
base=$(basename "$item")
# git-tracked файлы остаются в репозитории
case "$base" in .gitignore|settings.local.json|skills) continue ;; esac
mv "$item" "$HOME/.claude/" 2>/dev/null || true
done
info "Рантайм-данные перенесены в ~/.claude/"
else
warn "Не удалось прочитать источник старого симлинка ~/.claude, продолжаю без миграции данных"
fi
}
# Настроить boot command для hproxy.local в /etc/wsl.conf.
setup_wsl_hproxy() {
[ -n "${WSL_DISTRO_NAME:-}" ] || return 0
local script="$DOTFILES_DIR/scripts/setup-hproxy.sh"
local boot_cmd="command = \"$script\""
local legacy_cmd="command = \"ip route show default | awk '{print \$3 \\\" hproxy.local\\\"}' >> /etc/hosts\""
# Уже прописан наш скрипт
if grep -Fq "$boot_cmd" "$WSL_CONF_PATH" 2>/dev/null; then
warn "/etc/wsl.conf уже содержит boot command для setup-hproxy.sh, пропускаю"
return 0
fi
# Legacy inline command → предложить замену
if grep -Fq "$legacy_cmd" "$WSL_CONF_PATH" 2>/dev/null; then
warn "/etc/wsl.conf содержит legacy boot command для hproxy.local"
info "Рекомендуется заменить на: $boot_cmd"
if is_interactive && ask_yes_no "Заменить legacy command на setup-hproxy.sh?"; then
"$SUDO_BIN" sed -i '/ip route.*hproxy\.local/d' "$WSL_CONF_PATH"
"$SUDO_BIN" sed -i "/^\[boot\]/a command = \"$script\"" "$WSL_CONF_PATH"
info "Заменено. Перезапустите WSL: wsl --shutdown"
fi
return 0
fi
# Есть какой-то другой boot command
if wsl_conf_has_boot_command "$WSL_CONF_PATH"; then
warn "/etc/wsl.conf уже содержит [boot] command. Добавьте setup-hproxy.sh вручную"
info "Нужная строка: $boot_cmd"
return 0
fi
# Не интерактивный режим → только подсказка
if ! is_interactive; then
warn "WSL обнаружен, но stdin не интерактивный. Пропускаю настройку /etc/wsl.conf"
info "Добавьте вручную: $boot_cmd"
return 0
fi
# Интерактивный режим → спросить
echo ""
info "Обнаружен WSL. Скрипт $script может автоматически"
info "прописывать hproxy.local в /etc/hosts при каждом старте WSL (NAT mode)."
if ask_yes_no "Добавить boot command в /etc/wsl.conf?"; then
if ! grep -q '^\[boot\]' "$WSL_CONF_PATH" 2>/dev/null; then
printf '\n[boot]\n' | "$SUDO_BIN" tee -a "$WSL_CONF_PATH" > /dev/null
fi
"$SUDO_BIN" sed -i "/^\[boot\]/a command = \"$script\"" "$WSL_CONF_PATH"
info "Добавлено в /etc/wsl.conf. Перезапустите WSL: wsl --shutdown"
else
warn "Пропущено. Можно настроить вручную — см. README.md"
fi
}
# ── Stow пакеты ──────────────────────────────────────────────────────
if ! command -v "$STOW_BIN" &>/dev/null; then
error "stow не найден. Установите: sudo apt install stow"
exit 1
fi
PACKAGES=(bash tmux vim zed vscode agents codex gemini)
AVAILABLE_PACKAGES=()
info "Применяю stow пакеты..."
cd "$DOTFILES_DIR"
for pkg in "${PACKAGES[@]}"; do
if [ -d "$pkg" ]; then
AVAILABLE_PACKAGES+=("$pkg")
else
warn "Пакет $pkg не найден, пропускаю"
fi
done
if [ "${#AVAILABLE_PACKAGES[@]}" -gt 0 ]; then
stow_package "${AVAILABLE_PACKAGES[@]}"
fi
SKILLS_SRC="$DOTFILES_DIR/agents/.agents/skills"
# ── Opencode ─────────────────────────────────────────────────────────
OPENCODE_DIR="$HOME/.config/opencode"
OPENCODE_SRC_DIR="$DOTFILES_DIR/opencode/.config/opencode"
OPENCODE_JSON_SRC="$OPENCODE_SRC_DIR/opencode.json"
mkdir -p "$OPENCODE_DIR"
if [ -d "$OPENCODE_SRC_DIR/agents" ]; then
ensure_symlink "$OPENCODE_SRC_DIR/agents" "$OPENCODE_DIR/agents"
fi
if [ -d "$SKILLS_SRC" ]; then
ensure_symlink "$SKILLS_SRC" "$OPENCODE_DIR/skills"
fi
if [ -f "$OPENCODE_JSON_SRC" ]; then
copy_managed_file "$OPENCODE_JSON_SRC" "$OPENCODE_DIR/opencode.json" "opencode.json"
fi
# ── Claude Code ──────────────────────────────────────────────────────
# 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"
# Skills (симлинк)
if [ -d "$SKILLS_SRC" ]; then
ensure_symlink "$SKILLS_SRC" "$HOME/.claude/skills"
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
ln -s "$CLAUDE_LOCAL_SRC" "$CLAUDE_LOCAL_DST"
info "settings.local.json → репозиторий"
fi
fi
# ── Bash: .bashrc ────────────────────────────────────────────────────
BASHRC="$HOME/.bashrc"
BASHRC_LINE='[ -f ~/.bash_custom ] && source ~/.bash_custom'
if grep -q 'bash_custom' "$BASHRC" 2>/dev/null; then
warn "~/.bashrc уже содержит bash_custom, пропускаю"
else
echo "" >> "$BASHRC"
echo "# dotfiles" >> "$BASHRC"
echo "$BASHRC_LINE" >> "$BASHRC"
info "Добавлено в ~/.bashrc: $BASHRC_LINE"
fi
# ── WSL: hproxy.local ────────────────────────────────────────────────
setup_wsl_hproxy
info "Готово!"