Files
ai-setup/scripts/ai-setup.sh
vitaly c57158420e fix: корректный счётчик контекста в ai-glm (GLM 5.2 = 1M)
CLAUDE_CODE_DISABLE_1M_CONTEXT=1 заставлял Claude Code считать
контекстное окно = 200K, из-за чего ctx: в статус-баре заполнялся
в ~5 раз раньше реального лимита GLM 5.2 (1M токенов).

Флаг убран только в секции ai-glm. Прочие лаунчеры (deepseek/kimi/
openrouter/opencode) флаг оставляют — у них другой контекст, и флаг
убирает [1M]-дубли из пикера. ai-claude его не использует.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-04 17:35:10 +03:00

2004 lines
83 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# 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 <<EOF > "$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 <launcher> <default_model> <default_effort> <available_models_json>
_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 <launcher> <default_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}"