Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bin/cli/commands/registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -138,6 +139,7 @@ export function registerCommands(program) {
registerSetupKilo(program);
registerSetupContinue(program);
registerSetupCursor(program);
registerSetupRoo(program);
registerConnect(program);
registerTokens(program);
registerConfigure(program);
Expand Down
172 changes: 172 additions & 0 deletions bin/cli/commands/setup-roo.mjs
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 {};
}
Comment on lines +73 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Returning {} when a file exists but fails to parse is extremely dangerous. VS Code settings.json files often contain comments or trailing commas, which cause JSON.parse to throw. If we return {} here, the caller will merge the new setting into {} and overwrite the user's entire settings.json file, causing complete data loss of their VS Code configuration. We should throw an error if the file exists but cannot be parsed.

Suggested change
function readJson(path) {
try {
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
} catch {
/* corrupt/missing */
}
return {};
}
function readJson(path) {
if (!existsSync(path)) return {};
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
throw new Error(`Malformed JSON or unsupported comments/trailing commas: ${err.message}`);
}
}


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The default VS Code settings path is hardcoded to the Linux path (~/.config/Code/User/settings.json). This will fail to locate the settings file on macOS (which uses ~/Library/Application Support/Code/User/settings.json) and Windows (which uses %APPDATA%\\Code\\User\\settings.json). We should resolve this path dynamically based on the operating system platform.

Suggested change
const vscodePath =
opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json");
const defaultVscodePath =
os.platform() === "win32"
? join(process.env.APPDATA || join(os.homedir(), "AppData", "Roaming"), "Code", "User", "settings.json")
: os.platform() === "darwin"
? join(os.homedir(), "Library", "Application Support", "Code", "User", "settings.json")
: join(os.homedir(), ".config", "Code", "User", "settings.json");
const vscodePath = opts.vscodeSettings ?? opts["vscode-settings"] ?? defaultVscodePath;


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Wrap the VS Code settings update in a try-catch block. If readJson throws an error (e.g., due to parsing failure of a file with comments), we must catch it and abort the write operation to prevent overwriting and destroying the user's VS Code settings.

    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);
});
}
1 change: 1 addition & 0 deletions docs/guides/REMOTE-MODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ context, or `--remote <url> --api-key <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)
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/cli/setup-roo.test.ts
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");
});
Loading