#!/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} $*"; } stow_package() { "$STOW_BIN" --target="$HOME" -v "$@" 2>&1 | sed "s/^/ /" } ensure_symlink() { local src="$1" local dst="$2" if [ -L "$dst" ]; then ln -sfnT "$src" "$dst" return 0 fi if [ -d "$dst" ]; then 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" } 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" } 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 --- if ! command -v "$STOW_BIN" &>/dev/null; then error "stow не найден. Установите: sudo apt install stow" exit 1 fi # --- Stow пакеты --- 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: установка без stow --- 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: установка без stow --- # settings.json КОПИРУЕТСЯ (не линкуется), чтобы Claude Code не модифицировал # файл в репозитории. Локальные настройки — в settings.local.json. # Миграция: если ~/.claude — stow-симлинк, раскрываем в реальную директорию if [ -L "$HOME/.claude" ]; then info "Миграция: ~/.claude — симлинк, преобразую в директорию..." CLAUDE_STOW_DIR="$(readlink -f "$HOME/.claude" 2>/dev/null || true)" rm "$HOME/.claude" mkdir -p "$HOME/.claude" if [ -n "$CLAUDE_STOW_DIR" ] && [ -d "$CLAUDE_STOW_DIR" ]; then # Переносим рантайм-данные из stow-директории for item in "$CLAUDE_STOW_DIR"/* "$CLAUDE_STOW_DIR"/.[!.]*; do [ -e "$item" ] || continue base=$(basename "$item") # Пропускаем git-tracked файлы — они останутся в репозитории case "$base" in .gitignore|settings.json|skills) continue ;; esac mv "$item" "$HOME/.claude/" 2>/dev/null || true done info "Рантайм-данные перенесены в ~/.claude/" else warn "Не удалось прочитать источник старого симлинка ~/.claude, продолжаю без миграции данных" fi fi mkdir -p "$HOME/.claude" # Skills (симлинк) 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" # Если доступен git, копируем committed-версию без изменений рабочей директории. 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 else cp -f "$CLAUDE_SETTINGS_SRC" "$CLAUDE_SETTINGS_DST" 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 --- 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 (автоматический резолв при старте) --- if [ -n "${WSL_DISTRO_NAME:-}" ]; then HPROXY_SCRIPT="$DOTFILES_DIR/scripts/setup-hproxy.sh" WSL_BOOT_COMMAND="command = \"$HPROXY_SCRIPT\"" if grep -Fq "$WSL_BOOT_COMMAND" "$WSL_CONF_PATH" 2>/dev/null; then warn "/etc/wsl.conf уже содержит boot command для setup-hproxy.sh, пропускаю" elif ! is_interactive; then warn "WSL обнаружен, но stdin не интерактивный. Пропускаю настройку /etc/wsl.conf" info "Добавьте вручную: $WSL_BOOT_COMMAND" else if awk ' /^\[boot\]$/ { in_boot=1; next } /^\[/ { in_boot=0 } in_boot && /^[[:space:]]*command[[:space:]]*=/ { found=1 } END { exit !found } ' "$WSL_CONF_PATH" 2>/dev/null; then warn "/etc/wsl.conf уже содержит [boot] command. Добавьте setup-hproxy.sh вручную" info "Нужная строка: $WSL_BOOT_COMMAND" else echo "" info "Обнаружен WSL. Скрипт $HPROXY_SCRIPT может автоматически" info "прописывать hproxy.local в /etc/hosts при каждом старте WSL (NAT mode)." echo -ne "${YELLOW}[?]${NC} Добавить boot command в /etc/wsl.conf? [y/N] " answer="" if ! read -r answer; then answer="" fi if [[ "$answer" =~ ^[Yy]$ ]]; then # Добавляем [boot] секцию, если её нет 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 = \"$HPROXY_SCRIPT\"" "$WSL_CONF_PATH" info "Добавлено в /etc/wsl.conf. Перезапустите WSL: wsl --shutdown" else warn "Пропущено. Можно настроить вручную — см. README.md" fi fi fi fi info "Готово!"