ai-setup.sh проверял только наличие node через command -v, поэтому системный Node v12 (Ubuntu 22.04) проходил проверку и установка NodeSource 22 не запускалась. Из-за этого хук claude-notifier падал с SyntaxError на optional chaining (?.) и nullish coalescing (??), которые поддерживаются только начиная с Node 14. Теперь скрипт извлекает мажорную версию и при значении меньше 18 ставит/обновляет Node, чтобы регенерация не возвращала сломанные хуки. Co-Authored-By: Claude <noreply@anthropic.com>
2055 lines
85 KiB
Bash
Executable File
2055 lines
85 KiB
Bash
Executable File
#!/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..."
|
||
# Хуки (claude-notifier и др.) используют optional chaining (?.) и nullish
|
||
# coalescing (??) — нужен Node >=18. Проверяем не только наличие node, но и
|
||
# мажорную версию, иначе системный v12 (Ubuntu 22.04) проходит проверку, а хуки падают.
|
||
node_major=0
|
||
if command -v node &>/dev/null; then
|
||
node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
|
||
fi
|
||
if [ "${node_major:-0}" -lt 18 ]; then
|
||
if [ "${node_major:-0}" -gt 0 ]; then
|
||
info "Найден Node.js v${node_major}, нужен >=18. Обновляю..."
|
||
else
|
||
info "Пытаюсь установить Node.js..."
|
||
fi
|
||
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.7.3. Хук logout-account ───────────────────────────────────
|
||
info "Деплою хук logout-account..."
|
||
LOGOUT_HOOK_SRC="$SCRIPT_DIR/home-configs/claude/hooks/logout-account-hook.sh"
|
||
LOGOUT_HOOK_DST="$HOME/.claude/hooks/logout-account-hook.sh"
|
||
mkdir -p "$HOME/.claude/hooks"
|
||
if [ -f "$LOGOUT_HOOK_SRC" ]; then
|
||
cp "$LOGOUT_HOOK_SRC" "$LOGOUT_HOOK_DST"
|
||
chmod +x "$LOGOUT_HOOK_DST"
|
||
# Прописываем хук в settings.json (идемпотентно)
|
||
python3 - "$HOME/.claude/settings.json" "$LOGOUT_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 "Хук logout-account установлен"
|
||
else
|
||
warn "Файл $LOGOUT_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 logout-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" "$cfg/hooks/logout-account-hook.sh" <<'PYEOF'
|
||
import sys, json, os
|
||
settings_path = sys.argv[1]
|
||
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 sys.argv[2:]:
|
||
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 ''
|
||
# ai-glm стартует на /effort max. max невалиден в settings.json (там только
|
||
# low/medium/high/xhigh), поэтому задаём через CLI --effort, а не _apply_effort.
|
||
# CLAUDE_CODE_ALWAYS_ENABLE_EFFORT заставляет Claude Code отправлять effort в API
|
||
# для GLM (иначе glm-5.2 не распознан как effort-capable и параметр не уходит).
|
||
# /effort внутри сессии работает; ручной override запуска: AI_EFFORT=high ai-glm.
|
||
# CLAUDE_CODE_DISABLE_1M_CONTEXT НЕ выставляем: GLM 5.2 реально держит 1M контекста,
|
||
# а флаг заставлял бы счётчик ctx: считать окно = 200K (врал в ~5 раз). Прочие
|
||
# лаунчеры (deepseek/kimi/openrouter/opencode) флаг оставляют — у них другой контекст
|
||
# и флаг убирает [1M]-дубли из пикера; ai-claude его и так не использует.
|
||
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_ALWAYS_ENABLE_EFFORT=1 \
|
||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
|
||
claude --dangerously-skip-permissions --effort "${AI_EFFORT:-max}" --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}"
|