diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 8f13873692..08fa08f0c2 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -64,6 +64,7 @@ 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 { registerSetupRoo } from "./setup-roo.mjs"; import { registerConnect } from "./connect.mjs"; import { registerTokens } from "./tokens.mjs"; import { registerConfigure } from "./configure.mjs"; @@ -138,6 +139,7 @@ export function registerCommands(program) { registerSetupKilo(program); registerSetupContinue(program); registerSetupCursor(program); + registerSetupRoo(program); registerConnect(program); registerTokens(program); registerConfigure(program); diff --git a/bin/cli/commands/setup-roo.mjs b/bin/cli/commands/setup-roo.mjs new file mode 100644 index 0000000000..bc6a00a670 --- /dev/null +++ b/bin/cli/commands/setup-roo.mjs @@ -0,0 +1,172 @@ +/** + * omniroute setup-roo — configure Roo Code (RooVeterinaryInc.roo-cline) for OmniRoute. + * + * Roo is a VS Code extension (Cline fork). Its live settings live in opaque VS + * Code globalStorage, but Roo supports **Settings Import** + an + * `roo-cline.autoImportSettingsPath` (VS Code settings.json) that loads a JSON on + * startup. So this writes a best-effort import file + wires autoImport (when a VS + * Code settings.json exists) + prints the UI steps as the guaranteed path. + * + * OpenAI-compatible: baseUrl WITH /v1 (Roo appends /chat/completions). The model + * must support native OpenAI tool-calling (OmniRoute does). + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import os from "node:os"; +import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; +import { resolveActiveContext } from "../contexts.mjs"; + +function ensureV1(url) { + const s = String(url || "").replace(/\/+$/, ""); + return s.endsWith("/v1") ? s : `${s}/v1`; +} + +/** Resolve baseUrl (WITH /v1) + apiKey from flags → active context → localhost. */ +export function resolveRooTarget(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 { baseUrl: ensureV1(root), apiKey }; +} + +/** Build a Roo Settings-Import document (provider profile, openai-compatible). */ +export function buildRooImport({ baseUrl, apiKey, model }) { + return { + providerProfiles: { + currentApiConfigName: "OmniRoute", + apiConfigs: { + OmniRoute: { + apiProvider: "openai", + openAiBaseUrl: baseUrl, + openAiApiKey: apiKey || "sk_omniroute", + openAiModelId: model, + openAiCustomModelInfo: { supportsImages: false, supportsPromptCache: false }, + }, + }, + }, + }; +} + +/** Add the autoImport pointer to a VS Code settings.json object. */ +export function buildRooVscodeAutoImport(existing, importPath) { + return { ...(existing || {}), "roo-cline.autoImportSettingsPath": importPath }; +} + +function readJson(path) { + try { + if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8")); + } catch { + /* corrupt/missing */ + } + return {}; +} + +async function fetchModelIds(baseUrl, apiKey) { + try { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch(`${baseUrl.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 runSetupRooCommand(opts = {}) { + const { baseUrl, apiKey } = resolveRooTarget(opts); + const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); + const importPath = opts.importPath ?? opts["import-path"] ?? join(os.homedir(), ".omniroute", "roo-settings.json"); + const vscodePath = + opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json"); + + printHeading("OmniRoute → Roo Code (OpenAI-compatible)"); + printInfo(`Server: ${baseUrl}`); + + let model = opts.model; + if (!model) { + const ids = await fetchModelIds(baseUrl, apiKey); + if (ids.length && !opts.yes) { + printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`); + const { createPrompt } = await import("../io.mjs"); + const prompt = createPrompt(); + try { + model = await prompt.ask("Model id for Roo"); + } finally { + prompt.close(); + } + } + } + if (!model) { + printError("A model is required. Pass --model (Roo has no model auto-discovery)."); + return 2; + } + + const importDoc = buildRooImport({ baseUrl, apiKey, model }); + const vscodeExists = existsSync(vscodePath); + + if (dryRun) { + console.log(`\n── [dry-run] ${importPath} ──`); + console.log(JSON.stringify({ ...importDoc, providerProfiles: { ...importDoc.providerProfiles, apiConfigs: { OmniRoute: { ...importDoc.providerProfiles.apiConfigs.OmniRoute, openAiApiKey: apiKey ? "set" : "sk_omniroute" } } } }, null, 2)); + console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would set roo-cline.autoImportSettingsPath)" : "(skipped — file absent)"}`); + } else { + mkdirSync(join(importPath, ".."), { recursive: true }); + writeFileSync(importPath, JSON.stringify(importDoc, null, 2) + "\n", "utf8"); + printSuccess(`Wrote ${importPath}`); + if (vscodeExists) { + const merged = buildRooVscodeAutoImport(readJson(vscodePath), importPath); + writeFileSync(vscodePath, JSON.stringify(merged, null, 2) + "\n", "utf8"); + printSuccess(`Set roo-cline.autoImportSettingsPath in ${vscodePath}`); + } + } + + printInfo("\nIn the Roo Code panel: Settings → Providers → OpenAI Compatible (guaranteed path):"); + printInfo(` Base URL: ${baseUrl} (Roo expects /v1)`); + printInfo(` API Key: `); + printInfo(` Model: ${model}`); + printInfo(`Or use Roo: “Import Settings” → select ${importPath}`); + return 0; +} + +export function registerSetupRoo(program) { + program + .command("setup-roo") + .description( + "Configure Roo Code for OmniRoute: write a Roo import JSON + autoImport pointer + print UI steps" + ) + .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("--model ", "Model id for Roo (required unless picked interactively)") + .option("--import-path ", "Roo import JSON path (default: ~/.omniroute/roo-settings.json)") + .option("--vscode-settings ", "VS Code settings.json (default: ~/.config/Code/User/settings.json)") + .option("--yes", "Non-interactive: do not prompt (requires --model)") + .option("--dry-run", "Print what would be written without touching the filesystem") + .action(async (opts) => { + const code = await runSetupRooCommand(opts); + if (code !== 0) process.exit(code); + }); +} diff --git a/docs/guides/REMOTE-MODE.md b/docs/guides/REMOTE-MODE.md index cb9739c931..795623b6c6 100644 --- a/docs/guides/REMOTE-MODE.md +++ b/docs/guides/REMOTE-MODE.md @@ -151,6 +151,7 @@ context, or `--remote --api-key `): | 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 | +| Roo Code | `omniroute setup-roo` | writes a Roo import JSON (`~/.omniroute/roo-settings.json`) + sets `roo-cline.autoImportSettingsPath` + prints UI steps (OpenAI-compatible, Base URL **with** `/v1`) | ```bash # OpenCode (openai-compatible provider, all catalog models, remote VPS) diff --git a/tests/unit/cli/setup-roo.test.ts b/tests/unit/cli/setup-roo.test.ts new file mode 100644 index 0000000000..941e476d31 --- /dev/null +++ b/tests/unit/cli/setup-roo.test.ts @@ -0,0 +1,26 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveRooTarget, buildRooImport, buildRooVscodeAutoImport } from "../../../bin/cli/commands/setup-roo.mjs"; + +test("resolveRooTarget ensures /v1 on the base URL", () => { + assert.equal(resolveRooTarget({ remote: "http://vps:20128" }).baseUrl, "http://vps:20128/v1"); +}); +test("resolveRooTarget: explicit --api-key wins", () => { + assert.equal(resolveRooTarget({ remote: "http://x:20128", apiKey: "sk-x" }).apiKey, "sk-x"); +}); +test("buildRooImport produces an openai-compatible provider profile (baseUrl /v1, model)", () => { + const d = buildRooImport({ baseUrl: "http://vps:20128/v1", apiKey: "k", model: "glm/glm-5.2" }); + const cfg = d.providerProfiles.apiConfigs.OmniRoute; + assert.equal(cfg.apiProvider, "openai"); + assert.equal(cfg.openAiBaseUrl, "http://vps:20128/v1"); + assert.equal(cfg.openAiModelId, "glm/glm-5.2"); + assert.equal(d.providerProfiles.currentApiConfigName, "OmniRoute"); +}); +test("buildRooImport falls back to a placeholder key", () => { + assert.equal(buildRooImport({ baseUrl: "http://x/v1", apiKey: "", model: "m" }).providerProfiles.apiConfigs.OmniRoute.openAiApiKey, "sk_omniroute"); +}); +test("buildRooVscodeAutoImport sets the pointer, preserving other settings", () => { + const s = buildRooVscodeAutoImport({ "editor.tabSize": 2 }, "/home/u/.omniroute/roo-settings.json"); + assert.equal(s["editor.tabSize"], 2); + assert.equal(s["roo-cline.autoImportSettingsPath"], "/home/u/.omniroute/roo-settings.json"); +});