#!/bin/sh # RouteAPI: переезд с Cursor за 5 минут (macOS, Linux). # # curl -fsSL https://app.routeapi.ru/install.sh | sh -s -- --key sk-gw-... # # Что делает (и ничего больше): # 1. Направляет Claude Code на RouteAPI: ~/.claude/settings.json, блок env (ANTHROPIC_BASE_URL, ключ, модель, # модель фоновых задач ANTHROPIC_DEFAULT_HAIKU_MODEL). # Эти настройки общие для Claude Code в терминале и расширения Claude Code для VS Code. # Спрашивает разрешения; без терминала (CI, агенты) меняет их только с --yes. # 2. Ставит официальное расширение Claude Code (anthropic.claude-code) в Cursor и/или VS Code — во все # найденные — и выключает в нём окно входа в аккаунт Anthropic (claudeCode.disableLoginPrompt). # 3. Переносит правила проекта: .cursorrules, .cursor/rules/*, а также .roorules, .roo/rules/* и .clinerules # -> .claude/rules/ (globs из Cursor # становятся paths). Существующие файлы не трогает. # Перед изменением settings.json делается копия *.bak-routeapi. Ключ никогда не выводится на экран. # # Параметры: # --key KEY ключ RouteAPI (sk-gw-…). Без него скрипт спросит ключ, не показывая ввод. # Можно передать и через переменную окружения ROUTEAPI_KEY. # --model ID модель по умолчанию (по умолчанию claude-sonnet-5-5) # --project DIR папка проекта для переноса правил (по умолчанию текущая) # --yes не задавать вопросов: настроить Claude Code сразу # --no-claude не менять настройки Claude Code # --no-editors не ставить расширение в Cursor и VS Code set -eu API="https://api.routeapi.ru" MODEL="claude-sonnet-5-5" HAIKU_MODEL="claude-haiku-5-5" KEY="${ROUTEAPI_KEY:-}" PROJECT="." YES=0 DO_CLAUDE=ask DO_VSCODE=1 say() { printf '%s\n' "$*"; } warn() { printf '! %s\n' "$*" >&2; } die() { printf 'Ошибка: %s\n' "$*" >&2; exit 1; } while [ $# -gt 0 ]; do case "$1" in --key) [ $# -ge 2 ] || die "после --key нужен ключ"; KEY="$2"; shift 2 ;; --key=*) KEY="${1#--key=}"; shift ;; --model) [ $# -ge 2 ] || die "после --model нужен id модели"; MODEL="$2"; shift 2 ;; --model=*) MODEL="${1#--model=}"; shift ;; --project) [ $# -ge 2 ] || die "после --project нужна папка"; PROJECT="$2"; shift 2 ;; --project=*) PROJECT="${1#--project=}"; shift ;; --yes|-y) YES=1; shift ;; --no-claude) DO_CLAUDE=0; shift ;; --no-editors|--no-vscode|--no-roo) DO_VSCODE=0; shift ;; -h|--help) sed -n '2,26p' "$0" 2>/dev/null || say "См. https://app.routeapi.ru/pereezd-s-cursor"; exit 0 ;; *) die "неизвестный параметр: $1" ;; esac done TTY="" # ROUTEAPI_NO_TTY=1 behaves as if there were no terminal (used by tests). if [ -z "${ROUTEAPI_NO_TTY:-}" ] && [ -r /dev/tty ] && [ -w /dev/tty ] && (: /dev/null; then TTY=/dev/tty; fi if [ -z "$KEY" ]; then [ -n "$TTY" ] || die "передайте ключ: --key sk-gw-… (ключ — в кабинете https://app.routeapi.ru/dashboard/keys)" printf 'Ключ RouteAPI (sk-gw-…, ввод не отображается): ' >"$TTY" stty -echo <"$TTY" 2>/dev/null || true IFS= read -r KEY <"$TTY" || true stty echo <"$TTY" 2>/dev/null || true printf '\n' >"$TTY" fi case "$KEY" in sk-gw-*) ;; *) die "ключ должен начинаться с sk-gw- (создайте его в https://app.routeapi.ru/dashboard/keys)" ;; esac # Only safe characters: the key is written into JSON files. printf '%s' "$KEY" | grep -Eq '^sk-gw-[A-Za-z0-9_-]{8,200}$' || die "ключ содержит недопустимые символы — скопируйте его заново" printf '%s' "$MODEL" | grep -Eq '^[a-z0-9][a-z0-9.-]{1,99}$' || die "некорректный id модели: $MODEL" MASKED="$(printf '%s' "$KEY" | cut -c1-10)…" # JSON merge helper: node, else python3. On macOS /usr/bin/python3 without Command Line Tools # opens an install dialog, so python3 is used there only when the tools are present. JSON_TOOL="" if command -v node >/dev/null 2>&1; then JSON_TOOL=node elif command -v python3 >/dev/null 2>&1; then if [ "$(uname -s)" != "Darwin" ] || xcode-select -p >/dev/null 2>&1; then JSON_TOOL=python3; fi fi # merge_json FILE PATCH_JSON: deep-merges PATCH into FILE (JSON with comments allowed), keeps a backup. merge_json() { RT_FILE="$1" RT_PATCH="$2" export RT_FILE RT_PATCH case "$JSON_TOOL" in node) node -e ' const fs = require("fs"), path = require("path"); const file = process.env.RT_FILE, patch = JSON.parse(process.env.RT_PATCH); function strip(s) { let out = "", i = 0, str = false; while (i < s.length) { const c = s[i], n = s[i + 1]; if (str) { out += c; if (c === "\\") { out += n ?? ""; i += 2; continue; } if (c === "\"") str = false; i++; continue; } if (c === "\"") { str = true; out += c; i++; continue; } if (c === "/" && n === "/") { while (i < s.length && s[i] !== "\n") i++; continue; } if (c === "/" && n === "*") { i += 2; while (i < s.length && !(s[i] === "*" && s[i + 1] === "/")) i++; i += 2; continue; } out += c; i++; } return out.replace(/,(\s*[}\]])/g, "$1"); } function merge(a, b) { for (const k of Object.keys(b)) { if (b[k] && typeof b[k] === "object" && !Array.isArray(b[k]) && a[k] && typeof a[k] === "object" && !Array.isArray(a[k])) merge(a[k], b[k]); else a[k] = b[k]; } return a; } let data = {}; if (fs.existsSync(file)) { const raw = fs.readFileSync(file, "utf8"); if (raw.trim()) { data = JSON.parse(strip(raw)); if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("not an object"); } fs.copyFileSync(file, file + ".bak-routeapi"); } fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(merge(data, patch), null, 2) + "\n", { mode: 0o600 }); fs.chmodSync(file, 0o600); ' ;; python3) python3 -I -c ' import json, os, shutil f, patch = os.environ["RT_FILE"], json.loads(os.environ["RT_PATCH"]) def strip(s): out, i, st = [], 0, False while i < len(s): c, n = s[i], s[i + 1] if i + 1 < len(s) else "" if st: out.append(c) if c == "\\": out.append(n); i += 2; continue if c == "\"": st = False i += 1; continue if c == "\"": st = True; out.append(c); i += 1; continue if c == "/" and n == "/": while i < len(s) and s[i] != "\n": i += 1 continue if c == "/" and n == "*": i += 2 while i < len(s) and not (s[i] == "*" and i + 1 < len(s) and s[i + 1] == "/"): i += 1 i += 2; continue out.append(c); i += 1 import re return re.sub(r",(\s*[}\]])", r"\1", "".join(out)) def merge(a, b): for k, v in b.items(): if isinstance(v, dict) and isinstance(a.get(k), dict): merge(a[k], v) else: a[k] = v return a data = {} if os.path.exists(f): raw = open(f, encoding="utf-8").read() if raw.strip(): data = json.loads(strip(raw)) if not isinstance(data, dict): raise ValueError("not an object") shutil.copyfile(f, f + ".bak-routeapi") os.makedirs(os.path.dirname(f), exist_ok=True) fd = os.open(f, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as h: json.dump(merge(data, patch), h, ensure_ascii=False, indent=2); h.write("\n") os.chmod(f, 0o600) ' ;; *) return 2 ;; esac } # ask_yes "Вопрос": yes by default in a terminal; without a terminal (CI, agent shells) only with --yes. ask_yes() { [ "$YES" = 1 ] && return 0 [ -n "$TTY" ] || return 1 printf '%s [Y/n] ' "$1" >"$TTY" IFS= read -r a <"$TTY" || a="" case "$a" in n|N|no|нет|Н|н) return 1 ;; *) return 0 ;; esac } say "RouteAPI: настройка с ключом $MASKED, модель $MODEL" OS="$(uname -s)" if [ "$OS" = Darwin ]; then APP_SUPPORT="$HOME/Library/Application Support"; else APP_SUPPORT="${XDG_CONFIG_HOME:-$HOME/.config}"; fi VSCODE_USER="${ROUTEAPI_VSCODE_USER_DIR:-$APP_SUPPORT/Code/User}" CURSOR_USER="${ROUTEAPI_CURSOR_USER_DIR:-$APP_SUPPORT/Cursor/User}" # ---------------------------------------------------------------- 1. Claude Code settings CLAUDE_OK=0 if [ "$DO_CLAUDE" != 0 ]; then if ask_yes "Направить Claude Code (терминал и VS Code) на RouteAPI? Изменится ~/.claude/settings.json, копия сохранится."; then # ANTHROPIC_AUTH_TOKEN (Authorization: Bearer) is what Claude Code uses for gateways; no API-key approval prompt. # ANTHROPIC_DEFAULT_HAIKU_MODEL: background tasks of Claude Code go to the same cheap model (free on the free tier). HAIKU_ENV=""; [ -n "$HAIKU_MODEL" ] && HAIKU_ENV=",\"ANTHROPIC_DEFAULT_HAIKU_MODEL\":\"$HAIKU_MODEL\"" PATCH="{\"env\":{\"ANTHROPIC_BASE_URL\":\"$API\",\"ANTHROPIC_AUTH_TOKEN\":\"$KEY\",\"ANTHROPIC_MODEL\":\"$MODEL\"$HAIKU_ENV}}" if [ -n "$JSON_TOOL" ] && merge_json "$HOME/.claude/settings.json" "$PATCH" 2>/dev/null; then CLAUDE_OK=1 say "→ Claude Code направлен на RouteAPI (~/.claude/settings.json)." else warn "не удалось записать ~/.claude/settings.json: нужен node или python3. Инструкция: https://app.routeapi.ru/connect/claude-code" fi else say "→ Настройки Claude Code не тронуты (без терминала — только с --yes)." fi fi # ---------------------------------------------------------------- 2. Editors: Cursor and VS Code # real_path FILE: follow symlinks (portable readlink -f). real_path() { p="$1" while [ -L "$p" ]; do l="$(readlink "$p")" case "$l" in /*) p="$l" ;; *) p="$(dirname "$p")/$l" ;; esac done printf '%s' "$p" } # setup_editor NAME CLI USER_DIR: install the extension, turn off the Anthropic login prompt. setup_editor() { say "→ Ставлю расширение Claude Code в $1…" if "$2" --install-extension anthropic.claude-code >/dev/null 2>&1; then say " $1: расширение Claude Code установлено." else warn "$1: не удалось поставить расширение. Поставьте вручную: Extensions → «Claude Code» (Anthropic)." fi if [ "$CLAUDE_OK" = 1 ] && [ -n "$JSON_TOOL" ]; then merge_json "$3/settings.json" '{"claudeCode.disableLoginPrompt":true}' 2>/dev/null || warn "$1: не удалось выключить окно входа — включите Claude Code → Disable Login Prompt в настройках." fi } EDITORS="" if [ "$DO_VSCODE" = 1 ]; then CURSOR_CLI="" CODE_CLI="" # ROUTEAPI_NO_APP_BUNDLES=1 searches PATH only (used by tests on machines that have the apps installed). APPS="/Applications"; [ -z "${ROUTEAPI_NO_APP_BUNDLES:-}" ] || APPS="/nonexistent" for c in "$(command -v cursor 2>/dev/null || true)" "$APPS/Cursor.app/Contents/Resources/app/bin/cursor" "$HOME/Applications/Cursor.app/Contents/Resources/app/bin/cursor"; do if [ -n "$c" ] && [ -x "$c" ]; then CURSOR_CLI="$c"; break; fi done for c in "$(command -v code 2>/dev/null || true)" "$APPS/Visual Studio Code.app/Contents/Resources/app/bin/code" "$HOME/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"; do [ -n "$c" ] && [ -x "$c" ] || continue # Cursor ships its own `code`: when that one is first in PATH it is Cursor, not VS Code. case "$(real_path "$c")" in *Cursor.app*|*/cursor/*) [ -n "$CURSOR_CLI" ] || CURSOR_CLI="$c" ;; *) CODE_CLI="$c"; break ;; esac done if [ -n "$CURSOR_CLI" ]; then setup_editor Cursor "$CURSOR_CLI" "$CURSOR_USER"; EDITORS="Cursor"; fi if [ -n "$CODE_CLI" ]; then setup_editor "VS Code" "$CODE_CLI" "$VSCODE_USER"; EDITORS="${EDITORS:+$EDITORS или }VS Code"; fi [ -n "$EDITORS" ] || warn "Не нашёл ни Cursor, ни VS Code. Claude Code работает и в терминале: npm install -g @anthropic-ai/claude-code" fi # ---------------------------------------------------------------- 3. Project rules RULES=0 if [ -d "$PROJECT" ]; then cd "$PROJECT" # copy_rule SRC DEST_NAME: Cursor front matter -> Claude Code rule (globs -> paths), never overwrites. copy_rule() { dest=".claude/rules/$2" [ -e "$dest" ] && return 0 mkdir -p .claude/rules awk ' NR == 1 && $0 == "---" { fm = 1; next } fm && $0 == "---" { fm = 0 if (globs != "" && always != "true") { print "---"; print "paths:" n = split(globs, g, ",") for (i = 1; i <= n; i++) { p = g[i]; gsub(/^[ \t"'"'"'\[]+|[ \t"'"'"'\]]+$/, "", p); if (p != "") printf " - \"%s\"\n", p } print "---" } next } fm && /^globs:/ { globs = $0; sub(/^globs:[ \t]*/, "", globs); next } fm && /^alwaysApply:/ { always = $2; next } fm { next } { print } ' "$1" >"$dest" RULES=$((RULES + 1)) } [ -f .cursorrules ] && copy_rule .cursorrules "cursorrules.md" # Roo Code and Cline rules too (Roo Code is closed; its users move the same way). [ -f .roorules ] && copy_rule .roorules "roorules.md" [ -f .clinerules ] && copy_rule .clinerules "clinerules.md" if [ -d .roo/rules ]; then for f in .roo/rules/*.md; do [ -f "$f" ] || continue name="$(basename "$f")"; copy_rule "$f" "roo-${name%.md}.md" done fi if [ -d .cursor/rules ]; then for f in .cursor/rules/*.mdc .cursor/rules/*.md; do [ -f "$f" ] || continue name="$(basename "$f")"; name="${name%.*}" copy_rule "$f" "cursor-$name.md" done fi [ "$RULES" -gt 0 ] && say "→ Перенесено правил проекта: $RULES (в $(pwd)/.claude/rules — Claude Code читает их сам)" fi say "" if [ "$CLAUDE_OK" = 1 ]; then say "Готово. Откройте проект в ${EDITORS:-редакторе} и нажмите значок Claude Code, или запустите claude в терминале." else say "Готово. Чтобы Claude Code работал через RouteAPI, запустите скрипт ещё раз в терминале или с --yes." fi say "Расход и ключи: https://app.routeapi.ru/dashboard"