Repository navigation
feat(cli): setup-roo — configure Roo Code for OmniRoute (import JSON + autoImport + UI) #4292
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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"); | ||||||||||||||||||||
|
Comment on lines
+103
to
+104
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The default VS Code settings path is hardcoded to the Linux path (
Suggested change
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| 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 <id> (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}`); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+139
to
+143
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrap the VS Code settings update in a if (vscodeExists) {
try {
const merged = buildRooVscodeAutoImport(readJson(vscodePath), importPath);
writeFileSync(vscodePath, JSON.stringify(merged, null, 2) + "\n", "utf8");
printSuccess(`Set roo-cline.autoImportSettingsPath in ${vscodePath}`);
} catch (err) {
printError(`Skipped updating VS Code settings to prevent data loss: ${err.message}`);
}
} |
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| printInfo("\nIn the Roo Code panel: Settings → Providers → OpenAI Compatible (guaranteed path):"); | ||||||||||||||||||||
| printInfo(` Base URL: ${baseUrl} (Roo expects /v1)`); | ||||||||||||||||||||
| printInfo(` API Key: <your OMNIROUTE_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 <port>", "Local OmniRoute port (ignored when --remote is set)", "20128") | ||||||||||||||||||||
| .option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") | ||||||||||||||||||||
| .option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") | ||||||||||||||||||||
| .option("--model <id>", "Model id for Roo (required unless picked interactively)") | ||||||||||||||||||||
| .option("--import-path <path>", "Roo import JSON path (default: ~/.omniroute/roo-settings.json)") | ||||||||||||||||||||
| .option("--vscode-settings <path>", "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); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Returning
{}when a file exists but fails to parse is extremely dangerous. VS Codesettings.jsonfiles often contain comments or trailing commas, which causeJSON.parseto throw. If we return{}here, the caller will merge the new setting into{}and overwrite the user's entiresettings.jsonfile, causing complete data loss of their VS Code configuration. We should throw an error if the file exists but cannot be parsed.