diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 91cb56203335..8f13873692de 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -63,6 +63,7 @@ import { registerSetupOpencode } from "./setup-opencode.mjs"; import { registerSetupCline } from "./setup-cline.mjs"; import { registerSetupKilo } from "./setup-kilo.mjs"; import { registerSetupContinue } from "./setup-continue.mjs"; +import { registerSetupCursor } from "./setup-cursor.mjs"; import { registerConnect } from "./connect.mjs"; import { registerTokens } from "./tokens.mjs"; import { registerConfigure } from "./configure.mjs"; @@ -136,6 +137,7 @@ export function registerCommands(program) { registerSetupCline(program); registerSetupKilo(program); registerSetupContinue(program); + registerSetupCursor(program); registerConnect(program); registerTokens(program); registerConfigure(program); diff --git a/bin/cli/commands/setup-cursor.mjs b/bin/cli/commands/setup-cursor.mjs new file mode 100644 index 000000000000..c23b5accdd37 --- /dev/null +++ b/bin/cli/commands/setup-cursor.mjs @@ -0,0 +1,108 @@ +/** + * omniroute setup-cursor — guide Cursor to use OmniRoute. + * + * Cursor stores its OpenAI key + "Override OpenAI Base URL" in an opaque SQLite + * DB (state.vscdb) with no documented stable schema — NOT safe to file-write. + * So this command prints the exact in-app steps (and can list available models + * from /v1/models). Note: Cursor's custom base URL only powers the Chat panel; + * Composer / inline-edit / autocomplete stay on Cursor's own backend. + */ + +import { printHeading, printInfo, printSuccess } from "../io.mjs"; +import { resolveActiveContext } from "../contexts.mjs"; + +function ensureV1(url) { + const s = String(url || "").replace(/\/+$/, ""); + return s.endsWith("/v1") ? s : `${s}/v1`; +} + +/** Resolve apiBase (WITH /v1 — Cursor appends /chat/completions) + apiKey. */ +export function resolveCursorTarget(opts = {}) { + let root; + if (opts.remote) root = String(opts.remote).replace(/\/+$/, ""); + else { + try { + root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl; + } catch { + /* none */ + } + if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`; + } + let apiKey = opts.apiKey ?? opts["api-key"]; + if (!apiKey) { + try { + const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); + apiKey = c?.accessToken || c?.apiKey; + } catch { + /* none */ + } + } + if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || ""; + return { apiBase: ensureV1(root), apiKey }; +} + +/** The step-by-step Cursor UI instructions (pure → testable). */ +export function buildCursorInstructions({ apiBase, models }) { + const lines = [ + "Cursor stores this config in an opaque database, so configure it in the app:", + "", + " 1. Cursor → Settings (Cmd/Ctrl + ,) → Models", + " 2. Enable “Override OpenAI Base URL” and set it to:", + ` ${apiBase} (the /v1 suffix is required)`, + " 3. Set the OpenAI API Key to your OmniRoute key (OMNIROUTE_API_KEY)", + " 4. Add the model name(s) you want under “Models” (Cursor has no auto-discovery):", + ]; + const sample = (models && models.length ? models : ["glm/glm-5.2", "kmc/kimi-k2.7"]).slice(0, 8); + lines.push(` e.g. ${sample.join(", ")}`); + lines.push(" 5. Use the Chat panel (Cmd/Ctrl + L) to verify."); + lines.push(""); + lines.push("⚠ The custom base URL powers the CHAT panel only — Composer, inline edit"); + lines.push(" (Cmd/Ctrl+K) and autocomplete keep using Cursor's own backend."); + return lines.join("\n"); +} + +async function fetchModelIds(apiBase, apiKey) { + try { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch(`${apiBase.replace(/\/v1$/, "")}/v1/models`, { + headers, + signal: AbortSignal.timeout(8000), + }); + if (!res.ok) return []; + const body = await res.json(); + const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; + return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); + } catch { + return []; + } +} + +export async function runSetupCursorCommand(opts = {}) { + const { apiBase, apiKey } = resolveCursorTarget(opts); + printHeading("OmniRoute → Cursor"); + printInfo(`Server: ${apiBase}`); + + let models = []; + const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null; + const ids = await fetchModelIds(apiBase, apiKey); + models = only ? ids.filter((id) => only.some((f) => id.includes(f))) : ids; + + console.log("\n" + buildCursorInstructions({ apiBase, models })); + printSuccess("\nCursor is configured manually (no file written — Cursor's storage is opaque)."); + return 0; +} + +export function registerSetupCursor(program) { + program + .command("setup-cursor") + .description("Print the steps to point Cursor at OmniRoute (chat panel; Cursor config is not file-writable)") + .option("--port ", "Local OmniRoute port (ignored when --remote is set)", "20128") + .option("--remote ", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") + .option("--api-key ", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") + .option("--only ", "Comma-separated substrings — suggest only matching model IDs") + .action(async (opts) => { + const code = await runSetupCursorCommand(opts); + if (code !== 0) process.exit(code); + }); +} diff --git a/docs/guides/REMOTE-MODE.md b/docs/guides/REMOTE-MODE.md index 9a46fcf91888..cb9739c931b6 100644 --- a/docs/guides/REMOTE-MODE.md +++ b/docs/guides/REMOTE-MODE.md @@ -150,6 +150,7 @@ context, or `--remote --api-key `): | Cline | `omniroute setup-cline` | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints the VS Code extension settings to paste (OpenAI-compatible, Base URL **without** `/v1`) | | Kilo Code | `omniroute setup-kilo` | `~/.local/share/kilo/auth.json` (CLI) + VS Code `kilocode.*` settings — OpenAI-compatible, Base URL **with** `/v1` | | Continue | `omniroute setup-continue` | `~/.continue/config.yaml` (VS Code/JetBrains + `cn` CLI) — `provider: openai`, `apiBase` **with** `/v1`, key via `${{ secrets.OMNIROUTE_API_KEY }}` | +| Cursor | `omniroute setup-cursor` | prints the in-app steps (Settings → Models → Override OpenAI Base URL **with** `/v1` + key + model). Cursor config is opaque SQLite — chat panel only | ```bash # OpenCode (openai-compatible provider, all catalog models, remote VPS) diff --git a/tests/unit/cli/setup-cursor.test.ts b/tests/unit/cli/setup-cursor.test.ts new file mode 100644 index 000000000000..23484a4967ad --- /dev/null +++ b/tests/unit/cli/setup-cursor.test.ts @@ -0,0 +1,25 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveCursorTarget, buildCursorInstructions } from "../../../bin/cli/commands/setup-cursor.mjs"; + +test("resolveCursorTarget ensures /v1 (Cursor appends /chat/completions)", () => { + assert.equal(resolveCursorTarget({ remote: "http://vps:20128" }).apiBase, "http://vps:20128/v1"); + assert.equal(resolveCursorTarget({ remote: "http://vps:20128/v1/" }).apiBase, "http://vps:20128/v1"); +}); + +test("resolveCursorTarget: explicit --api-key wins", () => { + assert.equal(resolveCursorTarget({ remote: "http://x:20128", apiKey: "sk-x" }).apiKey, "sk-x"); +}); + +test("buildCursorInstructions includes the base URL, /v1 note, and model samples", () => { + const txt = buildCursorInstructions({ apiBase: "http://vps:20128/v1", models: ["glm/glm-5.2", "kmc/kimi-k2.7"] }); + assert.ok(txt.includes("http://vps:20128/v1")); + assert.ok(txt.includes("Override OpenAI Base URL")); + assert.ok(txt.includes("glm/glm-5.2")); + assert.ok(/chat panel only/i.test(txt)); +}); + +test("buildCursorInstructions falls back to sample models when none given", () => { + const txt = buildCursorInstructions({ apiBase: "http://x/v1", models: [] }); + assert.ok(txt.includes("glm/glm-5.2")); +});