#!/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 # ставит агент Cline уже подключённым к NEXUS AI. Перед каждым изменением копия файла сохраняется в # ~/.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" FILES_SITE = SITE # installer files always come from here, even when the API goes through a faster server 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: raise EOFError("Ввод закрыт. Запустите установщик в терминале или передайте NEXUS_KEY и NEXUS_AUTO=1.") from None 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("Готово. Установленные расширения (Cline) не удалялись - их можно удалить в редакторе.") 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 = (SITE + path) if path.startswith("/api/") else (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: try: return e.code, json.loads(e.read().decode("utf-8")) # error body says why (expired / inactive) except Exception: 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) try: return code, json.loads(body) except Exception: return code, None except Exception: return 0, None # Public NEXUS addresses. The main one has the real domain; Moscow works from Russia without a VPN, the US one suits a VPN. SERVERS = [("main", "Основной (nexusallin.store)", "https://nexusallin.store"), ("ru", "Москва (без VPN)", "https://178-236-242-96.nip.io"), ("us", "США (под VPN)", "https://34-227-208-238.nip.io")] def measure_server(url): # best of three /health round trips, None when the server does not answer best = None for _ in range(3): t = time.monotonic() try: with urllib.request.urlopen(url + "/health", timeout=5) as r: r.read() except urllib.error.HTTPError: pass except Exception: try: # Python without CA certificates (python.org builds on macOS): curl if subprocess.run(["curl", "-fsS", "-m", "5", "-o", os.devnull, url + "/health"], capture_output=True, timeout=8).returncode: continue except Exception: continue ms = int((time.monotonic() - t) * 1000) best = ms if best is None or ms < best else best return best def choose_server(): # only for the public addresses: tests and a custom NEXUS_SITE stay as given; NEXUS_SERVER=main|ru|us forces one global SITE, OPENAI_BASE if TEST_ROOT or not re.match(r"^https://(nexusallin\.store|[0-9-]+\.nip\.io)$", SITE): return step("Выбираю самый быстрый сервер...") res = [(sid, name, url, measure_server(url)) for sid, name, url in SERVERS] say(" " + " | ".join("%s: %s" % (n, "%d мс" % ms if ms is not None else "нет связи") for _, n, _, ms in res), "90") alive = sorted([r for r in res if r[3] is not None], key=lambda r: r[3]) pick = next((r for r in alive if r[0] == os.environ.get("NEXUS_SERVER")), None) if not pick: if not alive: warn("Ни один сервер не ответил - оставляю %s. Проверьте интернет." % SITE) return pick = alive[0] main = next((r for r in alive if r[0] == "main"), None) # the main address stays unless another one is clearly faster if main and (main[3] <= pick[3] * 1.3 or main[3] - pick[3] < 40): pick = main SITE, OPENAI_BASE = pick[2], pick[2] + "/v1" ok("Сервер: %s - %d мс" % (pick[1], pick[3])) def test_model(): # one short request through the configured address, key and model: shows that answers really come step("Проверяю связь: короткий запрос к %s..." % S["model"]) body = json.dumps({"model": S["model"], "max_tokens": 16, "stream": False, "messages": [{"role": "user", "content": "Reply with one word: ok"}]}).encode() t = time.monotonic() why = None try: req = urllib.request.Request(OPENAI_BASE + "/chat/completions", data=body, method="POST", headers={"Authorization": "Bearer " + S["key"], "Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=90) as r: r.read() except urllib.error.HTTPError as e: why = "HTTP %d" % e.code except Exception as ex: try: # Python without CA certificates: curl out = subprocess.run(["curl", "-sS", "-m", "90", "-o", os.devnull, "-w", "%{http_code}", "-H", "Authorization: Bearer " + S["key"], "-H", "Content-Type: application/json", "--data-binary", "@-", OPENAI_BASE + "/chat/completions"], input=body, capture_output=True, timeout=95).stdout.decode().strip() why = None if out == "200" else "HTTP " + (out or "?") except Exception: why = str(ex) if why: warn("Проверочный запрос не прошёл (%s). Программы настроены; если ответов нет - проверьте баланс: %s/stats.html" % (why, SITE)) else: ok("Связь есть: %s ответила за %.1f с" % (S["model"], time.monotonic() - t)) def fmt(n): return "{:,}".format(int(n)).replace(",", " ") def check_key(key): code, b = api_get("/balance", key) if code == 200 and isinstance(b, dict) and b: if b.get("is_active") is False: return "bad" 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 "expired" if "expired" in json.dumps(b or {}) else "bad" warn("Не удалось проверить ключ (нет связи с %s). Продолжаю." % SITE) return "offline" CURATED = [ ("deepseek-v4-flash", "быстрая, для кода и агентов"), ("claude-haiku-5.5", "модель Anthropic: тексты и код"), ("gpt-6-luna", "модель OpenAI"), ("minimax-m2.7", "с рассуждениями"), ("glm-5.3-flash", "быстрая и дешёвая"), ("gemini-2.5-flash", "видит изображения"), ] FAST = ["claude-haiku-5.5", "glm-5.3-flash", "deepseek-v4-flash"] # ── detection ──────────────────────────────────────────────────────────────── # ── terminal menu: arrows, Space, A, Enter ────────────────────────────────── def tui_ok(): # a real terminal only: tests, NEXUS_AUTO and piped input keep the numbered text menu if AUTO or TEST_ROOT or not sys.stdin.isatty() or not sys.stdout.isatty(): return False try: import termios, tty # noqa: F401 except ImportError: return False return shutil.get_terminal_size((80, 24)).lines >= 12 def read_menu_key(): import select, termios, tty fd = sys.stdin.fileno() old = termios.tcgetattr(fd) try: tty.setraw(fd) ch = os.read(fd, 1) if not ch: raise EOFError("Ввод терминала закрыт. Запустите установщик ещё раз в терминале.") if ch == b"\x03": raise KeyboardInterrupt if ch == b"\x1b": seq = b"" while select.select([fd], [], [], 0.05)[0]: part = os.read(fd, 1) if not part: raise EOFError("Ввод терминала закрыт. Запустите установщик ещё раз в терминале.") seq += part return {b"[A": "up", b"[B": "down", b"[5~": "pgup", b"[6~": "pgdn", b"[H": "home", b"[F": "end", b"OA": "up", b"OB": "down"}.get(seq, "esc" if not seq else "") if ch in (b"\r", b"\n"): return "enter" if ch == b" ": return "space" try: return ch.decode("utf-8").lower() except UnicodeDecodeError: rest = b"" while select.select([fd], [], [], 0.02)[0] and len(rest) < 3: part = os.read(fd, 1) if not part: raise EOFError("Ввод терминала закрыт. Запустите установщик ещё раз в терминале.") rest += part return (ch + rest).decode("utf-8", "replace").lower() finally: termios.tcsetattr(fd, termios.TCSADRAIN, old) def menu(title, rows, hint, extra=()): """rows: dicts with label, on, kind ('check' | 'radio'). Returns 'ok' | 'quit' | 'restore'.""" size = shutil.get_terminal_size((80, 24)) n = len(rows) view = max(4, min(n, size.lines - 8)) width = max(40, size.columns - 1) fit = lambda t: (t + " " * width)[:width] pos = first = 0 drawn = False out = sys.stdout out.write("\033[?25l") try: while True: if pos < first: first = pos elif pos >= first + view: first = pos - view + 1 if drawn: out.write("\033[%dF" % (view + 2)) drawn = True out.write("\033[2K" + c("97", fit(" " + title)) + "\n") last = min(n, first + view) for i in range(first, last): r = rows[i] box = ("(*)" if r["on"] else "( )") if r["kind"] == "radio" else ("[x]" if r["on"] else "[ ]") line = fit(" %s %s %s" % (">" if i == pos else " ", box, r["label"])) out.write("\033[2K" + ("\033[30;43m" + line + "\033[0m" if i == pos else c("97" if r["on"] else "37", line)) + "\n") for _ in range(last - first, view): out.write("\033[2K\n") more = " [%d-%d из %d]" % (first + 1, last, n) if n > view else "" out.write("\033[2K" + c("90", fit(" " + hint + more)) + "\n") out.flush() k = read_menu_key() if k == "up": pos = (pos - 1) % n elif k == "down": pos = (pos + 1) % n elif k == "pgup": pos = max(0, pos - view) elif k == "pgdn": pos = min(n - 1, pos + view) elif k == "home": pos = 0 elif k == "end": pos = n - 1 elif k in ("space", "enter"): r = rows[pos] if r["kind"] == "radio": for x in rows: if x["kind"] == "radio": x["on"] = False r["on"] = True elif k == "space": r["on"] = not r["on"] if k == "enter": return "ok" elif k == "esc" or (k in ("q", "й") and "quit" in extra): return "quit" elif k in ("a", "ф"): checks = [x for x in rows if x["kind"] == "check"] if len(checks) > 1: turn_on = any(not x["on"] for x in checks) for x in checks: x["on"] = turn_on elif k in ("r", "к") and "restore" in extra: return "restore" finally: out.write("\033[?25h") out.flush() 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): """Run a tool, print a dot every 3 s while it works (installing an extension into Cursor takes 1-2 min and a silent terminal looks frozen), return (exit code, output).""" try: proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) except Exception as ex: return -1, str(ex) start, dots = time.time(), False while True: try: out, _ = proc.communicate(timeout=3) break except subprocess.TimeoutExpired: if time.time() - start >= timeout: proc.kill() if dots: print("") return -1, "timeout" if not dots: sys.stdout.write(" ") dots = True sys.stdout.write(".") sys.stdout.flush() if dots: print(" %d s" % int(time.time() - start)) return proc.returncode, (out or "").strip() 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) # editors print warnings to stderr and sometimes exit non-zero after a successful install if code == 0 or re.search(r"successfully installed|already installed", text, re.I) or has_ext(e, ext_id.lower()): return True log("extension %s in %s failed (code %s): %s" % (ext_id, e["name"], code, text[-300:])) return False # ── actions ────────────────────────────────────────────────────────────────── S = {"key": "", "model": "", "fast": "", "catalog": None, "next": [], "notes": [], "cline_done": False} S["all_models"] = os.environ.get("NEXUS_ALL_MODELS", "1") != "0" def catalog_order(): cat = S["catalog"] or {} return sorted(cat, key=lambda k: (float(cat[k] or 1), k)) def model_ids(): # the main model first, then the recommended ones; with "all models" every catalogue model follows (cheapest first) 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) if S["all_models"]: ids += [m for m in catalog_order() if m not in ids] return ids def all_model_ids(): return model_ids() def model_label(mid): # "Claude Sonnet 5.5 · ×8": the catalogue name and multiplier, as on the pricing page cat = S["catalog"] or {} name = (S.get("names") or {}).get(mid, mid) return name + (" · ×%g" % float(cat[mid]) if cat.get(mid) is not None else "") def model_ctx(mid): return (S.get("ctx") or {}).get(mid, 128000) def opencode_models(): return {mid: {"name": model_label(mid)} for mid in all_model_ids()} # Cline 4 keeps its providers in files under ~/.cline/data/settings, shared by the VS Code / Cursor / Windsurf extension, # JetBrains and the Cline CLI: models.json declares the "NEXUS AI" provider with every model, providers.json holds its key # and makes it the current provider. Other providers the user has there are kept. def cline_data_dir(): if TEST_ROOT: return os.path.join(HOME, ".cline", "data") if os.environ.get("CLINE_DATA_DIR"): return os.environ["CLINE_DATA_DIR"] if os.environ.get("CLINE_DIR"): return os.path.join(os.environ["CLINE_DIR"], "data") return os.path.join(HOME, ".cline", "data") def apply_cline(): if S["cline_done"]: return d = os.path.join(cline_data_dir(), "settings") models = {mid: {"id": mid, "name": model_label(mid), "contextWindow": model_ctx(mid), "maxInputTokens": model_ctx(mid), "capabilities": ["streaming", "tools", "images"]} for mid in all_model_ids()} mpath = os.path.join(d, "models.json") cur = read_json(mpath) or {} provs = dict(cur.get("providers") or {}) provs["nexus"] = {"provider": {"name": "NEXUS AI", "baseUrl": OPENAI_BASE, "defaultModelId": S["model"], "protocol": "openai-chat", "client": "openai-compatible"}, "models": models} update_json(mpath, {"version": 1, "providers": provs}) ppath = os.path.join(d, "providers.json") cur = read_json(ppath) or {} provs = dict(cur.get("providers") or {}) settings = {"provider": "nexus", "apiKey": S["key"], "model": S["model"], "baseUrl": OPENAI_BASE} old = provs.get("nexus") or {} # nothing changed: keep the old time so a repeated run leaves the file as it is stamp = old.get("updatedAt") if old.get("settings") == settings and old.get("updatedAt") else time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()) provs["nexus"] = {"settings": settings, "updatedAt": stamp, "tokenSource": "manual"} update_json(ppath, {"version": 1, "lastUsedProvider": "nexus", "modes": cur.get("modes") or {}, "providers": provs}) S["cline_done"] = True ok("Cline подключён к NEXUS AI (модель %s; остальные модели - в списке провайдера NEXUS AI)" % S["model"]) def apply_cline_editor(e): if has_ext(e, "saoudrizwan.claude-dev"): ok("%s: Cline уже установлен" % e["name"]) else: step("%s: устанавливаю Cline (до 1-2 минут)..." % e["name"]) if install_ext(e, "saoudrizwan.claude-dev"): ok("%s: Cline установлен" % e["name"]) else: warn("%s: не удалось поставить Cline автоматически - поставьте вручную (Extensions -> Cline). Настройки подхватятся сами." % e["name"]) S["notes"].append("%s: установите расширение Cline - NEXUS AI подключится сам." % e["name"]) apply_cline() if e["name"] == "Cursor": # Cursor 3 opens in the Agents window, where third-party extensions are not shown S["next"].append("Cursor: перезапустите Cursor и откройте окно редактора (File -> Open Editor Window или папку проекта). " "Cline - значок слева; провайдер NEXUS AI уже выбран. Нет значка: Cmd/Ctrl+Shift+P -> Cline: Open In New Tab.") else: S["next"].append("%s: перезапустите редактор -> слева значок Cline -> пишите задачу (провайдер NEXUS AI уже выбран)." % 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 {}) # entries of every NEXUS address are replaced (a server switch used to leave the old ones as duplicates) ours = tuple([u for _, _, u in SERVERS] + [SITE]) lst = [x for x in (providers.get("openai") or []) if not str((x or {}).get("baseUrl", "")).startswith(ours)] 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 in ("OpenCode", "NEXUS CLI") 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": opencode_models()} 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)") # NEXUS CLI (terminal agent) = a pinned release of its open-source engine + our setup in ~/.nexus-ai/code. # The engine is the binary from the npm registry (checked by sha512), kept as ~/.nexus-ai/code/engine/nexus-cli: # no Node.js needed, no update prompts, other tools on the computer untouched. Files come from # SITE/install/nexus-code/; the commands go to ~/.nexus-ai/bin (added to PATH in the shell start files). NEXUS_ENGINE_VERSION = "2.2.1" NEXUS_ENGINE_SHA = { "darwin-arm64": "sha512-6MtEHlOHGnl/LDKD0flzN296ZTKwlT+0wiKkxYi/mLi+Tg/1iFq8KH6t08lTq6gF7hlFR9u14U5J7iE6zULJjw==", "darwin-x64": "sha512-SP7uJ5RLDgATZEOiO618dZvjFZRHDZM5B6GqW+xOVokQMbDUbFq/2lw52ZqQchjI9LRCAmDCArSpIx6a3jhPPQ==", "darwin-x64-baseline": "sha512-NsSGUnIDZjMxnZ+dO8kDpOGGhB8F/BnrPvKBgEKwYxTQspex1pD4o/9tHsquBlm0FdOuzR9EDAD3a58pE6klEQ==", "linux-x64": "sha512-ZqNT3vfRDp+/o7plz7X5+Vb5abnxnuflRQwQ2t2sXIaoAd8Lja24D+7z8ruZRN9XfZabUfvBYuDLx4xBuuanfg==", "linux-x64-baseline": "sha512-qUwfjEJ5Z3KK+DAMyo1xzz4q/evLiF2DsWlp6JvYFbQx1RjFWdcoe8v7DrdBt6+8D3yt4RTLO71ObBKFLJSPIA==", "linux-arm64": "sha512-BDCyHloCvB1rFpD9Uhzvv6EZx7JYJrNTA1BSzt4SnDwultwYvTvwZ0ujAzARJNHW2V8LGl4HTUwqjq9QOFxfUA==", "linux-x64-musl": "sha512-n2G9eiNKD+VJcOZiqObEmspZ9+cbPPijgOQDE27ap15jmWqx1Ih2cbYSlrB8hF3skP7NTMXPkE0rSFvXRWcMow==", "linux-x64-baseline-musl": "sha512-YHY2AHDCUpxYhcr7LahuCR6HX+JMhfVJOyLVspkVYf+/m0/agVrBFqWkK6lZ3zEKn7td5sueXOWWaZdvUI804g==", "linux-arm64-musl": "sha512-n29bje43ByTymCrWBoB4p9MLkmkzdeShNn8S9HSgkQzfSSlSFaYgfzG6I7wRjcYCe2u4fSEK6mkA4TB/xxgvMg==", } NEXUS_ENGINE_MIRRORS = ["https://registry.npmjs.org", "https://registry.npmmirror.com"] def _cmd_out(args): try: r = subprocess.run(args, capture_output=True, timeout=5) return (r.stdout + r.stderr).decode("utf-8", "replace") except Exception: return "" def engine_platform(): import platform m = platform.machine().lower() arm = m in ("arm64", "aarch64") or (IS_MAC and _cmd_out(["sysctl", "-n", "sysctl.proc_translated"]).strip() == "1") arch = "arm64" if arm else "x64" if IS_MAC: if arch == "x64" and _cmd_out(["sysctl", "-n", "hw.optional.avx2_0"]).strip() != "1": return "darwin-x64-baseline" return "darwin-" + arch musl = os.path.exists("/etc/alpine-release") or "musl" in _cmd_out(["ldd", "--version"]).lower() name = "linux-" + arch if arch == "x64": try: avx2 = re.search(r"(^|\s)avx2(\s|$)", open("/proc/cpuinfo", encoding="utf-8", errors="replace").read(), re.I | re.M) except Exception: avx2 = True if not avx2: name += "-baseline" return name + ("-musl" if musl else "") def _download(url, dest): try: with urllib.request.urlopen(urllib.request.Request(url), timeout=900) as r, open(dest, "wb") as f: shutil.copyfileobj(r, f, 1 << 20) return True except Exception: return subprocess.run(["curl", "-fsSL", "--max-time", "900", "-o", dest, url]).returncode == 0 def install_engine(): import base64, hashlib, tarfile, tempfile d = os.path.join(HOME, ".nexus-ai", "code", "engine") exe = os.path.join(d, "nexus-cli") stamp = os.path.join(d, "version.txt") plat = engine_platform() want = "%s %s" % (NEXUS_ENGINE_VERSION, plat) if os.path.exists(exe) and os.path.exists(stamp) and open(stamp, encoding="utf-8").read().strip() == want: ok("NEXUS CLI: ядро %s уже установлено" % NEXUS_ENGINE_VERSION) return exe os.makedirs(d, exist_ok=True) if TEST_ROOT: open(exe, "w").write("test engine") open(stamp, "w").write(want) return exe name = "cli-" + plat tmp = tempfile.mkdtemp(prefix="nexus-engine-") try: tgz = os.path.join(tmp, "engine.tgz") good = False for m in NEXUS_ENGINE_MIRRORS: host = m.split("//")[1] step("NEXUS CLI: скачиваю ядро %s (~120 МБ, %s)..." % (NEXUS_ENGINE_VERSION, host)) if not _download("%s/@opencode/%s/-/%s-%s.tgz" % (m, name, name, NEXUS_ENGINE_VERSION), tgz): warn("NEXUS CLI: не скачалось с " + host) continue h = hashlib.sha512() with open(tgz, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) if "sha512-" + base64.b64encode(h.digest()).decode() == NEXUS_ENGINE_SHA.get(plat): good = True break warn("NEXUS CLI: файл повреждён при скачивании (контрольная сумма не сошлась), пробую другой адрес") if not good: fail("NEXUS CLI: не удалось скачать ядро. Проверьте интернет и запустите установщик ещё раз.") return None part = exe + ".new" with tarfile.open(tgz, "r:gz") as t: src = t.extractfile(t.getmember("package/bin/opencode")) with open(part, "wb") as f: shutil.copyfileobj(src, f, 1 << 20) os.chmod(part, 0o755) os.replace(part, exe) # a running NEXUS CLI keeps its old file open; the new one is used next time open(stamp, "w").write(want) ok("NEXUS CLI: ядро %s установлено (контрольная сумма проверена)" % NEXUS_ENGINE_VERSION) return exe finally: shutil.rmtree(tmp, ignore_errors=True) PATH_LINE = 'export PATH="$HOME/.nexus-ai/bin:$PATH" # NEXUS AI: nexus, nexus-code' def add_bin_to_path(): # the line goes to the shell start files that exist (zsh on macOS gets ~/.zshrc even if it is missing) targets = [rc for rc in (".zshrc", ".bashrc", ".bash_profile", ".profile") if os.path.isfile(os.path.join(HOME, rc))] if IS_MAC and ".zshrc" not in targets: targets.append(".zshrc") if not targets: targets = [".profile"] for rc in targets: path = os.path.join(HOME, rc) text = open(path, encoding="utf-8", errors="replace").read() if os.path.exists(path) else "" if ".nexus-ai/bin" in text: continue save_text(path, text + ("" if not text or text.endswith("\n") else "\n") + PATH_LINE + "\n") def site_text(rel): url = FILES_SITE + "/install/nexus-code/" + rel try: with urllib.request.urlopen(urllib.request.Request(url), timeout=60) as r: text = r.read().decode("utf-8-sig") except Exception: out = subprocess.run(["curl", "-fsSL", "--max-time", "60", url], capture_output=True) if out.returncode != 0: raise RuntimeError("не удалось скачать " + url) text = out.stdout.decode("utf-8-sig") if not text.strip() or re.match(r"\s*( %s (баланс: %s balance, докупить: %s topup, обновить: %s update)" % (cmd, cmd, cmd, cmd)) return True # NEXUS AI for Roblox Studio (macOS): the plugin file with the key and server filled in, put into Studio's # local plugins folder; Studio loads it on the next start def roblox_plugins_dir(): return os.path.join(HOME, "Documents", "Roblox", "Plugins") def has_roblox_studio(): if os.path.isdir(roblox_plugins_dir()): return True return IS_MAC and not TEST_ROOT and os.path.isdir("/Applications/RobloxStudio.app") def apply_roblox(): url = FILES_SITE + "/install/roblox/NEXUS-AI.rbxmx" try: with urllib.request.urlopen(urllib.request.Request(url), timeout=60) as r: xml = r.read().decode("utf-8") except Exception: out = subprocess.run(["curl", "-fsSL", "--max-time", "60", url], capture_output=True) if out.returncode != 0: raise RuntimeError("не удалось скачать " + url) xml = out.stdout.decode("utf-8") if not re.match(r"\s*\s*$", xml, re.S): raise RuntimeError("сервер вернул неверный файл плагина Roblox") import xml.etree.ElementTree as ET ET.fromstring(xml) esc = lambda s: str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) xml = xml.replace("__NEXUS_KEY__", esc(S["key"])).replace("__NEXUS_SITE__", esc(SITE)) save_text(os.path.join(roblox_plugins_dir(), "NEXUS-AI.rbxmx"), xml) ok("Roblox Studio: плагин NEXUS AI установлен (ключ уже внутри)") S["next"].append("Roblox Studio: перезапустите Studio -> вкладка Plugins -> кнопка NEXUS AI. На первый запрос Studio спросит доступ к сайту и к скриптам - разрешите.") return True def apply_aider(): # ~/.aider.conf.yml holds plain "key: value" lines; only our three keys are set, everything else stays path = os.path.join(HOME, ".aider.conf.yml") values = {"openai-api-base": OPENAI_BASE, "openai-api-key": S["key"], "model": "openai/" + S["model"]} lines = open(path, encoding="utf-8").read().splitlines() if os.path.exists(path) else [] for k, v in values.items(): line = '%s: "%s"' % (k, v.replace('"', '\\"')) idx = next((i for i, l in enumerate(lines) if re.match(r"^%s\s*:" % re.escape(k), l)), -1) if idx >= 0: lines[idx] = line else: lines.append(line) save_text(path, "\n".join(lines) + "\n") ok("Aider подключён (модель openai/%s)" % S["model"]) S["next"].append("Aider: в папке проекта -> aider (модель из NEXUS AI уже выбрана; другая: aider --model openai/)") CONTINUE_MARK = "# NEXUS AI - written by the NEXUS AI installer; run it again to update. Delete this line to keep your own edits." def apply_continue(): # ~/.continue/config.yaml: a file the user wrote is never touched; ours carries a marker path = os.path.join(HOME, ".continue", "config.yaml") if os.path.exists(path) and not open(path, encoding="utf-8").read().startswith(CONTINUE_MARK): warn("Continue: у вас свой config.yaml - его не трогаю. Добавьте модель вручную: provider openai, apiBase " + OPENAI_BASE) S["notes"].append("Continue: свой config.yaml не изменён. Модель NEXUS AI: provider: openai, apiBase: %s, apiKey: ваш ключ" % OPENAI_BASE) return q = lambda t: json.dumps(str(t), ensure_ascii=False) out = [CONTINUE_MARK, "name: NEXUS AI", "version: 1.0.0", "schema: v1", "models:"] for mid in model_ids(): out += [" - name: " + q(model_label(mid)), " provider: openai", " model: " + q(mid), " apiBase: " + q(OPENAI_BASE), " apiKey: " + q(S["key"]), " roles: [chat, edit, apply]", " capabilities: [tool_use]", " defaultCompletionOptions:", " contextLength: %d" % model_ctx(mid)] save_text(path, "\n".join(out) + "\n") ok("Continue подключён (модели NEXUS AI, основная %s)" % S["model"]) S["next"].append("Continue: перезапустите редактор -> панель Continue -> модели 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 == "expired": fail("Срок действия ключа истёк. Купите пакет или подписку на FunPay с того же аккаунта - токены придут на этот же ключ.") if AUTO: return None k = ""; continue if state == "bad": fail("Ключ не найден или отключён. Проверьте, что скопировали его целиком.") if AUTO: return None k = ""; continue return k return None def choose_model(): # the public catalogue: the real models with names, multipliers and context windows. Not /v1/models: that list # also carries the extra alias ids made for Cursor (sonnet-5, 4.7-grok, ...) code, data = api_get("/api/public/models") items = (data.get("models", []) if isinstance(data, dict) else data) if code == 200 and data else [] items = [m for m in items if isinstance(m, dict) and m.get("id")] S["catalog"] = {str(m["id"]): (m.get("rate") if m.get("rate") is not None else m.get("multiplier")) for m in items} or None S["names"] = {str(m["id"]): str(m["name"]) for m in items if m.get("name")} S["ctx"] = {str(m["id"]): int(m["context_window"]) for m in items if m.get("context_window")} 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 elif tui_ok() and S["catalog"]: notes = dict(CURATED) main = opts[0][0] if opts else catalog_order()[0] rows = [{"kind": "check", "on": S["all_models"], "id": "", "label": "Подключить все модели (%d) - в программах будет весь список" % len(S["catalog"])}] for mid in catalog_order(): m = "x%s" % ("%.2f" % float(S["catalog"][mid] or 1)).rstrip("0").rstrip(".") rows.append({"kind": "radio", "on": mid == main, "id": mid, "label": "%-26s %-6s %s" % (mid, m, notes.get(mid, ""))}) menu("Модели NEXUS AI: выберите основную (её получат Claude Code, Codex, Aider)", rows, "↑↓ · Пробел/Enter - выбрать · xN - списание за 1 токен") S["all_models"] = rows[0]["on"] S["model"] = next((r["id"] for r in rows if r["kind"] == "radio" and r["on"]), main) 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() has_kilo = has_continue = False for e in EDITORS: if not e["found"]: continue add("%s: агент Cline - %s к NEXUS AI" % (e["name"], "подключить" if has_ext(e, "saoudrizwan.claude-dev") else "установить и подключить"), True, "cline", 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, "continue.continue"): has_continue = True 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") if find_cli("aider") or os.path.exists(os.path.join(HOME, ".aider.conf.yml")): add("Aider - подключить", True, "aider") if has_continue or os.path.isdir(os.path.join(HOME, ".continue")): add("Continue (VS Code / JetBrains) - подключить", True, "continue") nexus_code = os.path.exists(os.path.join(HOME, ".nexus-ai", "code", "opencode.json")) add("NEXUS CLI (агент в терминале) - " + ("обновить" if nexus_code else "установить"), bool(det["opencode"] or nexus_code), "nexus-code") studio = has_roblox_studio() if studio or "roblox" in os.environ.get("NEXUS_ONLY", ""): has = os.path.exists(os.path.join(roblox_plugins_dir(), "NEXUS-AI.rbxmx")) add("Roblox Studio: плагин NEXUS AI - " + ("обновить" if has else "установить"), studio, "roblox") add("Проверить связь в конце (короткий запрос, ~30 токенов)", True, "check") if os.environ.get("NEXUS_ONLY"): # a site button for one program: only its items (and the connection check) are ticked only = {x.strip() for x in os.environ["NEXUS_ONLY"].split(",") if x.strip()} | {"check"} for it in plan: it["sel"] = it["action"] in only 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 if tui_ok(): # a fresh menu starts with nothing ticked; the site's one-program buttons (NEXUS_ONLY) keep their ticks if not os.environ.get("NEXUS_ONLY"): for it in plan: it["sel"] = False rows = [{"kind": "check", "on": it["sel"], "label": it["label"], "item": it} for it in plan] res = menu("Что настроить:", rows, "↑↓ · Пробел - отметить · A - всё · Enter - выполнить · R - откат · Q - выход", ("quit", "restore")) for r in rows: r["item"]["sel"] = r["on"] return None if res == "ok" else res 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", "cline", "claude-ext", "claude", "codex", "qwen", "opencode", "kilo", "aider", "continue", "nexus-code", "roblox", "check"] def run_plan(plan): global EXIT sel = [it for it in plan if it["sel"]] sel = sorted(sel, 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(), "cline": lambda it: apply_cline_editor(it["arg"]), "check": lambda it: os.environ.get("NEXUS_SKIP_CHECK") == "1" or test_model(), "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(), "aider": lambda it: apply_aider(), "continue": lambda it: apply_continue(), "nexus-code": lambda it: apply_nexus_code(), "roblox": lambda it: apply_roblox(), } 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([it for it in sel if it["action"] != "check"]) 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 choose_server() 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 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"