Files
ddadminandClaude Opus 5.5 e36a756bed refactor(proxy-client): шаблоны прокси для агентов копируются вручную
- Зачем: прокси для агента — выбор конкретной машины. Автоматическая
  правка ~/.codex/.env и ~/.claude/settings.json оказалась слишком
  навязчивой.
- Что: шаблоны перенесены в proxy-client/.config/proxy-client/examples/
  (codex.env, claude-settings.json) и попадают на машину вместе с
  остальными примерами. install.sh больше не трогает настройки Codex и
  Claude, его тесты на это удалены. Инструкция по копированию — в README
  proxy-client.
- Проверка: tests/test_install.sh и tests/test_proxy_client_install.sh
  проходят.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:31:35 +02:00

532 lines
22 KiB
Bash
Executable File
Raw Permalink 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
}
# Миграция: если $1 — stow-симлинк (пакет был свёрнут в репозиторий),
# раскрыть в настоящий каталог и перенести содержимое. Остальные аргументы —
# имена git-tracked файлов, которые остаются в репозитории.
migrate_folded_symlink() {
local link="$1"
shift
[ -L "$link" ] || return 0
info "Миграция: $link — симлинк, преобразую в директорию..."
local stow_dir
stow_dir="$(readlink -f "$link" 2>/dev/null || true)"
rm "$link"
mkdir -p "$link"
if [ -n "$stow_dir" ] && [ -d "$stow_dir" ]; then
for item in "$stow_dir"/* "$stow_dir"/.[!.]*; do
[ -e "$item" ] || [ -L "$item" ] || continue
local base skip s
base=$(basename "$item")
# git-tracked файлы остаются в репозитории
skip=""
for s in "$@"; do
[ "$base" = "$s" ] && skip=1
done
[ -n "$skip" ] && continue
mv "$item" "$link/" 2>/dev/null || true
done
info "Рантайм-данные перенесены в $link/"
else
warn "Не удалось прочитать источник старого симлинка $link, продолжаю без миграции данных"
fi
}
# Убрать с пути чужие файлы, которые конфликтуют со stow-пакетами.
#
# Программы создают свои конфиги сами (kitty.conf, .wezterm.lua), и при
# конфликте stow не просто пропускает пакет, а печатает "All operations
# aborted" и откатывает весь набор: install.sh падает по pipefail, не дойдя
# до настройки claude, opencode и .bashrc. Раньше это чинилось руками.
#
# Обход повторяет логику stow, а не перебирает файлы подряд: если каталога в
# HOME нет, конфликта быть не может и всё поддерево пропускается. Это заодно
# отсекает свёрнутые пакеты (~/.vscode-server) с их гигабайтами
# рантайм-данных.
backup_conflicting_stow_targets() {
local pkg=""
for pkg in "$@"; do
backup_conflicting_tree "$DOTFILES_DIR/$pkg" "$HOME" ""
done
}
# Рекурсивный обход одного уровня: src_dir — каталог пакета, dst_dir — его
# соответствие в HOME, rel — путь для сообщений.
backup_conflicting_tree() {
local src_dir="$1"
local dst_dir="$2"
local rel="$3"
local src=""
local dst=""
local base=""
local src_real=""
local dst_real=""
[ -d "$src_dir" ] || return 0
for src in "$src_dir"/* "$src_dir"/.[!.]*; do
[ -e "$src" ] || [ -L "$src" ] || continue
base="$(basename "$src")"
dst="$dst_dir/$base"
# Цели нет — stow создаст симлинк или свернёт каталог, конфликта нет.
[ -e "$dst" ] || [ -L "$dst" ] || continue
if [ -d "$src" ] && [ ! -L "$src" ]; then
# Каталог: симлинк в цели трогать нельзя — stow спускается внутрь,
# так работают и свёрнутые пакеты, и посторонний симлинк ~/.config.
[ ! -L "$dst" ] || continue
# Два настоящих каталога stow объединяет, конфликт может быть глубже.
if [ -d "$dst" ]; then
backup_conflicting_tree "$src" "$dst" "$rel/$base"
continue
fi
# Файл на месте каталога — stow каталог поверх него не создаст.
backup_existing_path "$dst" "~$rel/$base"
continue
fi
# Файл: симлинк на наш же файл — результат прошлого прогона.
if [ -L "$dst" ]; then
src_real="$(readlink -f "$src" 2>/dev/null || true)"
dst_real="$(readlink -f "$dst" 2>/dev/null || true)"
if [ -n "$src_real" ] && [ "$src_real" = "$dst_real" ]; then
continue
fi
fi
backup_existing_path "$dst" "~$rel/$base"
done
}
# Миграция: раскрыть старую stow-ссылку ~/.config -> zed/.config.
# В пакете zed остаётся только управляемый каталог zed; остальные настройки
# возвращаются в домашний каталог и больше не попадают в репозиторий.
migrate_zed_config_stow_symlink() {
local config_path="$HOME/.config"
local zed_config="$DOTFILES_DIR/zed/.config"
local config_real=""
local zed_config_real=""
local replacement=""
local items_file=""
local item=""
local base=""
local moved_items=()
[ -L "$config_path" ] || return 0
config_real="$(readlink -f "$config_path" 2>/dev/null || true)"
zed_config_real="$(readlink -f "$zed_config" 2>/dev/null || true)"
if [ -z "$zed_config_real" ] || [ "$config_real" != "$zed_config_real" ]; then
return 0
fi
info "Миграция: ~/.config больше не должен принадлежать пакету zed..."
replacement="$(mktemp -d "$HOME/.config.migrate.XXXXXX")"
chmod --reference="$zed_config_real" "$replacement"
items_file="$(mktemp "$HOME/.config.migrate-items.XXXXXX")"
if ! find "$zed_config_real" -mindepth 1 -maxdepth 1 ! -name zed -print0 > "$items_file"; then
error "Не удалось получить полный список старого ~/.config"
rm -f -- "$items_file"
rmdir "$replacement" 2>/dev/null || true
return 1
fi
while IFS= read -r -d '' item; do
base="${item##*/}"
if ! mv -- "$item" "$replacement/$base"; then
error "Не удалось перенести ~/.config/$base"
for base in "${moved_items[@]}"; do
mv -- "$replacement/$base" "$zed_config_real/$base" 2>/dev/null || true
done
rm -f -- "$items_file"
rmdir "$replacement" 2>/dev/null || true
return 1
fi
moved_items+=("$base")
done < "$items_file"
rm -f -- "$items_file"
if ! rm -- "$config_path"; then
error "Не удалось удалить старую ссылку ~/.config"
for base in "${moved_items[@]}"; do
mv -- "$replacement/$base" "$zed_config_real/$base" 2>/dev/null || true
done
rmdir "$replacement" 2>/dev/null || true
return 1
fi
if ! mv -- "$replacement" "$config_path"; then
error "Не удалось создать новый каталог ~/.config"
ln -s "$zed_config_real" "$config_path" 2>/dev/null || true
for base in "${moved_items[@]}"; do
mv -- "$replacement/$base" "$zed_config_real/$base" 2>/dev/null || true
done
rmdir "$replacement" 2>/dev/null || true
return 1
fi
info "Настройки перенесены из пакета zed в ~/.config"
}
# Настроить 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 wezterm kitty vscode agents gemini proxy-client chatgpt)
AVAILABLE_PACKAGES=()
info "Применяю stow пакеты..."
cd "$DOTFILES_DIR"
for pkg in "${PACKAGES[@]}"; do
if [ -d "$pkg" ]; then
AVAILABLE_PACKAGES+=("$pkg")
else
warn "Пакет $pkg не найден, пропускаю"
fi
done
migrate_zed_config_stow_symlink
if [ ! -L "$HOME/.local" ]; then
mkdir -p "$HOME/.local"
fi
if [ -d "$HOME/.local" ] && [ ! -L "$HOME/.local" ] \
&& [ ! -L "$HOME/.local/bin" ]; then
mkdir -p "$HOME/.local/bin"
fi
if [ -d "$HOME/.local" ] && [ ! -L "$HOME/.local" ] \
&& [ ! -L "$HOME/.local/share" ]; then
mkdir -p "$HOME/.local/share"
fi
if [ -d "$HOME/.local/share" ] && [ ! -L "$HOME/.local/share" ] \
&& [ ! -L "$HOME/.local/share/applications" ]; then
mkdir -p "$HOME/.local/share/applications"
fi
if [ ! -L "$HOME/.config" ]; then
mkdir -p "$HOME/.config"
fi
if [ -d "$HOME/.config" ] && [ ! -L "$HOME/.config" ] \
&& [ ! -L "$HOME/.config/proxy-client" ]; then
mkdir -p "$HOME/.config/proxy-client"
fi
# ~/.config/kitty создаётся заранее настоящим каталогом, чтобы stow не свернул
# его в один симлинк: kitty пишет туда свои файлы (kitty.conf.bak от
# `kitten themes`), и при свёрнутом каталоге они попадали бы в репозиторий.
if [ -d "$HOME/.config" ] && [ ! -L "$HOME/.config" ] \
&& [ ! -L "$HOME/.config/kitty" ]; then
mkdir -p "$HOME/.config/kitty"
fi
if [ "${#AVAILABLE_PACKAGES[@]}" -gt 0 ]; then
backup_conflicting_stow_targets "${AVAILABLE_PACKAGES[@]}"
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"
OPENCODE_REVIEWER_SRC="$OPENCODE_SRC_DIR/permission-reviewer.jsonc"
mkdir -p "$OPENCODE_DIR"
# Профили safe-build/safe-plan удалены: агенты встроенные (build/plan),
# решения об "ask" принимает permission-reviewer. Старый симлинк чистим.
if [ -L "$OPENCODE_DIR/agents" ]; then
case "$(readlink "$OPENCODE_DIR/agents" 2>/dev/null || true)" in
"$OPENCODE_SRC_DIR"*)
rm "$OPENCODE_DIR/agents"
info "Удалён устаревший симлинк ~/.config/opencode/agents"
;;
esac
fi
# Скиллы opencode сам берёт из ~/.agents/skills и ~/.claude/skills. Прежняя
# ссылка ~/.config/opencode/skills загружала тот же набор ещё раз, и opencode
# сыпал предупреждениями о дублях. Чистим её.
if [ -L "$OPENCODE_DIR/skills" ]; then
case "$(readlink "$OPENCODE_DIR/skills" 2>/dev/null || true)" in
"$SKILLS_SRC"*)
rm "$OPENCODE_DIR/skills"
info "Удалён лишний симлинк ~/.config/opencode/skills"
;;
esac
fi
if [ -f "$OPENCODE_JSON_SRC" ]; then
copy_managed_file "$OPENCODE_JSON_SRC" "$OPENCODE_DIR/opencode.json" "opencode.json"
fi
# Настройки permission-reviewer: плагин читает их только из глобального
# ~/.config/opencode, из репозитория они не подхватываются — копируем.
if [ -f "$OPENCODE_REVIEWER_SRC" ]; then
copy_managed_file "$OPENCODE_REVIEWER_SRC" "$OPENCODE_DIR/permission-reviewer.jsonc" "permission-reviewer.jsonc"
fi
# ── Claude Code ──────────────────────────────────────────────────────
# Из репозитория линкуются только skills. Настройки (settings.json,
# settings.local.json) принадлежат Claude Code и живут только на машине.
migrate_folded_symlink "$HOME/.claude" .gitignore skills
mkdir -p "$HOME/.claude"
# Уборка артефактов прежних версий установщика: битые симлинки на удалённые
# из репозитория файлы и забытая копия statusline (наша — по строке-маркеру).
for stale in CLAUDE.md settings.local.json; do
stale_path="$HOME/.claude/$stale"
if [ -L "$stale_path" ]; then
case "$(readlink "$stale_path" 2>/dev/null || true)" in
"$DOTFILES_DIR"/*)
rm "$stale_path"
info "Удалён устаревший симлинк ~/.claude/$stale"
;;
esac
fi
done
if [ -f "$HOME/.claude/statusline-command.sh" ] && [ ! -L "$HOME/.claude/statusline-command.sh" ] \
&& grep -q "Статусная строка Claude Code" "$HOME/.claude/statusline-command.sh" 2>/dev/null; then
rm "$HOME/.claude/statusline-command.sh"
info "Удалена устаревшая копия ~/.claude/statusline-command.sh"
fi
# Skills (симлинк)
if [ -d "$SKILLS_SRC" ]; then
ensure_symlink "$SKILLS_SRC" "$HOME/.claude/skills"
fi
# ── Codex ────────────────────────────────────────────────────────────
# Пакета в репозитории нет: ~/.codex — обычный каталог, его заполняет Codex.
# Миграция раскрывает старый свёрнутый симлинк и переносит рантайм-данные.
migrate_folded_symlink "$HOME/.codex" .gitkeep
# ── 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 "Готово!"