#!/usr/bin/env bash # Copyright (c) 2026 Виталий Никитенко. MIT License. # ============================================================ # AI Setup - Claude / GPT-5.5 / Grok / Qwen / DeepSeek / Kimi / GLM / Gemini # Запуск: bash ai-setup.sh # ============================================================ CONFIG_DIR="$HOME/.config/ai-setup" # Автоопределение: ~/bin если есть в PATH, иначе ~/.local/bin if [[ ":$PATH:" == *":$HOME/bin:"* ]]; then BIN_DIR="$HOME/bin" else BIN_DIR="$HOME/.local/bin" fi NPM_GLOBAL="$HOME/.npm-global" PROXY_BIN="$BIN_DIR/claude-code-proxy" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" GLOBAL_RULES_SOURCE="$SCRIPT_DIR/home-configs/GLOBAL_RULES.md" # ── 0. Определение ОС и архитектуры (глобально) ───────────── # bash 3.2 на macOS: lowercasing через case, не через ${var,,} OS_KERNEL="$(uname -s)" # Linux | Darwin | ... OS_TAG="" ARCH_TAG="" case "$OS_KERNEL" in Linux) OS_TAG="linux" ;; Darwin) OS_TAG="darwin" ;; *) OS_TAG="" ;; esac case "$(uname -m)" in x86_64) ARCH_TAG="amd64" ;; arm64|aarch64) ARCH_TAG="arm64" ;; *) ARCH_TAG="" ;; esac # Портабельный sed -i: BSD (macOS) требует суффикс, GNU — нет if [ "$OS_KERNEL" = "Darwin" ]; then SED_INPLACE=(-i '') else SED_INPLACE=(-i) fi RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' info() { echo -e "${CYAN}[INFO]${NC} $*"; } success() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } err() { echo -e "${RED}[ERR]${NC} $*"; exit 1; } # Запрет запуска от root if [ "$EUID" -eq 0 ]; then echo -e "${RED}Не запускайте этот скрипт через sudo!${NC}" echo "Запустите просто: bash ai-setup.sh" exit 1 fi # ── Homebrew shellenv (macOS) ─────────────────────────────── # brew может быть установлен, но не в PATH текущего шелла (особенно # после свежей установки). Подключаем shellenv, чтобы node/npm/jq # стали видны через command -v в этой же сессии. if [ "$OS_KERNEL" = "Darwin" ]; then for _brew_prefix in /opt/homebrew /usr/local "$HOME/.homebrew"; do if [ -x "$_brew_prefix/bin/brew" ]; then eval "$("$_brew_prefix/bin/brew" shellenv)" break fi done unset _brew_prefix fi info "Проверяю зависимости (python3)..." if ! command -v python3 &>/dev/null; then err "Python 3 не установлен, но он требуется для работы скрипта." fi success "Python 3 найден" info "Проверяю зависимости (jq)..." if ! command -v jq &>/dev/null; then info "Устанавливаю jq..." if command -v brew &>/dev/null; then brew install jq elif command -v apt-get &>/dev/null; then sudo apt-get install -y jq elif command -v dnf &>/dev/null; then sudo dnf install -y jq else warn "Не удалось установить jq автоматически. Установите вручную." fi fi if command -v jq &>/dev/null; then success "jq найден" else warn "jq не найден. statusline-command.sh и некоторые функции могут не работать." fi # ── 1. Node.js ─────────────────────────────────────────────── info "Проверяю Node.js..." if ! command -v node &>/dev/null; then info "Пытаюсь установить Node.js..." if command -v brew &>/dev/null; then brew install node elif command -v apt-get &>/dev/null; then curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - sudo apt-get install -y nodejs elif command -v dnf &>/dev/null; then sudo dnf install -y nodejs else warn "Не удалось определить пакетный менеджер. Установите Homebrew (https://brew.sh) или Node.js вручную." fi fi if command -v node &>/dev/null; then success "Node.js $(node --version)" else warn "Node.js не найден. Некоторые функции могут не работать." fi # ── 2. npm prefix в домашнюю папку ────────────────────────── # После установки Node, чтобы npm гарантированно был доступен. info "Настраиваю npm prefix..." mkdir -p "$NPM_GLOBAL" if command -v npm &>/dev/null; then npm config set prefix "$NPM_GLOBAL" success "npm prefix -> $NPM_GLOBAL" else warn "npm не найден — пропускаю настройку prefix (установите Node.js)." fi export PATH="$NPM_GLOBAL/bin:$BIN_DIR:$PATH" # ── 3. Claude Code ─────────────────────────────────────────── info "Проверяю Claude Code..." if ! command -v claude &>/dev/null; then info "Устанавливаю Claude Code..." if curl -fsSL https://claude.ai/install.sh | bash 2>/dev/null; then success "Claude Code установлен (официальный инсталлер)" else npm install -g @anthropic-ai/claude-code success "Claude Code установлен (npm)" fi else success "Claude Code уже установлен: $(claude --version 2>/dev/null | head -1)" fi # ── 4. claude-code-proxy (GPT) ─────────────────────────────── mkdir -p "$BIN_DIR" install_proxy() { info "Устанавливаю claude-code-proxy..." if [ -z "$OS_TAG" ] || [ -z "$ARCH_TAG" ]; then err "Неизвестная платформа: kernel=$OS_KERNEL arch=$ARCH_TAG" fi LATEST=$(curl -fsSL "https://api.github.com/repos/raine/claude-code-proxy/releases/latest" \ | grep '"tag_name"' | sed 's/.*"tag_name": *"\(.*\)".*/\1/') [ -z "$LATEST" ] && err "Не удалось получить версию claude-code-proxy с GitHub" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT URL="https://github.com/raine/claude-code-proxy/releases/download/${LATEST}/claude-code-proxy-${OS_TAG}-${ARCH_TAG}.tar.gz" info "Скачиваю $URL" curl -fsSL "$URL" -o "$TMP/proxy.tar.gz" || err "Не удалось скачать claude-code-proxy" tar -xzf "$TMP/proxy.tar.gz" -C "$TMP" BINARY=$(find "$TMP" -name "claude-code-proxy" -type f | head -1) [ -z "$BINARY" ] && err "Бинарник не найден в архиве" cp "$BINARY" "$PROXY_BIN" chmod +x "$PROXY_BIN" success "claude-code-proxy $LATEST -> $PROXY_BIN" } if [ -f "$PROXY_BIN" ]; then CURRENT_VER=$("$PROXY_BIN" --version 2>/dev/null | head -1 || echo "unknown") success "claude-code-proxy уже установлен ($CURRENT_VER)" else install_proxy fi # ── 4b. effort-proxy wrapper (маппинг effort: xhigh→high) ───────── EFFORT_PROXY_BIN="$BIN_DIR/claude-gpt-effort-proxy.py" cat > "$EFFORT_PROXY_BIN" << 'PYEOF' #!/usr/bin/env python3 """Effort mapping proxy for GPT backend. claude-code-proxy now accepts: low, medium, high, max (no "xhigh"). Claude Code may send "xhigh" effort - we map it to "high". """ import http.client, http.server, sys, logging, socketserver UPSTREAM_PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 18766 LISTEN_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 18765 logging.basicConfig( filename="/tmp/claude-gpt-effort-proxy.log", level=logging.INFO, format='{"t":"%(asctime)s","level":"%(levelname)s","msg":"%(message)s"}', datefmt='%Y-%m-%dT%H:%M:%S', ) log = logging.getLogger("effort-proxy") # Потокобезопасный сервер - обрабатывает несколько запросов одновременно class _ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer): daemon_threads = True class _Proxy(http.server.BaseHTTPRequestHandler): def proxy_request(self): body = b"" if cl := self.headers.get("Content-Length"): body = self.rfile.read(int(cl)) # Маппинг xhigh→high: claude-code-proxy больше не принимает xhigh body = body.replace(b'"xhigh"', b'"high"') try: conn = http.client.HTTPConnection("127.0.0.1", UPSTREAM_PORT, timeout=300) hdrs = dict(self.headers) hdrs.pop("Host", None) hdrs["Content-Length"] = str(len(body)) conn.request(self.command, self.path, body=body or None, headers=hdrs) resp = conn.getresponse() self.send_response(resp.status, resp.reason) chunked = False for k, v in resp.getheaders(): if k.lower() == "transfer-encoding" and "chunked" in v.lower(): chunked = True self.send_header(k, v) self.end_headers() while chunk := resp.read(4096): try: if chunked: self.wfile.write(f"{len(chunk):X}\r\n".encode() + chunk + b"\r\n") else: self.wfile.write(chunk) self.wfile.flush() except (BrokenPipeError, ConnectionResetError): break if chunked: try: self.wfile.write(b"0\r\n\r\n") except: pass conn.close() except Exception as e: log.error("proxy error: %s", e) try: self.send_error(502, str(e)) except: pass do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = proxy_request def log_message(self, *args): pass log.info("effort-proxy starting on 127.0.0.1:%d → upstream %d", LISTEN_PORT, UPSTREAM_PORT) _ThreadedServer(("127.0.0.1", LISTEN_PORT), _Proxy).serve_forever() PYEOF chmod +x "$EFFORT_PROXY_BIN" success "claude-gpt-effort-proxy -> $EFFORT_PROXY_BIN" # ── 4c. antigravity CLI (Gemini) ────────────────────────── info "Проверяю antigravity CLI (agy)..." if ! command -v agy &>/dev/null; then info "Устанавливаю agy..." curl -fsSL https://antigravity.google/cli/install.sh | bash success "agy установлен" else success "agy уже установлен: $(agy --version 2>/dev/null | head -1)" fi # ── 4d. Google Android CLI ─────────────────────────────────── info "Проверяю Android CLI..." ANDROID_BIN="$HOME/.local/bin/android" [ ! -f "$ANDROID_BIN" ] && ANDROID_BIN="$(command -v android 2>/dev/null || true)" if [ -z "$ANDROID_BIN" ] || [ ! -f "$ANDROID_BIN" ]; then info "Устанавливаю Android CLI..." _uname_s=$(uname -s) _uname_m=$(uname -m) ANDROID_PLATFORM="" case "${_uname_s}-${_uname_m}" in Linux-x86_64) ANDROID_PLATFORM="linux_x86_64" ;; Linux-aarch64) ANDROID_PLATFORM="linux_arm64" ;; Linux-arm64) ANDROID_PLATFORM="linux_arm64" ;; Darwin-x86_64) ANDROID_PLATFORM="darwin_x86_64" ;; Darwin-arm64) ANDROID_PLATFORM="darwin_arm64" ;; Darwin-aarch64) ANDROID_PLATFORM="darwin_arm64" ;; MINGW*|MSYS*|CYGWIN*-x86_64) ANDROID_PLATFORM="windows_x86_64" ;; esac if [ -n "$ANDROID_PLATFORM" ]; then ANDROID_INSTALL_URL="https://dl.google.com/android/cli/latest/${ANDROID_PLATFORM}/install.sh" if curl -fsSL "$ANDROID_INSTALL_URL" | bash; then success "Android CLI установлен" else warn "Не удалось установить Android CLI через официальный скрипт" fi else warn "Неизвестная платформа ${_uname_s} ${_uname_m}. Пропускаю Android CLI." fi else success "Android CLI уже установлен: $($ANDROID_BIN --version 2>/dev/null | head -1)" fi # Официальный install.sh ставит бинарь в ~/.local/bin/android. # Если пользователь использует ~/bin вместо ~/.local/bin - symlink. if [ "$BIN_DIR" != "$HOME/.local/bin" ] && [ -f "$HOME/.local/bin/android" ] && [ ! -f "$BIN_DIR/android" ]; then ln -sf "$HOME/.local/bin/android" "$BIN_DIR/android" success "Android CLI symlink -> $BIN_DIR/android" fi # Разворачиваем Android CLI skills для агентов (Claude Code и др.) if command -v android &>/dev/null; then info "Запускаю android init для деплоя skills..." mkdir -p "$HOME/.claude/skills" android init || warn "android init завершился с ошибкой" else info "Android CLI не найден в PATH, пропускаю android init" fi # ── 5. routatic-proxy (OpenCode Go → Claude Code) ────────── info "Проверяю routatic-proxy..." ROUTATIC_BIN="$HOME/.local/bin/routatic-proxy" [ ! -f "$ROUTATIC_BIN" ] && ROUTATIC_BIN="$(command -v routatic-proxy 2>/dev/null || true)" if [ -z "$ROUTATIC_BIN" ] || [ ! -f "$ROUTATIC_BIN" ]; then info "Устанавливаю routatic-proxy..." _uname_s=$(uname -s) _uname_m=$(uname -m) ROUTATIC_PLATFORM="" case "${_uname_s}-${_uname_m}" in Linux-x86_64) ROUTATIC_PLATFORM="linux-amd64" ;; Linux-aarch64) ROUTATIC_PLATFORM="linux-arm64" ;; Linux-arm64) ROUTATIC_PLATFORM="linux-arm64" ;; Darwin-x86_64) ROUTATIC_PLATFORM="darwin-amd64" ;; Darwin-arm64) ROUTATIC_PLATFORM="darwin-arm64" ;; Darwin-aarch64) ROUTATIC_PLATFORM="darwin-arm64" ;; esac if [ -n "$ROUTATIC_PLATFORM" ]; then ROUTATIC_URL="https://github.com/routatic/proxy/releases/latest/download/routatic-proxy_${ROUTATIC_PLATFORM}" if curl -fsSL "$ROUTATIC_URL" -o "$HOME/.local/bin/routatic-proxy"; then chmod +x "$HOME/.local/bin/routatic-proxy" success "routatic-proxy установлен" else warn "Не удалось скачать routatic-proxy с GitHub" fi else warn "Неизвестная платформа ${_uname_s} ${_uname_m}. Пропускаю routatic-proxy." fi else success "routatic-proxy уже установлен" fi # Symlink в BIN_DIR если используется ~/bin вместо ~/.local/bin if [ "$BIN_DIR" != "$HOME/.local/bin" ] && [ -f "$HOME/.local/bin/routatic-proxy" ] && [ ! -f "$BIN_DIR/routatic-proxy" ]; then ln -sf "$HOME/.local/bin/routatic-proxy" "$BIN_DIR/routatic-proxy" success "routatic-proxy symlink -> $BIN_DIR/routatic-proxy" fi # Инициализируем конфиг routatic-proxy if command -v routatic-proxy &>/dev/null; then if [ ! -f "$HOME/.config/routatic-proxy/config.json" ]; then routatic-proxy init 2>/dev/null || true success "routatic-proxy config инициализирован" fi fi # ── 6. Папка для конфигов ──────────────────────────────────── mkdir -p "$CONFIG_DIR" # ── 6.5. Генерация глобальных правил агентов ───────────────── info "Обновляю глобальные правила агентов..." [ -f "$GLOBAL_RULES_SOURCE" ] || err "Файл глобальных правил не найден: $GLOBAL_RULES_SOURCE" cp "$GLOBAL_RULES_SOURCE" "$CONFIG_DIR/global_rules.md" success "Глобальные правила обновлены: $CONFIG_DIR/global_rules.md" info "Обновляю native rule-файлы агентов..." mkdir -p "$HOME/.codex" "$HOME/.kimi-code" "$HOME/.claude" "$HOME/.gemini" cp "$CONFIG_DIR/global_rules.md" "$HOME/.codex/AGENTS.md" cp "$CONFIG_DIR/global_rules.md" "$HOME/.kimi-code/AGENTS.md" cp "$CONFIG_DIR/global_rules.md" "$HOME/.claude/CLAUDE.md" cp "$CONFIG_DIR/global_rules.md" "$HOME/.gemini/GEMINI.md" cp "$CONFIG_DIR/global_rules.md" "$HOME/.cursorrules" success "Native rule-файлы обновлены" # ── 6.6. Деплой Claude и Gemini skills ─────────────────────── info "Обновляю skills для Claude и Gemini..." SKILLS_SRC="$SCRIPT_DIR/home-configs/claude/skills" CLAUDE_SKILLS_DST="$HOME/.claude/skills" GEMINI_SKILLS_DST="$HOME/.gemini/config/plugins/local-setup/skills" if [ -d "$SKILLS_SRC" ]; then mkdir -p "$CLAUDE_SKILLS_DST" "$GEMINI_SKILLS_DST" # Для Gemini нужен plugin.json, чтобы плагин со скилами загрузился GEMINI_PLUGIN_DIR="$HOME/.gemini/config/plugins/local-setup" cat < "$GEMINI_PLUGIN_DIR/plugin.json" { "name": "local-setup", "description": "Local custom skills deployed via ai-setup" } EOF for skill_dir in "$SKILLS_SRC"/*; do [ -d "$skill_dir" ] || continue skill_name=$(basename "$skill_dir") # Деплой для Claude mkdir -p "$CLAUDE_SKILLS_DST/$skill_name" cp -r "$skill_dir/"* "$CLAUDE_SKILLS_DST/$skill_name/" # Деплой для Gemini (agy) mkdir -p "$GEMINI_SKILLS_DST/$skill_name" cp -r "$skill_dir/"* "$GEMINI_SKILLS_DST/$skill_name/" done success "Skills обновлены для Claude и Gemini" else info "Папка со skills не найдена, пропускаю" fi # ── 6.7. Статусная строка Claude Code ─────────────────────── info "Настраиваю статусную строку Claude Code..." STATUSLINE_SRC="$SCRIPT_DIR/home-configs/claude/statusline-command.sh" STATUSLINE_DST="$HOME/.claude/statusline-command.sh" if [ -f "$STATUSLINE_SRC" ]; then cp "$STATUSLINE_SRC" "$STATUSLINE_DST" chmod +x "$STATUSLINE_DST" # Вписываем statusLine в settings.json через python3 SETTINGS="$HOME/.claude/settings.json" python3 - "$SETTINGS" "$STATUSLINE_DST" <<'PYEOF' import sys, json, os settings_path, script_path = sys.argv[1], sys.argv[2] data = {} if os.path.exists(settings_path): with open(settings_path) as f: try: data = json.load(f) except json.JSONDecodeError: pass data["statusLine"] = {"type": "command", "command": f"bash {script_path}"} # Дефолтный effort для ai-claude (не передаётся через CLI, берётся отсюда) data.setdefault("effortLevel", "xhigh") # SessionStart хук - триггерит вызов statusLine при старте сессии if "hooks" not in data: data["hooks"] = {} if "SessionStart" not in data["hooks"]: data["hooks"]["SessionStart"] = [{"hooks": [{"type": "command", "command": "true"}]}] with open(settings_path, "w") as f: json.dump(data, f, indent=2, ensure_ascii=False) f.write("\n") PYEOF success "Статусная строка настроена" else warn "Файл $STATUSLINE_SRC не найден, пропускаю" fi # ── 6.7.0. Чистка устаревшего хука effort-save ────────────── # effort/model теперь персистятся нативно в settings.json каждого CLAUDE_CONFIG_DIR # (полная изоляция лаунчеров), самопальный кэш ~/.cache/ai-setup/{effort,model}_* не нужен. # Удаляем старый хук с диска и из ~/.claude/settings.json (нотифаер в Stop сохраняем). info "Удаляю устаревший хук effort-save..." rm -f "$HOME/.claude/hooks/effort-save-hook.sh" # Осиротевший кэш моделей (источник старой протечки между ai-*); effort_* не трогаем - # они используются статусбаром для отображения текущего уровня. rm -f "$HOME"/.cache/ai-setup/model_* python3 - "$HOME/.claude/settings.json" <<'PYEOF' import sys, json, os settings_path = sys.argv[1] if not os.path.exists(settings_path): sys.exit(0) try: with open(settings_path) as f: data = json.load(f) except Exception: sys.exit(0) stop = data.get("hooks", {}).get("Stop") if isinstance(stop, list): for entry in stop: hooks = entry.get("hooks") if isinstance(hooks, list): entry["hooks"] = [h for h in hooks if "effort-save-hook" not in h.get("command", "")] data["hooks"]["Stop"] = [e for e in stop if e.get("hooks")] if not data["hooks"]["Stop"]: del data["hooks"]["Stop"] with open(settings_path, "w") as f: json.dump(data, f, indent=2, ensure_ascii=False) f.write("\n") PYEOF success "Старый хук effort-save удалён" # ── 6.7.05. Хелпер account-email (определение email по токену) ── # Вспомогательный скрипт для хуков switch-account/add-account. # Не регистрируется в settings.json — вызывается из хуков напрямую. EMAIL_HELPER_SRC="$SCRIPT_DIR/home-configs/claude/hooks/account-email.sh" EMAIL_HELPER_DST="$HOME/.claude/hooks/account-email.sh" mkdir -p "$HOME/.claude/hooks" if [ -f "$EMAIL_HELPER_SRC" ]; then cp "$EMAIL_HELPER_SRC" "$EMAIL_HELPER_DST" chmod +x "$EMAIL_HELPER_DST" fi # ── 6.7.1. Хук switch-account ─────────────────────────────────── info "Деплою хук switch-account..." SWITCH_HOOK_SRC="$SCRIPT_DIR/home-configs/claude/hooks/switch-account-hook.sh" SWITCH_HOOK_DST="$HOME/.claude/hooks/switch-account-hook.sh" mkdir -p "$HOME/.claude/hooks" if [ -f "$SWITCH_HOOK_SRC" ]; then cp "$SWITCH_HOOK_SRC" "$SWITCH_HOOK_DST" chmod +x "$SWITCH_HOOK_DST" # Прописываем хук в settings.json (идемпотентно) python3 - "$HOME/.claude/settings.json" "$SWITCH_HOOK_DST" <<'PYEOF' import sys, json, os settings_path, hook_path = sys.argv[1], sys.argv[2] data = {} if os.path.exists(settings_path): with open(settings_path) as f: try: data = json.load(f) except json.JSONDecodeError: pass data.setdefault("hooks", {}).setdefault("UserPromptSubmit", [{"hooks": []}]) hook_cmd = f'bash "{hook_path}"' ups = data["hooks"]["UserPromptSubmit"] already = any( any(h.get("command", "") == hook_cmd for h in entry.get("hooks", [])) for entry in ups ) if not already: ups[0]["hooks"].append({"type": "command", "command": hook_cmd}) with open(settings_path, "w") as f: json.dump(data, f, indent=2, ensure_ascii=False) f.write("\n") PYEOF success "Хук switch-account установлен" else warn "Файл $SWITCH_HOOK_SRC не найден, пропускаю" fi # ── 6.7.2. Хук add-account ────────────────────────────────────── info "Деплою хук add-account..." ADD_HOOK_SRC="$SCRIPT_DIR/home-configs/claude/hooks/add-account-hook.sh" ADD_HOOK_DST="$HOME/.claude/hooks/add-account-hook.sh" mkdir -p "$HOME/.claude/hooks" if [ -f "$ADD_HOOK_SRC" ]; then cp "$ADD_HOOK_SRC" "$ADD_HOOK_DST" chmod +x "$ADD_HOOK_DST" # Прописываем хук в settings.json (идемпотентно) python3 - "$HOME/.claude/settings.json" "$ADD_HOOK_DST" <<'PYEOF' import sys, json, os settings_path, hook_path = sys.argv[1], sys.argv[2] data = {} if os.path.exists(settings_path): with open(settings_path) as f: try: data = json.load(f) except json.JSONDecodeError: pass data.setdefault("hooks", {}).setdefault("UserPromptSubmit", [{"hooks": []}]) hook_cmd = f'bash "{hook_path}"' ups = data["hooks"]["UserPromptSubmit"] already = any( any(h.get("command", "") == hook_cmd for h in entry.get("hooks", [])) for entry in ups ) if not already: ups[0]["hooks"].append({"type": "command", "command": hook_cmd}) with open(settings_path, "w") as f: json.dump(data, f, indent=2, ensure_ascii=False) f.write("\n") PYEOF success "Хук add-account установлен" else warn "Файл $ADD_HOOK_SRC не найден, пропускаю" fi # ── 6.8. Регистрация официального маркетплейса плагинов Claude ── info "Настраиваю маркетплейс плагинов Claude Code..." if ! command -v claude &>/dev/null; then warn "claude не найден, пропускаю настройку маркетплейса" else existing=$(claude plugin marketplace list 2>/dev/null | grep "claude-plugins-official" || true) if [ -n "$existing" ]; then success "Маркетплейс claude-plugins-official уже добавлен" else # Берём токен только из env; в неинтерактивном режиме не спрашиваем if [ -z "$GITHUB_TOKEN" ]; then warn "GITHUB_TOKEN не задан, пропускаю настройку маркетплейса плагинов" warn "Позже запустите: claude plugin marketplace add https://TOKEN@github.com/anthropics/claude-plugins-official.git" else if claude plugin marketplace add "https://${GITHUB_TOKEN}@github.com/anthropics/claude-plugins-official.git" 2>&1; then success "Маркетплейс claude-plugins-official добавлен" else warn "Не удалось добавить маркетплейс, проверьте токен" fi fi fi fi # ── 6.9. Установка Claude Notifier ────────────────────────── info "Устанавливаю Claude Notifier..." if [ -f "$HOME/.claude/hooks/claude-notifier-on-stop.js" ]; then success "Claude Notifier уже установлен" else if curl -fsSL https://raw.githubusercontent.com/ashmitb95/claude-notifier/main/install.sh | bash; then success "Claude Notifier установлен" else warn "Не удалось установить Claude Notifier" fi fi # ── 7. Очистка старых функций из .bashrc / .zshrc ─────────── clean_rc() { local rc_file="$1" if [ -f "$rc_file" ] && grep -q "# === CLAUDE LAUNCHER ===" "$rc_file"; then info "Очищаю старые функции из $rc_file..." python3 - "$rc_file" <<'PYEOF' import sys path = sys.argv[1] with open(path, 'r') as f: content = f.read() marker = '# === CLAUDE LAUNCHER ===' end_marker = '# === END CLAUDE LAUNCHER ===' start = content.find(marker) end = content.find(end_marker) if start != -1 and end != -1: new_content = content[:start] + content[end + len(end_marker):].lstrip('\n') with open(path, 'w') as f: f.write(new_content) PYEOF success "Старые функции удалены из $rc_file" fi } clean_rc "$HOME/.bashrc" clean_rc "$HOME/.zshrc" add_path_to_rc() { local rc_file="$1" local bin_rel="${BIN_DIR#$HOME/}" if [ -f "$rc_file" ]; then if ! grep -q 'NPM_GLOBAL' "$rc_file" 2>/dev/null; then cat >> "$rc_file" << PATHEOF # Claude Code Launcher PATH export NPM_GLOBAL="\$HOME/.npm-global" export PATH="\$NPM_GLOBAL/bin:\$HOME/${bin_rel}:\$PATH" PATHEOF success "PATH добавлен в $rc_file" fi fi } add_path_to_rc "$HOME/.bashrc" # На macOS дефолтный шелл — zsh, но ~/.zshrc может не существовать. # Гарантируем наличие файла, иначе PATH не добавится и лаунчеры будут # недоступны без ручного `exec`. [ -f "$HOME/.zshrc" ] || touch "$HOME/.zshrc" add_path_to_rc "$HOME/.zshrc" # ── 8. Генерация Standalone скриптов ──────────────────────── info "Генерирую standalone скрипты в $BIN_DIR..." HELPERS_FILE="$BIN_DIR/ai-api-helpers.sh" cat > "$HELPERS_FILE" << 'HELPEREOF' #!/usr/bin/env bash export TZ="Europe/Helsinki" # _claude_test_api: Send 1-token test to an Anthropic-compatible endpoint _claude_test_api() { local url="$1" auth_header="$2" model="${3:-claude-sonnet-4-6}" local response response=$(curl -s -w "\n%{http_code}" --max-time 15 "$url" \ -H "$auth_header" \ -H "Content-Type: application/json" \ -H "anthropic-version: 2023-06-01" \ -d "{\"model\":\"$model\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" \ 2>/dev/null || echo "000") _CLAUDE_TEST_CODE=$(echo "$response" | tail -1) _CLAUDE_TEST_BODY=$(echo "$response" | sed '$d') } # _claude_test_openai_api: Send a tiny test to an OpenAI-compatible endpoint _claude_test_openai_api() { local url="$1" api_key="$2" model="${3:-kimi-k2.6}" local response response=$(curl -s -w "\n%{http_code}" --max-time 30 "$url" \ -H "Authorization: Bearer $api_key" \ -H "Content-Type: application/json" \ -d "{\"model\":\"$model\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" \ 2>/dev/null || echo "000") _CLAUDE_TEST_CODE=$(echo "$response" | tail -1) _CLAUDE_TEST_BODY=$(echo "$response" | sed '$d') } # _deepseek_balance: Query DeepSeek balance API and print info _deepseek_balance() { local api_key="$1" local response response=$(curl -s --max-time 10 "https://api.deepseek.com/user/balance" \ -H "Authorization: Bearer $api_key" \ -H "Accept: application/json" \ 2>/dev/null || echo "") if [ -z "$response" ]; then echo -e " \033[0;33m[БАЛАНС]\033[0m Не удалось получить баланс (сеть?)" return 1 fi echo "$response" | python3 -c " import sys, json, os try: d = json.load(sys.stdin) available = d.get('is_available', False) infos = d.get('balance_infos', []) if not infos: print(' \033[0;33m[БАЛАНС]\033[0m Нет данных о балансе') sys.exit(0) symbols = {'USD': '\$', 'CNY': '¥'} cache_parts = [] for info in infos: curr = info.get('currency', '???') total = info.get('total_balance', '0') granted = info.get('granted_balance', '0') topped_up = info.get('topped_up_balance', '0') sym = symbols.get(curr, curr) status = '✅ доступен' if available else '❌ не активен' print(f' \033[1;36m💰 Баланс DeepSeek:\033[0m {total} {curr} {status}') if float(granted) > 0: print(f' └─ Начислено: {granted} {curr}') if float(topped_up) > 0: print(f' └─ Пополнено: {topped_up} {curr}') cache_parts.append(f'{sym}{total}') # Cache all currencies with symbols for statusline if cache_parts: cache_dir = os.path.expanduser('~/.cache/ai-setup') os.makedirs(cache_dir, exist_ok=True) with open(os.path.join(cache_dir, 'deepseek_balance'), 'w') as f: f.write(' '.join(cache_parts) + '\n') except Exception as e: print(f' ⚠️ Не удалось разобрать баланс: {e}') " 2>/dev/null || echo -e " \033[0;33m[БАЛАНС]\033[0m Ошибка парсинга ответа" } # _openrouter_balance: Query OpenRouter credits API and print balance # Uses /api/v1/credits (works with regular API key too) _openrouter_balance() { local api_key="$1" local response response=$(curl -s --max-time 10 "https://openrouter.ai/api/v1/credits" \ -H "Authorization: Bearer $api_key" \ -H "Accept: application/json" \ 2>/dev/null || echo "") if [ -z "$response" ]; then echo -e " \033[0;33m[БАЛАНС]\033[0m Не удалось получить баланс (сеть?)" return 1 fi echo "$response" | python3 -c " import sys, json, os try: d = json.load(sys.stdin) data = d.get('data', {}) total = data.get('total_credits', 0) or 0 usage = data.get('total_usage', 0) or 0 remaining = total - usage cache_dir = os.path.expanduser('~/.cache/ai-setup') os.makedirs(cache_dir, exist_ok=True) cache_file = os.path.join(cache_dir, 'openrouter_balance') # Зелёный как у DeepSeek: \033[38;5;78m print(f' \033[1;36m💰 Баланс OpenRouter:\033[0m \033[38;5;78m\${remaining:.2f}\033[0m (загружено \${total:.2f}, потрачено \${usage:.2f})') with open(cache_file, 'w') as f: f.write(f'\${remaining:.2f}\n') except Exception as e: print(f' ⚠️ Не удалось разобрать баланс: {e}') " 2>/dev/null || echo -e " \033[0;33m[БАЛАНС]\033[0m Ошибка парсинга ответа" } _handle_openai_api_response() { local provider="$1" local code="$2" local body="$3" local topup_url="$4" # Используем глобальную переменную _API_RET вместо return, # потому что bash return умеет только 0-255, а HTTP-коды # вроде 401/429 обрезаются (401 % 256 = 145). _API_RET=0 local _emsg case "$code" in 200) echo -e "\033[0;32mOK\033[0m" _API_RET=0 ;; 401|403) _emsg=$(_claude_extract_error "$body") echo "" echo -e "\033[0;31m[ОШИБКА АВТОРИЗАЦИИ]\033[0m Авторизация $provider недействительна (HTTP $code)." [ -n "$_emsg" ] && echo " $_emsg" _API_RET=401 ;; 429) _emsg=$(_claude_extract_error "$body") echo "" echo -e "\033[0;33m[ЛИМИТ ИСЧЕРПАН]\033[0m Баланс/лимит $provider исчерпан." [ -n "$_emsg" ] && echo " $_emsg" [ -n "$topup_url" ] && echo " $topup_url" _API_RET=429 ;; 000) echo "" echo -e "\033[0;33m[СЕТЬ]\033[0m Не удалось проверить ключ (нет сети?). Продолжаю..." _API_RET=0 ;; 5[0-9][0-9]) echo "" echo -e "\033[0;33m[СЕРВЕР]\033[0m $provider временно недоступен (HTTP $code). Продолжаю..." _API_RET=0 ;; *) _emsg=$(_claude_extract_error "$body") echo "" echo -e "\033[0;31m[ОШИБКА]\033[0m API $provider вернул HTTP $code." [ -n "$_emsg" ] && echo " $_emsg" _API_RET=$code ;; esac return 0 } _claude_extract_error() { local body="$1" echo "$body" | python3 -c " import sys, json try: d = json.load(sys.stdin) e = d.get('error', {}) if isinstance(e, str): print(e) else: msg = e.get('message', '') if msg: print(msg) except: pass " 2>/dev/null } _claude_offer_reauth() { local provider="$1" echo "" echo -n "Хотите выполнить повторную авторизацию $provider? [Y/n] " local answer read -r answer case "${answer:-Y}" in [Yy]|[Yy][Ee][Ss]) return 0 ;; *) echo "Отменено."; return 1 ;; esac } _handle_api_response() { local provider="$1" local code="$2" local body="$3" local topup_url="$4" # Используем глобальную переменную _API_RET вместо return, # потому что bash return умеет только 0-255, а HTTP-коды # вроде 401/429 обрезаются (401 % 256 = 145). _API_RET=0 local _emsg case "$code" in 200) echo -e "\033[0;32mOK\033[0m" _API_RET=0 ;; 401|403) _emsg=$(_claude_extract_error "$body") echo "" echo -e "\033[0;31m[ОШИБКА АВТОРИЗАЦИИ]\033[0m Авторизация $provider недействительна (HTTP $code)." [ -n "$_emsg" ] && echo " $_emsg" _API_RET=401 ;; 429) _emsg=$(_claude_extract_error "$body") echo "" echo -e "\033[0;33m[ЛИМИТ ИСЧЕРПАН]\033[0m Баланс/лимит $provider исчерпан." [ -n "$_emsg" ] && echo " $_emsg" [ -n "$topup_url" ] && echo " $topup_url" _API_RET=429 ;; 000) echo "" echo -e "\033[0;33m[СЕТЬ]\033[0m Не удалось проверить ключ (нет сети?). Продолжаю..." _API_RET=0 ;; 5[0-9][0-9]) echo "" echo -e "\033[0;33m[СЕРВЕР]\033[0m $provider временно недоступен (HTTP $code). Продолжаю..." _API_RET=0 ;; 400) _emsg=$(_claude_extract_error "$body") if echo "${_emsg:-$body}" | grep -qi "RESOURCE_EXHAUSTED"; then echo "" echo -e "\033[0;33m[КВОТА ИСЧЕРПАНА]\033[0m Лимит запросов исчерпан." [ -n "$topup_url" ] && echo " $topup_url" _API_RET=429 else # 400 = auth is valid, but max_tokens=1 is too small for thinking models echo -e "\033[0;32mOK\033[0m" _API_RET=0 fi ;; *) _emsg=$(_claude_extract_error "$body") echo "" echo -e "\033[0;31m[ОШИБКА]\033[0m API $provider вернул HTTP $code." [ -n "$_emsg" ] && echo " $_emsg" _API_RET=$code ;; esac return 0 } # _kimi_account_info: пытается получить email или имя аккаунта Kimi по API ключу. # Сохраняет найденное значение в stdout. Если не удалось — exit 1. _kimi_account_info() { local api_key="$1" local resp email name # Пробуем несколько возможных endpoint'ов Moonshot/Kimi Code API. for url in "https://api.kimi.com/coding/v1/account" "https://api.kimi.com/coding/v1/users/me"; do resp=$(curl -s --max-time 10 "$url" \ -H "x-api-key: $api_key" \ -H "Accept: application/json" \ 2>/dev/null || echo "") [ -z "$resp" ] && continue email=$(echo "$resp" | jq -r '.email // .data.email // .account.email // empty' 2>/dev/null) name=$(echo "$resp" | jq -r '.name // .data.name // .account.name // empty' 2>/dev/null) if [ -n "$email" ]; then echo "$email" return 0 fi if [ -n "$name" ]; then echo "$name" return 0 fi done return 1 } _open_browser() { local url="$1" if command -v xdg-open &>/dev/null; then xdg-open "$url" 2>/dev/null elif command -v open &>/dev/null; then open "$url" 2>/dev/null elif command -v sensible-browser &>/dev/null; then sensible-browser "$url" 2>/dev/null else echo "Откройте вручную: $url"; fi } # _setup_isolated_config: готовит изолированную папку CLAUDE_CONFIG_DIR для лаунчера. # Каждый сторонний провайдер получает собственные settings.json и .claude.json, # поэтому выбор модели и кэш кастом-моделей НЕ протекают между ai-* лаунчерами. # Общие ресурсы (skills, CLAUDE.md) шарятся симлинком из ~/.claude. # model и effortLevel сидируются как дефолты - выбор юзера через /model и /effort # сохраняется нативно в этом же settings.json (включая max через AI_EFFORT). # Использование: _setup_isolated_config _setup_isolated_config() { local launcher="$1" default_model="$2" default_effort="${3:-high}" avail="${4:-}" local cfg="$HOME/.config/ai-setup/cfg/$launcher" mkdir -p "$cfg" # Общие ресурсы из ~/.claude (единый источник правды) ln -sfn "$HOME/.claude/skills" "$cfg/skills" ln -sfn "$HOME/.claude/CLAUDE.md" "$cfg/CLAUDE.md" [ -e "$HOME/.claude/agents" ] && ln -sfn "$HOME/.claude/agents" "$cfg/agents" # .claude.json в свежей папке: пропускаем онбординг [ -f "$cfg/.claude.json" ] || echo '{"hasCompletedOnboarding": true}' > "$cfg/.claude.json" python3 - "$cfg/settings.json" "$HOME/.claude/statusline-command.sh" \ "$default_model" "$default_effort" "$avail" <<'PYEOF' import sys, json, os cfg_settings, statusline, model, effort, avail = sys.argv[1:6] data = {} if os.path.exists(cfg_settings): try: with open(cfg_settings) as f: data = json.load(f) except Exception: pass # Структурные настройки лаунчера (переустанавливаем всегда) data["statusLine"] = {"type": "command", "command": f"bash {statusline}"} data["skipDangerousModePermissionPrompt"] = True data.setdefault("hooks", {})["SessionStart"] = [{"hooks": [{"type": "command", "command": "true"}]}] # availableModels - белый список пикера (политика лаунчера) if avail: data["availableModels"] = json.loads(avail) else: data.pop("availableModels", None) # model/effortLevel - сидируем дефолты, не перетирая выбор юзера (нативный persistence) if model: data.setdefault("model", model) data.setdefault("effortLevel", effort) with open(cfg_settings, "w") as f: json.dump(data, f, indent=2, ensure_ascii=False) f.write("\n") PYEOF # Деплой shared хуков аккаунтов в изолированный config dir. # ai-claude использует ~/.claude/hooks напрямую; остальные лаунчеры получают # свою копию, чтобы /switch-account и /add-account работали изолированно. mkdir -p "$cfg/hooks" for hook in switch-account-hook.sh add-account-hook.sh account-email.sh; do if [ -f "$HOME/.claude/hooks/$hook" ]; then cp "$HOME/.claude/hooks/$hook" "$cfg/hooks/$hook" chmod +x "$cfg/hooks/$hook" fi done # Регистрируем UserPromptSubmit хуки в изолированном settings.json python3 - "$cfg/settings.json" "$cfg/hooks/switch-account-hook.sh" "$cfg/hooks/add-account-hook.sh" <<'PYEOF' import sys, json, os settings_path, switch_hook, add_hook = sys.argv[1:4] data = {} if os.path.exists(settings_path): try: with open(settings_path) as f: data = json.load(f) except Exception: pass data.setdefault("hooks", {}).setdefault("UserPromptSubmit", [{"hooks": []}]) ups = data["hooks"]["UserPromptSubmit"] for hook_path in (switch_hook, add_hook): hook_cmd = f'bash "{hook_path}"' already = any( any(h.get("command", "") == hook_cmd for h in entry.get("hooks", [])) for entry in ups ) if not already: ups[0]["hooks"].append({"type": "command", "command": hook_cmd}) with open(settings_path, "w") as f: json.dump(data, f, indent=2, ensure_ascii=False) f.write("\n") PYEOF # Звук по завершении задачи: используем Claude Notifier из ~/.claude/hooks, # либо минимальный fallback, если Notifier не установился. if [ -f "$HOME/.claude/hooks/claude-notifier-on-stop.js" ]; then stop_hook_cmd="node \"$HOME/.claude/hooks/claude-notifier-on-stop.js\"" else cat > "$cfg/hooks/sound-on-stop.sh" <<'SOUNDEOF' #!/usr/bin/env bash # Minimal task-completion sound fallback ( afplay /System/Library/Sounds/Glass.aiff 2>/dev/null || paplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || pw-play --volume=1 /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || aplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null ) & SOUNDEOF chmod +x "$cfg/hooks/sound-on-stop.sh" stop_hook_cmd="bash \"$cfg/hooks/sound-on-stop.sh\"" fi python3 - "$cfg/settings.json" "$stop_hook_cmd" <<'PYEOF' import sys, json, os settings_path, hook_cmd = sys.argv[1:3] data = {} if os.path.exists(settings_path): try: with open(settings_path) as f: data = json.load(f) except Exception: pass data.setdefault("hooks", {}).setdefault("Stop", [{"hooks": []}]) stop_entries = data["hooks"]["Stop"] already = any( any(h.get("command", "") == hook_cmd for h in entry.get("hooks", [])) for entry in stop_entries ) if not already: stop_entries[0]["hooks"].append({"type": "command", "command": hook_cmd}) with open(settings_path, "w") as f: json.dump(data, f, indent=2, ensure_ascii=False) f.write("\n") PYEOF } # _apply_effort: per-launcher persistence уровня effort. # Все уровни (включая max) пишутся в settings.json лаунчера через AI_EFFORT. # Без AI_EFFORT - effortLevel персистнут нативно в settings.json (Claude Code его читает). # CLAUDE_CODE_EFFORT_LEVEL не используем - он блокирует /effort внутри сессии. # Использование: _apply_effort _apply_effort() { local launcher="$1" default_effort="${2:-high}" local settings="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json" if [ -n "${AI_EFFORT:-}" ]; then python3 - "$settings" "$AI_EFFORT" <<'PYEOF' import sys, json, os p, eff = sys.argv[1], sys.argv[2] d = {} if os.path.exists(p): try: d = json.load(open(p)) except Exception: pass d["effortLevel"] = eff os.makedirs(os.path.dirname(p), exist_ok=True) with open(p, "w") as fp: json.dump(d, fp, indent=2, ensure_ascii=False); fp.write("\n") PYEOF fi # Без AI_EFFORT: effortLevel уже персистнут в settings.json - ничего не делаем. } _build_ai_sys_prompt() { local global_rules="$HOME/.config/ai-setup/global_rules.md" local global_rendered="" [ -f "$global_rules" ] && global_rendered="$(cat "$global_rules" 2>/dev/null)" # Нативные глобальные правила: только global_rules.md, без проектного контекста. # Проектные *.md файлы агент должен читать из текущего репозитория сам, если умеет. # Для агентов без надежного нативного чтения проектный контекст добавляется ниже в prompt. # Cursor использует ~/.cursorrules как глобальные пользовательские правила. mkdir -p "$HOME/.codex" "$HOME/.kimi-code" "$HOME/.claude" "$HOME/.gemini" echo "$global_rendered" > "$HOME/.codex/AGENTS.md" echo "$global_rendered" > "$HOME/.kimi-code/AGENTS.md" echo "$global_rendered" > "$HOME/.claude/CLAUDE.md" echo "$global_rendered" > "$HOME/.gemini/GEMINI.md" echo "$global_rendered" > "$HOME/.cursorrules" local sp="=== ГЛОБАЛЬНЫЕ ПРАВИЛА ===\n" [ -n "$global_rendered" ] && sp+="$global_rendered\n\n" sp+="=== ПРАВИЛА ПРОЕКТА ===\n" for f in *.md; do [ -f "$f" ] && sp+="\n--- Файл $f ---\n$(cat "$f")\n" done echo -e "$sp" } _setup_codex_sound_hooks() { local hooks_dir="$HOME/.codex/hooks" local hooks_json="$HOME/.codex/hooks.json" mkdir -p "$hooks_dir" cat > "$hooks_dir/ai-setup-sound-on-stop.sh" <<'SOUNDEOF' #!/usr/bin/env bash # Minimal Codex turn-completion sound. ( afplay /System/Library/Sounds/Glass.aiff 2>/dev/null || paplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || pw-play --volume=1 /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || aplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null ) & SOUNDEOF cat > "$hooks_dir/ai-setup-sound-on-auto-compact.sh" <<'SOUNDEOF' #!/usr/bin/env bash # Minimal Codex context auto-compact warning sound. ( afplay /System/Library/Sounds/Basso.aiff 2>/dev/null || paplay /usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga 2>/dev/null || pw-play --volume=1 /usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga 2>/dev/null || aplay /usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga 2>/dev/null ) & SOUNDEOF chmod +x "$hooks_dir/ai-setup-sound-on-stop.sh" "$hooks_dir/ai-setup-sound-on-auto-compact.sh" python3 - "$hooks_json" <<'PYEOF' import json import os import sys path = sys.argv[1] data = {} if os.path.exists(path): try: with open(path) as f: data = json.load(f) except Exception: print(f"WARNING: {path} is not valid JSON; Codex sound hooks were not installed", file=sys.stderr) sys.exit(0) if not isinstance(data, dict): print(f"WARNING: {path} does not contain a JSON object; Codex sound hooks were not installed", file=sys.stderr) sys.exit(0) hooks = data.setdefault("hooks", {}) if not isinstance(hooks, dict): print(f"WARNING: {path}.hooks is not a JSON object; Codex sound hooks were not installed", file=sys.stderr) sys.exit(0) def ensure_hook(event, command, matcher=None): groups = hooks.setdefault(event, []) for group in groups: if matcher is not None and group.get("matcher") != matcher: continue if any(h.get("command") == command for h in group.get("hooks", [])): return target = None for group in groups: if matcher is None or group.get("matcher") == matcher: target = group break if target is None: target = {"hooks": []} if matcher is not None: target["matcher"] = matcher groups.append(target) target.setdefault("hooks", []).append({ "type": "command", "command": command, "timeout": 5, }) ensure_hook("Stop", 'bash "$HOME/.codex/hooks/ai-setup-sound-on-stop.sh"') ensure_hook("PreCompact", 'bash "$HOME/.codex/hooks/ai-setup-sound-on-auto-compact.sh"', "auto") os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as f: json.dump(data, f, indent=2, ensure_ascii=False) f.write("\n") PYEOF } HELPEREOF chmod +x "$HELPERS_FILE" source "$HELPERS_FILE" _setup_codex_sound_hooks || warn "Не удалось установить Codex sound hooks" # === ai-gpt === cat > "$BIN_DIR/ai-gpt" << 'GPTEOF' #!/usr/bin/env bash # ai-gpt - запуск нативного OpenAI Codex source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ai-api-helpers.sh" 2>/dev/null || true codex_bin="$HOME/.npm-global/bin/codex" [ ! -f "$codex_bin" ] && codex_bin="$(command -v codex 2>/dev/null)" if [ -z "$codex_bin" ] || [ ! -f "$codex_bin" ]; then echo "OpenAI Codex не найден. Устанавливаю..." curl -fsSL https://chatgpt.com/codex/install.sh | sh codex_bin="$HOME/.npm-global/bin/codex" [ ! -f "$codex_bin" ] && codex_bin="$(command -v codex 2>/dev/null)" # Fallback: если curl-установка не сработала (например, 403 от Cloudflare), ставим через npm if [ -z "$codex_bin" ] || [ ! -f "$codex_bin" ]; then echo "Установка через curl не удалась, пробую npm..." npm install -g @openai/codex codex_bin="$HOME/.npm-global/bin/codex" [ ! -f "$codex_bin" ] && codex_bin="$(command -v codex 2>/dev/null)" fi fi if [ -z "$codex_bin" ] || [ ! -f "$codex_bin" ]; then echo "Ошибка: не удалось установить OpenAI Codex." exit 1 fi _build_ai_sys_prompt > /dev/null # сохраняет в ~/.codex/AGENTS.md (codex читает авто) _setup_codex_sound_hooks exec "$codex_bin" --dangerously-bypass-approvals-and-sandbox "$@" GPTEOF chmod +x "$BIN_DIR/ai-gpt" # === ai-deepseek === cat > "$BIN_DIR/ai-deepseek" << 'DEEPSEEKEOF' #!/usr/bin/env bash source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ai-api-helpers.sh" 2>/dev/null || true key_file="$HOME/.config/ai-setup/deepseek_key" api_key="" reauth=0 # Принудительный сброс сохранённого ключа через AI_RESET_KEY=1 if [ "${AI_RESET_KEY:-0}" == "1" ] && [ -f "$key_file" ]; then rm -f "$key_file" api_key="" fi [ -f "$key_file" ] && api_key=$(cat "$key_file") if [ -n "$api_key" ]; then echo -n "Проверка сохранённого DeepSeek ключа... " _claude_test_api "https://api.deepseek.com/anthropic/v1/messages" "x-api-key: $api_key" "deepseek-v4-flash" _handle_api_response "DeepSeek" "$_CLAUDE_TEST_CODE" "$_CLAUDE_TEST_BODY" "Пополните баланс: https://platform.deepseek.com/top_up" ret=$_API_RET if [ $ret -eq 401 ]; then rm -f "$key_file" api_key="" reauth=1 elif [ $ret -eq 429 ]; then echo -n "Продолжить всё равно? (запросы могут не проходить) [y/N] " read -r _ans case "${_ans:-N}" in [Yy]*) ;; *) echo -n "Хотите ввести новый DeepSeek ключ? [Y/n] " read -r _ans2 case "${_ans2:-Y}" in [Yy]*) rm -f "$key_file" api_key="" reauth=1 ;; *) exit 1 ;; esac ;; esac elif [ $ret -ne 0 ]; then exit 1 fi _deepseek_balance "$api_key" fi if [ -z "$api_key" ] && [ "$reauth" -eq 1 ]; then echo -n "Хотите ввести новый DeepSeek ключ? [Y/n] " read -r _ans; case "${_ans:-Y}" in [Yy]*) ;; *) exit 1 ;; esac fi if [ -z "$api_key" ]; then echo "Получить ключ: https://platform.deepseek.com/api_keys" read -r -p "Введите ваш DeepSeek API ключ: " api_key [ -z "$api_key" ] && { echo "Выход."; exit 1; } echo -n "Проверяю ключ и баланс... " _claude_test_api "https://api.deepseek.com/anthropic/v1/messages" "x-api-key: $api_key" "deepseek-v4-flash" _handle_api_response "DeepSeek" "$_CLAUDE_TEST_CODE" "$_CLAUDE_TEST_BODY" "Пополните баланс: https://platform.deepseek.com/top_up" ret=$_API_RET if [ $ret -eq 0 ] || [ $ret -eq 429 ]; then mkdir -p "$(dirname "$key_file")" echo "$api_key" > "$key_file" chmod 600 "$key_file" echo "Ключ сохранён." _deepseek_balance "$api_key" if [ $ret -eq 429 ]; then echo -n "Продолжить всё равно? (запросы могут не проходить) [y/N] " read -r _ans; case "${_ans:-N}" in [Yy]*) ;; *) exit 1 ;; esac fi else echo "Ключ НЕ сохранён." exit 1 fi fi _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX) trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM _build_ai_sys_prompt > "$_PROMPT_FILE" export AI_LAUNCHER=deepseek export CLAUDE_CONFIG_DIR="$HOME/.config/ai-setup/cfg/deepseek" # Пикер: DeepSeek V4 Pro (opus+sonnet, дефолт) и DeepSeek V4 Flash (haiku). # availableModels НЕ задаём: при кастомном провайдере он схлопывает пикер в Default. # Claude Code навязывает 3 слота opus/sonnet/haiku; незаданный слот показал бы чужой # Claude, поэтому sonnet тоже мапим на Pro (лёгкий дубль, но все пункты - DeepSeek). # FABLE не навязывается - не задаём. DISABLE_1M убирает [1M] дубли из пикера. _setup_isolated_config deepseek opus high '' _apply_effort deepseek high ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic \ ANTHROPIC_AUTH_TOKEN="$api_key" \ ANTHROPIC_DEFAULT_OPUS_MODEL=deepseek-v4-pro \ ANTHROPIC_DEFAULT_OPUS_MODEL_NAME="DeepSeek V4 Pro" \ ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION="DeepSeek V4 Pro - флагман для сложных задач" \ ANTHROPIC_DEFAULT_SONNET_MODEL=deepseek-v4-pro \ ANTHROPIC_DEFAULT_SONNET_MODEL_NAME="DeepSeek V4 Pro" \ ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION="DeepSeek V4 Pro - флагман для сложных задач" \ ANTHROPIC_DEFAULT_HAIKU_MODEL=deepseek-v4-flash \ ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME="DeepSeek V4 Flash" \ ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION="DeepSeek V4 Flash - быстрый и дешёвый" \ CLAUDE_CODE_SUBAGENT_MODEL=deepseek-v4-flash \ CLAUDE_CODE_DISABLE_1M_CONTEXT=1 \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ claude --dangerously-skip-permissions --system-prompt-file "$_PROMPT_FILE" "$@" DEEPSEEKEOF chmod +x "$BIN_DIR/ai-deepseek" # === ai-kimi === cat > "$BIN_DIR/ai-kimi" << 'KIMIEOF' #!/usr/bin/env bash # ai-kimi - запуск Claude Code через официальный Kimi Code API source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ai-api-helpers.sh" 2>/dev/null || true keys_dir="$HOME/.config/ai-setup/kimi_keys" legacy_key_file="$HOME/.config/ai-setup/kimi_key" # Миграция старого одиночного файла ключа в директорию с ключами. if [ -f "$legacy_key_file" ] && [ ! -d "$keys_dir" ]; then mkdir -p "$keys_dir" cp "$legacy_key_file" "$keys_dir/account1.key" chmod 600 "$keys_dir/account1.key" echo "account1" > "$keys_dir/current" fi mkdir -p "$keys_dir" get_current_alias() { local current_file="$keys_dir/current" local alias="" [ -f "$current_file" ] && alias=$(cat "$current_file") if [ -n "$alias" ] && [ ! -f "$keys_dir/${alias}.key" ]; then alias="" fi if [ -z "$alias" ]; then alias=$(ls "$keys_dir"/*.key 2>/dev/null | head -1 | xargs -I{} basename {} .key 2>/dev/null || true) fi echo "$alias" } get_key_by_alias() { local alias="$1" [ -f "$keys_dir/${alias}.key" ] && cat "$keys_dir/${alias}.key" || echo "" } set_current_alias() { echo "$1" > "$keys_dir/current" } save_new_key() { local key="$1" local alias="$2" echo "$key" > "$keys_dir/${alias}.key" chmod 600 "$keys_dir/${alias}.key" } next_free_alias() { local i=1 while [ -f "$keys_dir/account${i}.key" ]; do i=$((i + 1)) done echo "account${i}" } # Миграция устаревшего default.key в accountN.key (новая схема именования). if [ -f "$keys_dir/default.key" ]; then old_default_alias=$(next_free_alias) mv "$keys_dir/default.key" "$keys_dir/${old_default_alias}.key" [ -f "$keys_dir/default.meta" ] && mv "$keys_dir/default.meta" "$keys_dir/${old_default_alias}.meta" current=$(cat "$keys_dir/current" 2>/dev/null || true) if [ "$current" = "default" ]; then echo "$old_default_alias" > "$keys_dir/current" fi fi prompt_and_save_key() { echo "Получить ключ: https://www.kimi.com/code" read -r -p "Введите ваш Kimi API ключ: " api_key [ -z "$api_key" ] && { echo "Выход."; return 1; } echo -n "Проверяю ключ и баланс... " _claude_test_api "https://api.kimi.com/coding/v1/messages" "x-api-key: $api_key" "kimi-k2.7" _handle_api_response "Kimi" "$_CLAUDE_TEST_CODE" "$_CLAUDE_TEST_BODY" "Пополните баланс: https://www.kimi.com/code" local ret=$_API_RET if [ $ret -eq 0 ] || [ $ret -eq 429 ]; then local alias=$(next_free_alias) save_new_key "$api_key" "$alias" # Пытаемся получить email/имя аккаунта для отображения в статусной строке. meta=$(_kimi_account_info "$api_key" 2>/dev/null || true) if [ -n "$meta" ]; then echo "$meta" > "$keys_dir/${alias}.meta" chmod 600 "$keys_dir/${alias}.meta" fi set_current_alias "$alias" echo "Ключ сохранён как: $alias." if [ $ret -eq 429 ]; then echo -n "Продолжить всё равно? (запросы могут не проходить) [y/N] " read -r _ans; case "${_ans:-N}" in [Yy]*) ;; *) return 1 ;; esac fi return 0 else echo "Ключ НЕ сохранён." return 1 fi } validate_current_key() { local alias="$1" local api_key=$(get_key_by_alias "$alias") if [ -z "$api_key" ]; then return 1 fi echo -n "Проверка сохранённого Kimi ключа... " _claude_test_api "https://api.kimi.com/coding/v1/messages" "x-api-key: $api_key" "kimi-k2.7" _handle_api_response "Kimi" "$_CLAUDE_TEST_CODE" "$_CLAUDE_TEST_BODY" "Пополните баланс: https://www.kimi.com/code" local ret=$_API_RET if [ $ret -eq 401 ]; then # Удаляем невалидный ключ. rm -f "$keys_dir/${alias}.key" "$keys_dir/${alias}.meta" # Если ключей не осталось — сбрасываем current. if [ -z "$(ls "$keys_dir"/*.key 2>/dev/null)" ]; then rm -f "$keys_dir/current" fi return 1 elif [ $ret -eq 429 ]; then echo -n "Продолжить всё равно? (запросы могут не проходить) [y/N] " read -r _ans; case "${_ans:-N}" in [Yy]*) ;; *) return 2 ;; esac elif [ $ret -ne 0 ]; then return 2 fi return 0 } if ! command -v claude &>/dev/null; then echo "Ошибка: Claude Code не найден. Установите через npm:" echo " npm install -g @anthropic-ai/claude-code" exit 1 fi last_alias="" while true; do current_alias=$(get_current_alias) api_key=$(get_key_by_alias "$current_alias") # Если ключей нет — запросить первый. if [ -z "$api_key" ]; then prompt_and_save_key || exit 1 current_alias=$(get_current_alias) api_key=$(get_key_by_alias "$current_alias") fi # Проверка сохранённого ключа. if [ -n "$api_key" ]; then validate_current_key "$current_alias" ret=$? if [ $ret -eq 1 ]; then # Невалидный ключ удалён; пересчитаем current и попробуем снова. continue elif [ $ret -eq 2 ]; then exit 1 fi fi _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX) trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM _build_ai_sys_prompt > "$_PROMPT_FILE" last_alias="$current_alias" export AI_LAUNCHER=kimi export CLAUDE_CONFIG_DIR="$HOME/.config/ai-setup/cfg/kimi" # Пикер: Opus/Sonnet = Kimi K2.7 Code, Haiku = Kimi K2.6. availableModels НЕ задаём # (он схлопывает пикер в Default). Claude Code навязывает 3 слота opus/sonnet/haiku; # незаданный показал бы чужой Claude, поэтому opus+sonnet = K2.7, haiku = K2.6 # DISABLE_1M убирает [1M] дубли из пикера. _setup_isolated_config kimi opus high '' _apply_effort kimi high ANTHROPIC_BASE_URL=https://api.kimi.com/coding \ ANTHROPIC_AUTH_TOKEN="$api_key" \ ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k2.7 \ ANTHROPIC_DEFAULT_OPUS_MODEL_NAME="Kimi K2.7 Code" \ ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION="Kimi K2.7 Code — флагманский программист (Moonshot AI)" \ ANTHROPIC_DEFAULT_SONNET_MODEL=kimi-k2.7 \ ANTHROPIC_DEFAULT_SONNET_MODEL_NAME="Kimi K2.7 Code" \ ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION="Kimi K2.7 Code — флагманский программист (Moonshot AI)" \ ANTHROPIC_DEFAULT_HAIKU_MODEL=kimi-k2.6 \ ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME="Kimi K2.6" \ ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION="Kimi K2.6 — быстрый универсал (Moonshot AI)" \ CLAUDE_CODE_SUBAGENT_MODEL=kimi-k2.6 \ CLAUDE_CODE_DISABLE_1M_CONTEXT=1 \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ claude --dangerously-skip-permissions --system-prompt-file "$_PROMPT_FILE" "$@" claude_exit=$? # Если хук сменил ключ, перезапускаем ai-kimi с новым. new_alias=$(get_current_alias) if [ -n "$new_alias" ] && [ "$new_alias" != "$last_alias" ]; then echo "" echo "Kimi ключ изменён (/switch-account или /add-account). Перезапускаю ai-kimi с новым ключом..." continue fi exit $claude_exit done KIMIEOF chmod +x "$BIN_DIR/ai-kimi" # === ai-glm === cat > "$BIN_DIR/ai-glm" << 'GLMEOF' #!/usr/bin/env bash # ai-glm - запуск Claude Code через Zhipu AI GLM 5.2 (Anthropic-compatible API) source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ai-api-helpers.sh" 2>/dev/null || true key_file="$HOME/.config/ai-setup/glm_key" api_key="" reauth=0 # Принудительный сброс сохранённого ключа через AI_RESET_KEY=1 if [ "${AI_RESET_KEY:-0}" == "1" ] && [ -f "$key_file" ]; then rm -f "$key_file" api_key="" fi [ -f "$key_file" ] && api_key=$(cat "$key_file") # Endpoint: international Z.AI по умолчанию, можно переопределить через GLM_BASE_URL. glm_base_url="${GLM_BASE_URL:-https://api.z.ai/api/anthropic}" if [ -n "$api_key" ]; then echo -n "Проверка сохранённого GLM ключа... " _claude_test_api "$glm_base_url/v1/messages" "x-api-key: $api_key" "glm-5.2" _handle_api_response "GLM" "$_CLAUDE_TEST_CODE" "$_CLAUDE_TEST_BODY" "Пополните баланс: https://z.ai/manage-apikey/apikey-list" ret=$_API_RET if [ $ret -eq 401 ]; then rm -f "$key_file" api_key="" reauth=1 elif [ $ret -eq 429 ]; then echo -n "Продолжить всё равно? (запросы могут не проходить) [y/N] " read -r _ans case "${_ans:-N}" in [Yy]*) ;; *) echo -n "Хотите ввести новый GLM ключ? [Y/n] " read -r _ans2 case "${_ans2:-Y}" in [Yy]*) rm -f "$key_file" api_key="" reauth=1 ;; *) exit 1 ;; esac ;; esac elif [ $ret -ne 0 ]; then exit 1 fi fi if [ -z "$api_key" ] && [ "$reauth" -eq 1 ]; then echo -n "Хотите ввести новый GLM ключ? [Y/n] " read -r _ans; case "${_ans:-Y}" in [Yy]*) ;; *) exit 1 ;; esac fi if [ -z "$api_key" ]; then echo "Получить ключ: https://z.ai/manage-apikey/apikey-list" read -r -p "Введите ваш Zhipu AI (GLM) API ключ: " api_key [ -z "$api_key" ] && { echo "Выход."; exit 1; } echo -n "Проверяю ключ... " _claude_test_api "$glm_base_url/v1/messages" "x-api-key: $api_key" "glm-5.2" _handle_api_response "GLM" "$_CLAUDE_TEST_CODE" "$_CLAUDE_TEST_BODY" "Пополните баланс: https://z.ai/manage-apikey/apikey-list" ret=$_API_RET if [ $ret -eq 0 ] || [ $ret -eq 429 ]; then mkdir -p "$(dirname "$key_file")" echo "$api_key" > "$key_file" chmod 600 "$key_file" echo "Ключ сохранён." if [ $ret -eq 429 ]; then echo -n "Продолжить всё равно? (запросы могут не проходить) [y/N] " read -r _ans; case "${_ans:-N}" in [Yy]*) ;; *) exit 1 ;; esac fi else echo "Ключ НЕ сохранён." exit 1 fi fi _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX) trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM _build_ai_sys_prompt > "$_PROMPT_FILE" export AI_LAUNCHER=glm export CLAUDE_CONFIG_DIR="$HOME/.config/ai-setup/cfg/glm" # Пикер: Opus/Sonnet = GLM 5.2, Haiku = GLM 5.2[1m]. availableModels НЕ задаём # (он схлопывает пикер). Claude Code навязывает 3 слота; незаданный показал бы # чужой Claude, поэтому opus+sonnet = glm-5.2, haiku = glm-5.2[1m]. _setup_isolated_config glm opus high '' _apply_effort glm high ANTHROPIC_BASE_URL="$glm_base_url" \ ANTHROPIC_AUTH_TOKEN="$api_key" \ ANTHROPIC_DEFAULT_OPUS_MODEL=glm-5.2 \ ANTHROPIC_DEFAULT_OPUS_MODEL_NAME="GLM 5.2" \ ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION="GLM 5.2 — флагман Zhipu AI" \ ANTHROPIC_DEFAULT_SONNET_MODEL=glm-5.2 \ ANTHROPIC_DEFAULT_SONNET_MODEL_NAME="GLM 5.2" \ ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION="GLM 5.2 — флагман Zhipu AI" \ ANTHROPIC_DEFAULT_HAIKU_MODEL='glm-5.2[1m]' \ ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME="GLM 5.2 [1M]" \ ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION="GLM 5.2 [1M] — расширенный контекст" \ CLAUDE_CODE_SUBAGENT_MODEL=glm-5.2 \ # CLAUDE_CODE_DISABLE_1M_CONTEXT НЕ выставляем: GLM 5.2 реально держит 1M контекста, # а флаг заставлял бы счётчик ctx: считать окно = 200K (врал в ~5 раз). Прочие # лаунчеры (deepseek/kimi/openrouter/opencode) флаг оставляют — у них другой контекст # и флаг убирает [1M]-дубли из пикера; ai-claude его и так не использует. CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ claude --dangerously-skip-permissions --system-prompt-file "$_PROMPT_FILE" "$@" GLMEOF chmod +x "$BIN_DIR/ai-glm" # === ai-openrouter === cat > "$BIN_DIR/ai-openrouter" << 'OPENROUTEREOF' #!/usr/bin/env bash # ai-openrouter - запуск Claude Code через OpenRouter (любые модели) source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ai-api-helpers.sh" 2>/dev/null || true key_file="$HOME/.config/ai-setup/openrouter_key" api_key="" # Принудительный сброс сохранённого ключа через AI_RESET_KEY=1 if [ "${AI_RESET_KEY:-0}" == "1" ] && [ -f "$key_file" ]; then rm -f "$key_file" api_key="" fi [ -f "$key_file" ] && api_key=$(cat "$key_file") if [ -n "$api_key" ]; then echo -n "Проверка сохранённого OpenRouter ключа... " _claude_test_openai_api "https://openrouter.ai/api/v1/chat/completions" "$api_key" "openai/gpt-4o-mini" _handle_openai_api_response "OpenRouter" "$_CLAUDE_TEST_CODE" "$_CLAUDE_TEST_BODY" "Пополните баланс: https://openrouter.ai/settings/credits" ret=$_API_RET if [ $ret -eq 401 ]; then rm -f "$key_file" api_key="" elif [ $ret -eq 429 ]; then _openrouter_balance "$api_key" echo -n "Продолжить всё равно? (запросы могут не проходить) [y/N] " read -r _ans case "${_ans:-N}" in [Yy]*) ;; *) echo -n "Хотите ввести новый OpenRouter ключ? [Y/n] " read -r _ans2 case "${_ans2:-Y}" in [Yy]*) rm -f "$key_file" api_key="" ;; *) exit 1 ;; esac ;; esac elif [ $ret -ne 0 ]; then exit 1 else _openrouter_balance "$api_key" fi fi if [ -z "$api_key" ]; then echo "Получить ключ: https://openrouter.ai/settings/keys" read -r -p "Введите ваш OpenRouter API ключ: " api_key [ -z "$api_key" ] && { echo "Выход."; exit 1; } echo -n "Проверяю ключ и баланс... " _claude_test_openai_api "https://openrouter.ai/api/v1/chat/completions" "$api_key" "openai/gpt-4o-mini" _handle_openai_api_response "OpenRouter" "$_CLAUDE_TEST_CODE" "$_CLAUDE_TEST_BODY" "Пополните баланс: https://openrouter.ai/settings/credits" ret=$_API_RET if [ $ret -eq 0 ] || [ $ret -eq 429 ]; then mkdir -p "$(dirname "$key_file")" echo "$api_key" > "$key_file" chmod 600 "$key_file" echo "Ключ сохранён." _openrouter_balance "$api_key" if [ $ret -eq 429 ]; then echo -n "Продолжить всё равно? (запросы могут не проходить) [y/N] " read -r _ans; case "${_ans:-N}" in [Yy]*) ;; *) exit 1 ;; esac fi else echo "Ключ НЕ сохранён." exit 1 fi fi if ! command -v claude &>/dev/null; then echo "Ошибка: Claude Code не найден. Установите через npm:" echo " npm install -g @anthropic-ai/claude-code" exit 1 fi _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX) trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM _build_ai_sys_prompt > "$_PROMPT_FILE" export AI_LAUNCHER=openrouter export CLAUDE_CONFIG_DIR="$HOME/.config/ai-setup/cfg/openrouter" # openrouter - модели, которых НЕТ в других ai-* лаунчерах (без anthropic/deepseek/ # kimi/gemini). Пикер строится из алиас-слотов + 1 custom-пункта (потолок Claude Code). # availableModels НЕ задаём (он схлопывает пикер). Дефолт - GPT-5.5 (custom-пункт). export ANTHROPIC_CUSTOM_MODEL_OPTION="openai/gpt-5.5" export ANTHROPIC_CUSTOM_MODEL_OPTION_NAME="GPT-5.5" export ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION="OpenAI GPT-5.5 (OpenRouter)" _setup_isolated_config openrouter "openai/gpt-5.5" high '' _apply_effort openrouter high ANTHROPIC_BASE_URL=https://openrouter.ai/api \ ANTHROPIC_AUTH_TOKEN="$api_key" \ ANTHROPIC_DEFAULT_OPUS_MODEL=x-ai/grok-4.20 \ ANTHROPIC_DEFAULT_OPUS_MODEL_NAME="Grok 4.20" \ ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION="xAI Grok 4.20 (OpenRouter)" \ ANTHROPIC_DEFAULT_SONNET_MODEL=qwen/qwen3.7-max \ ANTHROPIC_DEFAULT_SONNET_MODEL_NAME="Qwen3.7 Max" \ ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION="Qwen3.7 Max (OpenRouter)" \ ANTHROPIC_DEFAULT_HAIKU_MODEL=z-ai/glm-5.2 \ ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME="GLM 5.2" \ ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION="Zhipu GLM 5.2 (OpenRouter)" \ CLAUDE_CODE_SUBAGENT_MODEL=openai/gpt-5.5 \ CLAUDE_CODE_DISABLE_1M_CONTEXT=1 \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ claude --dangerously-skip-permissions --system-prompt-file "$_PROMPT_FILE" "$@" OPENROUTEREOF chmod +x "$BIN_DIR/ai-openrouter" # === ai-gemini === cat > "$BIN_DIR/ai-gemini" << 'GEMINIEOF' #!/usr/bin/env bash # ============================================================ # ai-gemini - запуск нативного antigravity CLI (agy) # ============================================================ agy_bin="$HOME/.local/bin/agy" [ ! -f "$agy_bin" ] && agy_bin="$(command -v agy 2>/dev/null)" if [ -z "$agy_bin" ] || [ ! -f "$agy_bin" ]; then echo "Antigravity CLI (agy) не найден. Устанавливаю..." curl -fsSL https://antigravity.google/cli/install.sh | bash agy_bin="$HOME/.local/bin/agy" [ ! -f "$agy_bin" ] && agy_bin="$(command -v agy 2>/dev/null)" fi if [ -z "$agy_bin" ] || [ ! -f "$agy_bin" ]; then echo "Ошибка: не удалось установить antigravity CLI." exit 1 fi source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ai-api-helpers.sh" 2>/dev/null || true # agy нативно подтягивает правила и проектные .md файлы, # поэтому ручная инъекция SYS_PROMPT больше не требуется. "$agy_bin" --dangerously-skip-permissions "$@" agy_exit=$? ( afplay /System/Library/Sounds/Glass.aiff 2>/dev/null || paplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || pw-play --volume=1 /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || aplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null ) & exit $agy_exit GEMINIEOF chmod +x "$BIN_DIR/ai-gemini" # Подменяем путь к agy, если BIN_DIR отличается от ~/.local/bin [ "$BIN_DIR" != "$HOME/.local/bin" ] && sed "${SED_INPLACE[@]}" "s|\$HOME/\.local/bin|\$HOME/${BIN_DIR#$HOME/}|g" "$BIN_DIR/ai-gemini" # === ai-cursor === cat > "$BIN_DIR/ai-cursor" << 'CURSOREOF' #!/usr/bin/env bash # ============================================================ # ai-cursor - запуск нативного Cursor CLI (agent) # ============================================================ cursor_bin="$HOME/.local/bin/agent" [ ! -f "$cursor_bin" ] && cursor_bin="$(command -v agent 2>/dev/null)" if [ -z "$cursor_bin" ] || [ ! -f "$cursor_bin" ]; then echo "Cursor CLI (agent) не найден. Устанавливаю..." curl -fsSL https://cursor.com/install -fsS | bash cursor_bin="$HOME/.local/bin/agent" [ ! -f "$cursor_bin" ] && cursor_bin="$(command -v agent 2>/dev/null)" fi if [ -z "$cursor_bin" ] || [ ! -f "$cursor_bin" ]; then echo "Ошибка: не удалось установить Cursor CLI." exit 1 fi source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ai-api-helpers.sh" 2>/dev/null || true # Cursor CLI нативно подтягивает ~/.cursorrules и проектные правила. # Обновляем ~/.cursorrules актуальными глобальными правилами (как AGENTS.md для codex). _build_ai_sys_prompt > /dev/null # --force (alias --yolo) — авто-одобрение правок и команд, аналог # --dangerously-skip-permissions. Логин аккаунтом Cursor (OAuth при первом запуске); # опционально CURSOR_API_KEY прокидывается через env для headless/скриптов. "$cursor_bin" --force "$@" cursor_exit=$? ( afplay /System/Library/Sounds/Glass.aiff 2>/dev/null || paplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || pw-play --volume=1 /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || aplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null ) & exit $cursor_exit CURSOREOF chmod +x "$BIN_DIR/ai-cursor" # Подменяем путь к agent, если BIN_DIR отличается от ~/.local/bin [ "$BIN_DIR" != "$HOME/.local/bin" ] && sed "${SED_INPLACE[@]}" "s|\$HOME/\.local/bin|\$HOME/${BIN_DIR#$HOME/}|g" "$BIN_DIR/ai-cursor" # === ai-claude === cat > "$BIN_DIR/ai-claude" << 'CLAUDEEOF' #!/usr/bin/env bash # ai-claude - запуск оригинального Claude Code (Anthropic) source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ai-api-helpers.sh" 2>/dev/null || true _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX) trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM _build_ai_sys_prompt > "$_PROMPT_FILE" export AI_LAUNCHER=claude # ai-claude работает в дефолтном ~/.claude (нативный логин и аккаунты). # Модель хранится нативно в ~/.claude/settings.json; другие ai-* лаунчеры теперь # изолированы в своих CLAUDE_CONFIG_DIR, поэтому в пикер не протекают чужие модели - # показываются только нативные модели Claude Code. _apply_effort claude xhigh claude --dangerously-skip-permissions --system-prompt-file "$_PROMPT_FILE" "$@" CLAUDEEOF chmod +x "$BIN_DIR/ai-claude" # === ai-opencode === cat > "$BIN_DIR/ai-opencode" << 'OPENCODEEOF' #!/usr/bin/env bash source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ai-api-helpers.sh" 2>/dev/null || true key_file="$HOME/.config/ai-setup/opencode_key" api_key="" # Принудительный сброс сохранённого ключа через AI_RESET_KEY=1 if [ "${AI_RESET_KEY:-0}" == "1" ] && [ -f "$key_file" ]; then rm -f "$key_file" api_key="" fi [ -f "$key_file" ] && api_key=$(cat "$key_file") # Приоритет: env var → файл → ввод if [ -z "$api_key" ] && [ -n "$OPENCODE_GO_API_KEY" ]; then api_key="$OPENCODE_GO_API_KEY" mkdir -p "$(dirname "$key_file")" printf '%s' "$api_key" > "$key_file" chmod 600 "$key_file" fi if [ -z "$api_key" ]; then echo "Получить ключ: https://opencode.ai/en/go" read -r -p "Введи OpenCode Go ключ (sk-opencode-...): " api_key [ -z "$api_key" ] && { echo "Выход."; exit 1; } mkdir -p "$(dirname "$key_file")" printf '%s' "$api_key" > "$key_file" chmod 600 "$key_file" echo "Ключ сохранён." fi # --- routatic-proxy --- export ROUTATIC_PROXY_API_KEY="$api_key" # Запускаем прокси в фоне, если ещё не запущен if ! routatic-proxy status &>/dev/null; then echo -n "Запускаю routatic-proxy... " routatic-proxy serve -b 2>/dev/null sleep 1 if routatic-proxy status &>/dev/null; then echo "готово" else echo "НЕ УДАЛОСЬ" echo "Проверь: routatic-proxy serve" exit 1 fi fi _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX) trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM _build_ai_sys_prompt > "$_PROMPT_FILE" export AI_LAUNCHER=opencode export CLAUDE_CONFIG_DIR="$HOME/.config/ai-setup/cfg/opencode" # Модели OpenCode Go через routatic-proxy. # deepseek-v4-pro — основной для сложных задач (opus/sonnet), # kimi-k2.6 — быстрый (haiku), рекомендован routatic-proxy как дефолт. _setup_isolated_config opencode deepseek-v4-pro high '' _apply_effort opencode high ANTHROPIC_BASE_URL="http://127.0.0.1:3456" \ ANTHROPIC_AUTH_TOKEN="unused" \ ANTHROPIC_DEFAULT_OPUS_MODEL="deepseek-v4-pro" \ ANTHROPIC_DEFAULT_OPUS_MODEL_NAME="DeepSeek V4 Pro (Go)" \ ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION="DeepSeek V4 Pro через OpenCode Go" \ ANTHROPIC_DEFAULT_SONNET_MODEL="deepseek-v4-pro" \ ANTHROPIC_DEFAULT_SONNET_MODEL_NAME="DeepSeek V4 Pro (Go)" \ ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION="DeepSeek V4 Pro через OpenCode Go" \ ANTHROPIC_DEFAULT_HAIKU_MODEL="kimi-k2.6" \ ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME="Kimi K2.6 (Go)" \ ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION="Kimi K2.6 через OpenCode Go" \ CLAUDE_CODE_SUBAGENT_MODEL="kimi-k2.6" \ CLAUDE_CODE_DISABLE_1M_CONTEXT=1 \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ claude --dangerously-skip-permissions --system-prompt-file "$_PROMPT_FILE" "$@" OPENCODEEOF chmod +x "$BIN_DIR/ai-opencode" # Подготавливаем изолированные config dir'ы для Claude Code-лаунчеров, # чтобы hooks (/switch-account, /add-account) и settings.json были на месте # ещё до первого запуска пользователем. source "$BIN_DIR/ai-api-helpers.sh" _setup_isolated_config deepseek opus high '' _setup_isolated_config kimi opus high '' _setup_isolated_config glm opus high '' _setup_isolated_config openrouter "openai/gpt-5.5" high '' _setup_isolated_config opencode deepseek-v4-pro high '' info "Удаляю старые версии скриптов (claude_*)..." rm -f "$BIN_DIR/claude_gpt" "$BIN_DIR/claude_deepseek" "$BIN_DIR/claude_kimi" "$BIN_DIR/claude_gemini" "$BIN_DIR/claude_api_helpers.sh" # Если переехали на ~/bin — удаляем старые скрипты из ~/.local/bin if [ "$BIN_DIR" != "$HOME/.local/bin" ]; then warn "BIN_DIR=$BIN_DIR — удаляю старые скрипты из ~/.local/bin/ ..." rm -f "$HOME/.local/bin/ai-gpt" "$HOME/.local/bin/ai-deepseek" "$HOME/.local/bin/ai-kimi" \ "$HOME/.local/bin/ai-glm" "$HOME/.local/bin/ai-openrouter" "$HOME/.local/bin/ai-gemini" "$HOME/.local/bin/ai-cursor" "$HOME/.local/bin/ai-claude" \ "$HOME/.local/bin/ai-opencode" \ "$HOME/.local/bin/ai-api-helpers.sh" "$HOME/.local/bin/claude-gpt-effort-proxy.py" fi success "Скрипты сгенерированы." # ── 9. Итог ────────────────────────────────────────────────── echo "" echo -e "${GREEN}════════════════════════════════════════════════════${NC}" echo -e "${GREEN} Установка завершена!${NC}" echo -e "${GREEN}════════════════════════════════════════════════════${NC}" echo "" echo "Доступные команды (теперь это независимые скрипты в ~/${BIN_DIR#$HOME/}):" echo "" echo " На базе Claude Code:" echo -e " ${CYAN}ai-claude${NC} - Оригинальный Claude Code (Anthropic)" echo -e " ${CYAN}ai-deepseek${NC} - DeepSeek (через Claude Code, API ключ сохраняется)" echo -e " ${CYAN}ai-kimi${NC} - Kimi K2.7 Code (через Claude Code, API ключ сохраняется)" echo -e " ${CYAN}ai-glm${NC} - GLM 5.2 (через Claude Code, Zhipu AI API)" echo -e " ${CYAN}ai-openrouter${NC} - OpenRouter (через Claude Code: GPT-5.5, Grok, Qwen, GLM 5.2 и др.)" echo -e " ${CYAN}ai-opencode${NC} - OpenCode Go (через Claude Code + routatic-proxy: DeepSeek, Kimi, Qwen, GLM, MiniMax, MiMo)" echo "" echo " Нативные CLI:" echo -e " ${CYAN}ai-gpt${NC} - OpenAI Codex (нативный CLI, автоустановка)" echo -e " ${CYAN}ai-gemini${NC} - Gemini (нативный agy CLI, автоустановка)" echo -e " ${CYAN}ai-cursor${NC} - Cursor (нативный Cursor CLI, автоустановка)" echo "" echo " Android CLI:" echo -e " ${CYAN}android${NC} - Google Android CLI (SDK, эмулятор, проекты)" echo "" echo -e "Чтобы команды были доступны сразу, выполните: ${GREEN}exec bash${NC}"