#!/usr/bin/env bash # NEXUS_AI - установщик в 1 клик для macOS и Linux. # # curl -fsSL https://nexusallin.store/install/nexus-setup.sh | NEXUS_KEY=sk-... bash # # Проверяет ключ, находит Cursor / VS Code / Windsurf и терминальные агенты (Claude Code, Codex, # Qwen Code, OpenCode, Kilo Code) и прописывает в их настройки адрес шлюза и ключ. В Cursor / VS Code # ставит агент Roo Code уже подключённым. Перед каждым изменением копия файла сохраняется в # ~/.nexus-ai/backups/<время>/. Откат последней установки: запустите ещё раз и введите R # (или NEXUS_ACTION=restore). # # Переменные: NEXUS_KEY, NEXUS_MODEL, NEXUS_AUTO=1 (без вопросов), NEXUS_ACTION=restore, NEXUS_SITE, # NEXUS_TEST_ROOT (песочница для тестов). set -u find_python() { for c in python3 python; do p="$(command -v "$c" 2>/dev/null)" || continue # на macOS без инструментов разработчика /usr/bin/python3 только предлагает их установить if [ "$(uname -s)" = "Darwin" ] && [ "$p" = "/usr/bin/python3" ] && ! xcode-select -p >/dev/null 2>&1; then continue; fi if "$c" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 6) else 1)' >/dev/null 2>&1; then echo "$c"; return 0; fi done return 1 } PY="$(find_python)" || { echo "Для установщика нужен Python 3." if [ "$(uname -s)" = "Darwin" ]; then echo "Установите инструменты разработчика: xcode-select --install - и запустите команду снова." else echo "Установите его (например: sudo apt install -y python3) и запустите команду снова."; fi exit 1 } TMP_PY="$(mktemp "${TMPDIR:-/tmp}/nexus-setup.XXXXXX")" || exit 1 trap 'rm -f "$TMP_PY"' EXIT cat > "$TMP_PY" <<'NEXUS_PY' import json, os, re, shutil, subprocess, sys, time, urllib.request, urllib.error from datetime import datetime SITE = (os.environ.get("NEXUS_SITE") or "https://nexusallin.store").rstrip("/") OPENAI_BASE = SITE + "/v1" AUTO = os.environ.get("NEXUS_AUTO") == "1" TEST_ROOT = os.environ.get("NEXUS_TEST_ROOT") or "" HOME = os.path.join(TEST_ROOT, "home") if TEST_ROOT else os.path.expanduser("~") IS_MAC = sys.platform == "darwin" NEXUS_DIR = os.path.join(HOME, ".nexus-ai") BACKUP_ROOT = os.path.join(NEXUS_DIR, "backups") STAMP = datetime.now().strftime("%Y%m%d_%H%M%S") RUN_DIR = os.path.join(BACKUP_ROOT, STAMP) _n = 2 while os.path.exists(RUN_DIR): RUN_DIR = os.path.join(BACKUP_ROOT, "%s_%d" % (STAMP, _n)); _n += 1 COLOR = sys.stdout.isatty() EXIT = 0 def c(code, text): return "\033[%sm%s\033[0m" % (code, text) if COLOR else text def say(text="", code=None): print(c(code, text) if code else text, flush=True) def ok(text): say(" " + c("32", "[OK] ") + text) def warn(text): say(" " + c("33", "[!] ") + text) def fail(text): say(" " + c("31", "[X] ") + text) def step(text): say(" " + c("36", "-> ") + text) def mask(k): return k[:6] + "..." + k[-4:] if len(k) > 12 else k def log(text): try: os.makedirs(NEXUS_DIR, exist_ok=True) with open(os.path.join(NEXUS_DIR, "install.log"), "a", encoding="utf-8") as f: f.write("%s %s\n" % (datetime.now().isoformat(timespec="seconds"), text)) except OSError: pass def ask(prompt, default=""): if AUTO: return default try: a = input(prompt + " ").strip() except EOFError: return default return a or default # ── backups / restore ──────────────────────────────────────────────────────── _backed = set() def manifest_add(line): os.makedirs(RUN_DIR, exist_ok=True) with open(os.path.join(RUN_DIR, "manifest.tsv"), "a", encoding="utf-8") as f: f.write(line + "\n") def backup_once(path): if path in _backed: return _backed.add(path) if os.path.exists(path): name = "%02d_%s" % (len(_backed), os.path.basename(path)) os.makedirs(RUN_DIR, exist_ok=True) shutil.copy2(path, os.path.join(RUN_DIR, name)) manifest_add("FILE\t%s\t%s\t1" % (path, name)) else: manifest_add("FILE\t%s\t\t0" % path) def save_text(path, text): backup_once(path) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8", newline="") as f: f.write(text) def restore(): runs = [] if os.path.isdir(BACKUP_ROOT): for n in os.listdir(BACKUP_ROOT): d = os.path.join(BACKUP_ROOT, n) if re.match(r"^\d{8}_\d{6}(_\d+)?$", n) and os.path.isfile(os.path.join(d, "manifest.tsv")): runs.append((os.path.getmtime(os.path.join(d, "manifest.tsv")), n, d)) if not runs: warn("Нечего откатывать: резервных копий нет.") return _, name, d = sorted(runs)[-1] say(" Откатываю установку от %s..." % name) with open(os.path.join(d, "manifest.tsv"), encoding="utf-8") as f: for line in f: p = line.rstrip("\n").split("\t") if len(p) < 4 or p[0] != "FILE": continue if p[3] == "1": shutil.copy2(os.path.join(d, p[2]), p[1]); ok("восстановлен " + p[1]) elif os.path.exists(p[1]): os.remove(p[1]); ok("удалён созданный файл " + p[1]) os.rename(d, d + ".restored") ok("Готово. Установленные расширения (Roo Code) не удалялись - их можно удалить в редакторе.") log("restore " + name) # ── JSON / JSONC: comments and unrelated keys survive ──────────────────────── TOK = re.compile(r'"(?:[^"\\]|\\.)*"|//[^\r\n]*|/\*[\s\S]*?\*/|[{}\[\]:,]|[^\s{}\[\]:,"/]+') def tokens(text): return [m for m in TOK.finditer(text) if not m.group().startswith(("//", "/*"))] def parse_jsonc(text): toks = [m.group() for m in tokens(text)] if not toks: return None clean = [t for i, t in enumerate(toks) if not (t == "," and i + 1 < len(toks) and toks[i + 1] in ("}", "]"))] return json.loads(" ".join(clean)) def read_json(path): if not os.path.exists(path): return {} with open(path, encoding="utf-8-sig") as f: text = f.read() if not text.strip(): return {} obj = parse_jsonc(text) if not isinstance(obj, dict): raise ValueError("в %s не JSON-объект" % path) return obj def dumps(value, unit, nl, level=1): s = json.dumps(value, indent=unit, ensure_ascii=False) return s.replace("\n", nl + unit * level) def set_top_level(text, key, value_json, nl, unit): toks = tokens(text) if not toks or toks[0].group() != "{": raise ValueError("корень файла - не объект") quoted = json.dumps(key, ensure_ascii=False) depth, close = 0, None for i, m in enumerate(toks): v = m.group() if v in "{[": depth += 1; continue if v in "}]": depth -= 1 if depth == 0: close = i; break continue if depth == 1 and v == quoted and i + 2 < len(toks) and toks[i + 1].group() == ":": j = i + 2 start, stop = toks[j].start(), toks[j].end() if toks[j].group() in "{[": d = 0 for k in range(j, len(toks)): t = toks[k].group() if t in "{[": d += 1 elif t in "}]": d -= 1 if d == 0: stop = toks[k].end(); break return text[:start] + value_json + text[stop:] if close is None: raise ValueError("не найден конец объекта") prev = toks[close - 1] prop = quoted + ": " + value_json if prev.group() == "{": return text[:prev.end()] + nl + unit + prop + nl + text[toks[close].start():] if prev.group() == ",": at, sep = prev.end(), nl + unit else: at, sep = prev.end(), "," + nl + unit return text[:at] + sep + prop + text[at:] def update_json(path, values): text = "" if os.path.exists(path): with open(path, encoding="utf-8-sig") as f: text = f.read() if not text.strip(): text = "{\n}" nl = "\r\n" if "\r\n" in text else "\n" m = re.search(r'\n([ \t]+)"', text) unit = m.group(1) if m else " " parse_jsonc(text) for k, v in values.items(): text = set_top_level(text, k, dumps(v, unit, nl), nl, unit) parse_jsonc(text) # the result must stay valid, otherwise nothing is written save_text(path, text) # ── API ────────────────────────────────────────────────────────────────────── def api_get(path, key=None): url = OPENAI_BASE + path headers = {"Authorization": "Bearer " + key} if key else {} try: req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=25) as r: return 200, json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: return e.code, None except Exception: # Python without CA certificates (python.org builds on macOS): fall back to curl cmd = ["curl", "-sS", "-m", "25", "-w", "\n%{http_code}", url] if key: cmd[1:1] = ["-H", "Authorization: Bearer " + key] try: out = subprocess.run(cmd, capture_output=True, text=True, timeout=30).stdout body, _, code = out.rpartition("\n") code = int(code or 0) return code, (json.loads(body) if code == 200 else None) except Exception: return 0, None def fmt(n): return "{:,}".format(int(n)).replace(",", " ") def check_key(key): code, b = api_get("/balance", key) if code == 200 and b: if b.get("is_unlimited"): ok("Ключ %s активен - безлимит" % mask(key)) else: extra = "" if b.get("expires_at"): extra = " · действует до " + str(b["expires_at"])[:10] ok("Ключ %s активен - баланс %s токенов%s" % (mask(key), fmt(b.get("remaining_credits", 0)), extra)) if (b.get("remaining_credits") or 0) <= 0: warn("Баланс пуст - программы подключатся, но ответы пойдут после пополнения на FunPay.") return "ok" if code in (401, 403): return "bad" warn("Не удалось проверить ключ (нет связи с %s). Продолжаю." % SITE) return "offline" CURATED = [ ("claude-sonnet-5.5", "самая сильная для кода и агентов"), ("gpt-6-luna", "умная и заметно дешевле"), ("deepseek-v4-pro", "сильная, дешевле Sonnet"), ("grok-4.7", "быстрая, логика и математика"), ("deepseek-v4.1-flash", "быстрая"), ("gemini-3.8-flash", "быстрая"), ("glm-5.3-flash", "дешёвая"), ("qwen3.7-flash", "самая дешёвая, для простых задач"), ] FAST = ["claude-haiku-5.5", "glm-5.3-flash", "qwen3.7-flash", "deepseek-v4.1-flash"] # ── detection ──────────────────────────────────────────────────────────────── def find_cli(*names): if TEST_ROOT: for n in names: p = os.path.join(TEST_ROOT, "bin", n) if os.path.exists(p): return p return None for n in names: p = shutil.which(n) if p: return p return None def app_support(name): if TEST_ROOT: return os.path.join(TEST_ROOT, "config", name, "User") if IS_MAC: return os.path.join(HOME, "Library", "Application Support", name, "User") return os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.join(HOME, ".config"), name, "User") EDITORS = [ {"id": "cursor", "name": "Cursor", "cmd": "cursor", "mac": "/Applications/Cursor.app/Contents/Resources/app/bin/cursor", "user": app_support("Cursor"), "ext": os.path.join(HOME, ".cursor", "extensions")}, {"id": "vscode", "name": "VS Code", "cmd": "code", "mac": "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", "user": app_support("Code"), "ext": os.path.join(HOME, ".vscode", "extensions")}, {"id": "windsurf", "name": "Windsurf", "cmd": "windsurf", "mac": "/Applications/Windsurf.app/Contents/Resources/app/bin/windsurf", "user": app_support("Windsurf"), "ext": os.path.join(HOME, ".windsurf", "extensions")}, ] def detect_editors(): for e in EDITORS: cli = find_cli(e["cmd"]) if not cli and not TEST_ROOT and IS_MAC and os.path.exists(e["mac"]): cli = e["mac"] e["cli"] = cli e["found"] = bool(cli or os.path.isdir(e["user"])) def has_ext(e, prefix): d = e["ext"] return os.path.isdir(d) and any(n.lower().startswith(prefix + "-") for n in os.listdir(d)) def run_tool(args, timeout=300): try: r = subprocess.run(args, capture_output=True, text=True, timeout=timeout) return r.returncode, (r.stdout + "\n" + r.stderr).strip() except Exception as ex: return -1, str(ex) def install_ext(e, ext_id): if TEST_ROOT: os.makedirs(os.path.join(e["ext"], ext_id.lower() + "-9.9.9"), exist_ok=True) return True if not e["cli"]: return False code, text = run_tool([e["cli"], "--install-extension", ext_id, "--force"], 240) if code != 0: log("extension %s in %s failed: %s" % (ext_id, e["name"], text[-300:])) return code == 0 # ── actions ────────────────────────────────────────────────────────────────── S = {"key": "", "model": "", "fast": "", "catalog": None, "economy": False, "next": [], "notes": [], "cline": []} def model_ids(): ids = [S["model"]] for mid, _ in CURATED: if mid not in ids and (S["catalog"] is None or mid in S["catalog"]): ids.append(mid) return ids def roo_import_file(): return os.path.join(NEXUS_DIR, "roo-code-settings.json") def write_roo_import(): cfg = {"providerProfiles": {"currentApiConfigName": "NEXUS_AI", "apiConfigs": {"NEXUS_AI": { "apiProvider": "openai", "openAiBaseUrl": OPENAI_BASE, "openAiApiKey": S["key"], "openAiModelId": S["model"], "openAiStreamingEnabled": True, "id": "nexus-ai"}}, "modeApiConfigs": {}}, "globalSettings": {}} if S["economy"]: cfg["globalSettings"] = {"autoCondenseContext": True, "autoCondenseContextPercent": 60, "maxOpenTabsContext": 5, "maxWorkspaceFiles": 100, "maxDiagnosticMessages": 20} save_text(roo_import_file(), json.dumps(cfg, indent=2, ensure_ascii=False) + "\n") def apply_roo(e): write_roo_import() if has_ext(e, "rooveterinaryinc.roo-cline"): ok("%s: Roo Code уже установлен" % e["name"]) else: step("%s: устанавливаю Roo Code (до 1-2 минут)..." % e["name"]) if install_ext(e, "RooVeterinaryInc.roo-cline"): ok("%s: Roo Code установлен" % e["name"]) else: warn("%s: не удалось поставить Roo Code автоматически - поставьте вручную (Extensions -> Roo Code). Настройки подхватятся сами." % e["name"]) S["notes"].append("%s: установите расширение Roo Code - ключ подключится сам." % e["name"]) update_json(os.path.join(e["user"], "settings.json"), {"roo-cline.autoImportSettingsPath": roo_import_file()}) ok("%s: Roo Code подключён к NEXUS_AI (модель %s)" % (e["name"], S["model"])) S["next"].append("%s: перезапустите редактор -> слева иконка Roo Code (кенгуру) -> пишите задачу." % e["name"]) def apply_claude_ext(e): path = os.path.join(e["user"], "settings.json") cur = read_json(path) ours = {"ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL", "ANTHROPIC_API_KEY", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"} lst = [x for x in (cur.get("claudeCode.environmentVariables") or []) if not (isinstance(x, dict) and x.get("name") in ours)] lst += [{"name": "ANTHROPIC_BASE_URL", "value": SITE}, {"name": "ANTHROPIC_AUTH_TOKEN", "value": S["key"]}, {"name": "ANTHROPIC_MODEL", "value": S["model"]}, {"name": "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "value": "1"}] update_json(path, {"claudeCode.environmentVariables": lst, "claudeCode.disableLoginPrompt": True}) ok("%s: расширение Claude Code подключено" % e["name"]) RC_FILES = [".zshrc", ".bashrc", ".bash_profile", ".profile", ".zprofile"] def update_shell_exports(values): # old manual setups export ANTHROPIC_* in shell rc files; those beat settings.json, so keep them in sync for rc in RC_FILES: path = os.path.join(HOME, rc) if not os.path.isfile(path): continue with open(path, encoding="utf-8", errors="replace") as f: text = f.read() new = text for name, value in values.items(): new = re.sub(r'(?m)^(\s*export\s+%s=).*$' % re.escape(name), lambda m: m.group(1) + '"%s"' % value, new) if new != text: save_text(path, new) ok("обновлены переменные в ~/%s" % rc) def apply_claude(): path = os.path.join(HOME, ".claude", "settings.json") cur = read_json(path) env = dict(cur.get("env") or {}) env.pop("ANTHROPIC_API_KEY", None) env.update({"ANTHROPIC_BASE_URL": SITE, "ANTHROPIC_AUTH_TOKEN": S["key"], "ANTHROPIC_MODEL": S["model"], "ANTHROPIC_DEFAULT_HAIKU_MODEL": S["fast"], "ANTHROPIC_SMALL_FAST_MODEL": S["fast"], "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1"}) update_json(path, {"env": env}) state = os.path.join(HOME, ".claude.json") if not os.path.exists(state): save_text(state, '{\n "hasCompletedOnboarding": true\n}\n') else: with open(state, encoding="utf-8") as f: t = f.read() if re.search(r'"hasCompletedOnboarding"\s*:\s*false', t): save_text(state, re.sub(r'"hasCompletedOnboarding"\s*:\s*false', '"hasCompletedOnboarding": true', t, count=1)) elif '"hasCompletedOnboarding"' not in t and "{" in t: i = t.index("{") save_text(state, t[:i + 1] + '\n "hasCompletedOnboarding": true,' + t[i + 1:]) update_shell_exports({"ANTHROPIC_BASE_URL": SITE, "ANTHROPIC_AUTH_TOKEN": S["key"], "ANTHROPIC_MODEL": S["model"]}) if os.environ.get("ANTHROPIC_API_KEY"): warn("В окружении задана переменная ANTHROPIC_API_KEY (другой ключ). Если Claude Code спросит, использовать ли её, ответьте No.") ok("Claude Code подключён (модель %s, фоновая %s)" % (S["model"], S["fast"])) S["next"].append("Claude Code: откройте НОВОЕ окно терминала в папке проекта -> claude") def apply_codex(): d = os.environ.get("CODEX_HOME") if (os.environ.get("CODEX_HOME") and not TEST_ROOT) else os.path.join(HOME, ".codex") path = os.path.join(d, "config.toml") lines = [] if os.path.exists(path): with open(path, encoding="utf-8") as f: lines = f.read().splitlines() top, rest, in_tables, skip = [], [], False, False for l in lines: if re.match(r"^\s*\[", l): in_tables = True skip = bool(re.match(r'^\s*\[\s*model_providers\s*\.\s*"?tokine"?\s*(\]|\.)', l)) if not in_tables: if re.match(r"^\s*(model|model_provider)\s*=", l): continue top.append(l) elif not skip: rest.append(l) while top and not top[0].strip(): top.pop(0) while top and not top[-1].strip(): top.pop() while rest and not rest[-1].strip(): rest.pop() out = ['model = "%s"' % S["model"], 'model_provider = "tokine"'] + top if rest: out += [""] + rest out += ["", "[model_providers.tokine]", 'name = "NEXUS_AI Gateway"', 'base_url = "%s"' % OPENAI_BASE, 'wire_api = "responses"', 'experimental_bearer_token = "%s"' % S["key"]] save_text(path, "\n".join(out) + "\n") ok("Codex CLI подключён (модель %s)" % S["model"]) S["next"].append("Codex: откройте НОВОЕ окно терминала в папке проекта -> codex") def apply_qwen(): path = os.path.join(HOME, ".qwen", "settings.json") cur = read_json(path) providers = dict(cur.get("modelProviders") or {}) lst = [x for x in (providers.get("openai") or []) if not str((x or {}).get("baseUrl", "")).startswith(SITE)] lst += [{"id": mid, "name": mid + " (NEXUS_AI)", "baseUrl": OPENAI_BASE, "envKey": "NEXUS_API_KEY"} for mid in model_ids()] providers["openai"] = lst env = dict(cur.get("env") or {}); env["NEXUS_API_KEY"] = S["key"] security = dict(cur.get("security") or {}); auth = dict(security.get("auth") or {}); auth["selectedType"] = "openai"; security["auth"] = auth model = dict(cur.get("model") or {}); model["name"] = S["model"] update_json(path, {"modelProviders": providers, "env": env, "security": security, "model": model}) ok("Qwen Code подключён (модель %s)" % S["model"]) S["next"].append("Qwen Code: откройте НОВОЕ окно терминала -> qwen") def apply_opencode_like(path, title): cur = read_json(path) values = {} if title == "OpenCode" and "$schema" not in cur: values["$schema"] = "https://opencode.ai/config.json" prov = dict(cur.get("provider") or {}) prov["nexus"] = {"npm": "@ai-sdk/openai-compatible", "name": "NEXUS_AI", "options": {"baseURL": OPENAI_BASE, "apiKey": S["key"]}, "models": {mid: {"name": mid} for mid in model_ids()}} values["provider"] = prov values["model"] = "nexus/" + S["model"] if not cur.get("small_model"): values["small_model"] = "nexus/" + S["fast"] update_json(path, values) ok("%s подключён (модель nexus/%s)" % (title, S["model"])) def apply_opencode(): apply_opencode_like(os.path.join(HOME, ".config", "opencode", "opencode.json"), "OpenCode") S["next"].append("OpenCode: в папке проекта -> opencode (модели в /models, провайдер NEXUS_AI)") def apply_kilo(): apply_opencode_like(os.path.join(HOME, ".config", "kilo", "kilo.json"), "Kilo Code") S["next"].append("Kilo Code: перезапустите редактор - в списке моделей появится провайдер NEXUS_AI") def install_npm(package, title): npm = find_cli("npm") if not npm: fail("%s: нужен Node.js. %s" % (title, "brew install node" if IS_MAC else "https://nodejs.org (или nvm)")) return False if TEST_ROOT: return True step("%s: устанавливаю %s (до 2 минут)..." % (title, package)) code, text = run_tool([npm, "install", "-g", package], 420) if code != 0: hint = " Похоже, нужны права: sudo npm install -g %s" % package if "EACCES" in text else "" fail("%s: npm не смог установить пакет.%s" % (title, hint)) return False ok("%s установлен" % title) return True # ── main flow ──────────────────────────────────────────────────────────────── def read_key(): k = (os.environ.get("NEXUS_KEY") or "").strip() for _ in range(4): if not k: if AUTO: fail("Ключ не передан (NEXUS_KEY)."); return None k = ask(" Вставьте ваш ключ sk-... и нажмите Enter:") if not re.match(r"^sk-[A-Za-z0-9_\-]{16,}$", k): fail("Это не похоже на ключ NEXUS_AI (он начинается с sk-)."); k = ""; continue state = check_key(k) if state == "bad": fail("Ключ не найден или отключён. Проверьте, что скопировали его целиком.") if AUTO: return None k = ""; continue return k return None def choose_model(): code, data = api_get("/models") S["catalog"] = {str(m.get("id")): m.get("multiplier") for m in data.get("data", [])} if code == 200 and data else None opts = [(mid, note) for mid, note in CURATED if S["catalog"] is None or mid in S["catalog"]] want = (os.environ.get("NEXUS_MODEL") or "").strip() if want: S["model"] = want else: say(" Модель по умолчанию (её можно менять в любой момент в самой программе):") for i, (mid, note) in enumerate(opts, 1): mult = S["catalog"].get(mid) if S["catalog"] else None m = ("x%s" % ("%.2f" % float(mult)).rstrip("0").rstrip(".")) if mult is not None else "" say(" %d) %-22s %-6s %s" % (i, mid, m, note)) say(" xN - коэффициент списания: сколько токенов баланса уходит на 1 токен диалога.", "90") a = ask(" Номер модели [Enter = 1]:", "1") if a.isdigit() and 1 <= int(a) <= len(opts): S["model"] = opts[int(a) - 1][0] elif re.match(r"^[a-z0-9][a-z0-9.\-:/]+$", a): S["model"] = a else: S["model"] = opts[0][0] S["fast"] = next((f for f in FAST if S["catalog"] is None or f in S["catalog"]), S["model"]) ok("Модель: " + S["model"]) def build_plan(): plan = [] def add(label, sel, action, arg=None): plan.append({"label": label, "sel": sel, "action": action, "arg": arg}) detect_editors() if any(e["found"] for e in EDITORS): add("Экономный режим Roo Code: сжатие контекста на 60% и без лишних данных в запросах (тратит меньше токенов)", True, "economy") has_kilo = False for e in EDITORS: if not e["found"]: continue add("%s: агент Roo Code (как Cline) - установить и подключить" % e["name"], True, "roo", e) if has_ext(e, "anthropic.claude-code"): add("%s: расширение Claude Code - подключить" % e["name"], True, "claude-ext", e) if has_ext(e, "kilocode.kilo-code"): has_kilo = True if has_ext(e, "saoudrizwan.claude-dev"): S["cline"].append(e["name"]) det = {"node": find_cli("node"), "claude": find_cli("claude") or (None if TEST_ROOT else (os.path.join(HOME, ".local", "bin", "claude") if os.path.exists(os.path.join(HOME, ".local", "bin", "claude")) else None)), "codex": find_cli("codex"), "qwen": find_cli("qwen"), "opencode": find_cli("opencode"), "kilo": find_cli("kilo")} for k, title, pkg in (("claude", "Claude Code (терминал)", "claude"), ("codex", "Codex CLI", "codex"), ("qwen", "Qwen Code", "qwen")): if det[k]: add("%s - подключить" % title, True, pkg) else: add("%s - установить и подключить" % title, False, pkg + "-install") if det["opencode"] or os.path.isdir(os.path.join(HOME, ".config", "opencode")): add("OpenCode - подключить", True, "opencode") else: add("OpenCode - установить и подключить", False, "opencode-install") if det["kilo"] or has_kilo or os.path.isdir(os.path.join(HOME, ".config", "kilo")): add("Kilo Code - подключить", True, "kilo") S["det"] = det return plan def show_detected(): found = [e["name"] for e in EDITORS if e["found"]] missing = [e["name"] for e in EDITORS if not e["found"]] for k, title in (("claude", "Claude Code"), ("codex", "Codex CLI"), ("qwen", "Qwen Code"), ("opencode", "OpenCode"), ("node", "Node.js")): (found if S["det"][k] else missing).append(title) say(" Найдено: " + (", ".join(found) or "-"), "32") say(" Не найдено: " + (", ".join(missing) or "-"), "90") say("") def choose_items(plan): if AUTO: return None while True: say(" Что настроить:") for i, it in enumerate(plan, 1): say(" %s %2d. %s" % ("[x]" if it["sel"] else "[ ]", i, it["label"]), None if it["sel"] else "90") say(" Enter - выполнить отмеченное | номера через пробел - отметить/снять | A - всё | R - откатить прошлую установку | Q - выход", "90") a = ask(" >", "").lower() if a == "": return None if a in ("q", "0", "й"): return "quit" if a in ("r", "к"): return "restore" if a in ("a", "ф"): for it in plan: it["sel"] = True continue for tok in re.split(r"[\s,;]+", a): if tok.isdigit() and 1 <= int(tok) <= len(plan): plan[int(tok) - 1]["sel"] = not plan[int(tok) - 1]["sel"] say("") ORDER = ["claude-install", "codex-install", "qwen-install", "opencode-install", "roo", "claude-ext", "claude", "codex", "qwen", "opencode", "kilo"] def run_plan(plan): global EXIT sel = [it for it in plan if it["sel"]] S["economy"] = any(it["action"] == "economy" for it in sel) sel = sorted([it for it in sel if it["action"] != "economy"], key=lambda it: ORDER.index(it["action"])) acts = { "claude-install": lambda it: install_npm("@anthropic-ai/claude-code", "Claude Code") and apply_claude(), "codex-install": lambda it: install_npm("@openai/codex", "Codex CLI") and apply_codex(), "qwen-install": lambda it: install_npm("@qwen-code/qwen-code", "Qwen Code") and apply_qwen(), "opencode-install": lambda it: install_npm("opencode-ai", "OpenCode") and apply_opencode(), "roo": lambda it: apply_roo(it["arg"]), "claude-ext": lambda it: apply_claude_ext(it["arg"]), "claude": lambda it: apply_claude(), "codex": lambda it: apply_codex(), "qwen": lambda it: apply_qwen(), "opencode": lambda it: apply_opencode(), "kilo": lambda it: apply_kilo(), } for it in sel: try: acts[it["action"]](it) log("done: " + it["action"]) except Exception as ex: fail("%s: %s" % (it["label"], ex)) log("failed: %s: %s" % (it["action"], ex)) EXIT = 1 return len(sel) def main(): global EXIT say("") say(" ==========================================================", "33") say(" NEXUS_AI - подключение программ в 1 клик") say(" ==========================================================", "33") say(" Шлюз: " + SITE, "90") say("") if os.environ.get("NEXUS_ACTION") == "restore": restore(); return S["key"] = read_key() if not S["key"]: fail("Без действующего ключа настроить нельзя. Ключ приходит в чат заказа FunPay.") EXIT = 2; return say("") choose_model() say("") plan = build_plan() show_detected() choice = choose_items(plan) if choice == "quit": say(" Выход без изменений."); EXIT = 3; return if choice == "restore": restore(); return say("") n = run_plan(plan) say("") if n == 0: warn("Ничего не выбрано - изменений нет."); EXIT = 3; return if S["cline"]: S["notes"].append("Cline (%s) хранит ключ в защищённом хранилище редактора - его нельзя заполнить снаружи. " "В Cline: шестерёнка -> OpenAI Compatible, Base URL %s, ваш ключ. Или пользуйтесь Roo Code - он уже настроен." % (", ".join(S["cline"]), OPENAI_BASE)) say(" ==========================================================", "33") say(" Готово! Что дальше:" if EXIT == 0 else " Готово с ошибками (см. [X] выше). Что дальше:", "32" if EXIT == 0 else "33") for x in S["next"]: say(" * " + x) for x in S["notes"]: say(" ! " + x, "33") say(" Баланс и расход: %s/stats.html" % SITE, "90") if os.path.isdir(RUN_DIR): say(" Копии изменённых файлов: %s (откат - запустите ещё раз и введите R)" % RUN_DIR, "90") say(" ==========================================================", "33") log("installed: model=%s items=%d exit=%d" % (S["model"], n, EXIT)) try: main() except KeyboardInterrupt: say(""); fail("Прервано."); EXIT = 130 except Exception as ex: fail(str(ex)); log("fatal: %s" % ex); EXIT = 1 sys.exit(EXIT) NEXUS_PY if [ -t 0 ] || [ -n "${NEXUS_TEST_ROOT:-}" ]; then "$PY" "$TMP_PY" elif (exec /dev/null; then "$PY" "$TMP_PY"