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 @@ -60,6 +60,7 @@ import { registerLaunchCodex } from "./launch-codex.mjs";
import { registerSetupCodex } from "./setup-codex.mjs";
import { registerSetupClaude } from "./setup-claude.mjs";
import { registerSetupOpencode } from "./setup-opencode.mjs";
import { registerSetupCline } from "./setup-cline.mjs";
import { registerConnect } from "./connect.mjs";
import { registerTokens } from "./tokens.mjs";
import { registerConfigure } from "./configure.mjs";
Expand Down Expand Up @@ -130,6 +131,7 @@ export function registerCommands(program) {
registerSetupCodex(program);
registerSetupClaude(program);
registerSetupOpencode(program);
registerSetupCline(program);
registerConnect(program);
registerTokens(program);
registerConfigure(program);
Expand Down
160 changes: 160 additions & 0 deletions bin/cli/commands/setup-cline.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* omniroute setup-cline — configure the Cline AI coding agent to use OmniRoute.
*
* Cline's VS Code extension keeps its config in VS Code's opaque globalStorage
* (not file-writable). Its CLI/standalone mode reads ~/.cline/data/. This command
* writes the CLI-mode files (matching the OmniRoute dashboard) AND prints the
* Base URL / model to paste into the VS Code extension UI.
*
* Cline uses the OpenAI-compatible provider: openAiBaseUrl is the ROOT URL
* (no /v1 — Cline appends /v1/chat/completions). Plan + Act modes are set to the
* same provider/model. The key goes in secrets.json (Cline has no env ref).
*/

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";

function stripToRoot(url) {
let s = String(url || "").replace(/\/+$/, "");
return s.endsWith("/v1") ? s.slice(0, -3) : s;
}

/** Resolve baseUrl (ROOT, no /v1) + apiKey from flags → active context → localhost. */
export function resolveClineTarget(opts = {}) {
let baseUrl;
if (opts.remote) baseUrl = stripToRoot(opts.remote);
else {
try {
baseUrl = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
} catch {
/* none */
}
if (!baseUrl) baseUrl = `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, apiKey };
}

/** Merge OmniRoute openai-compatible settings into Cline's globalState (Plan + Act). */
export function buildClineGlobalState(existing, { baseUrl, model }) {
const gs = { ...(existing || {}) };
gs.actModeApiProvider = "openai";
gs.planModeApiProvider = "openai";
gs.openAiBaseUrl = baseUrl; // ROOT — Cline appends /v1/chat/completions
if (model) {
gs.openAiModelId = model;
gs.planModeOpenAiModelId = model;
}
return gs;
}

/** Merge the API key into Cline's secrets (Cline has no env-var reference). */
export function buildClineSecrets(existing, { apiKey }) {
return { ...(existing || {}), openAiApiKey: apiKey || "sk_omniroute" };
}
Comment on lines +64 to +66

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

If apiKey is not provided (e.g., when running the command without an active context or environment variable), buildClineSecrets will overwrite any existing openAiApiKey in secrets.json with the fallback placeholder 'sk_omniroute'. To prevent losing an already configured key, we should fall back to the existing key if present before using the placeholder.

Suggested change
export function buildClineSecrets(existing, { apiKey }) {
return { ...(existing || {}), openAiApiKey: apiKey || "sk_omniroute" };
}
export function buildClineSecrets(existing, { apiKey }) {
return { ...(existing || {}), openAiApiKey: apiKey || existing?.openAiApiKey || "sk_omniroute" };
}


function readJson(path) {
try {
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
} catch {
/* corrupt/missing → start fresh */
}
return {};
}

async function fetchModelIds(baseUrl, apiKey) {
try {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`${baseUrl}/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 runSetupClineCommand(opts = {}) {
const { baseUrl, apiKey } = resolveClineTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const clineDir = opts.clineDir ?? opts["cline-dir"] ?? join(os.homedir(), ".cline", "data");

printHeading("OmniRoute → Cline (OpenAI-compatible)");
printInfo(`Server: ${baseUrl}`);

// Resolve the model (Cline needs one explicit id — no auto-discovery).
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 prompt = createPrompt();
try {
model = await prompt.ask("Model id for Cline");
} finally {
prompt.close();
}
}
}
if (!model) {
printError("A model is required. Pass --model <id> (Cline has no model auto-discovery).");
return 2;
}

const gsPath = join(clineDir, "globalState.json");
const secPath = join(clineDir, "secrets.json");
const globalState = buildClineGlobalState(readJson(gsPath), { baseUrl, model });
const secrets = buildClineSecrets(readJson(secPath), { apiKey });

if (dryRun) {
console.log(`\n── [dry-run] ${gsPath} ──`);
console.log(JSON.stringify({ actModeApiProvider: globalState.actModeApiProvider, planModeApiProvider: globalState.planModeApiProvider, openAiBaseUrl: globalState.openAiBaseUrl, openAiModelId: globalState.openAiModelId }, null, 2));
console.log(`\n── [dry-run] ${secPath} ── (openAiApiKey: ${apiKey ? "set" : "sk_omniroute"})`);
} else {
if (!existsSync(clineDir)) mkdirSync(clineDir, { recursive: true });
writeFileSync(gsPath, JSON.stringify(globalState, null, 2) + "\n", "utf8");
writeFileSync(secPath, JSON.stringify(secrets, null, 2) + "\n", "utf8");
printSuccess(`Wrote ${gsPath}`);
printSuccess(`Wrote ${secPath}`);
}

// The VS Code extension uses opaque globalStorage — can't be file-written.
printInfo("\nFor the Cline VS Code extension, set these in its Settings → API (OpenAI Compatible):");
printInfo(` Base URL: ${baseUrl} (NOT /v1 — Cline appends it)`);
printInfo(` API Key: <your OMNIROUTE_API_KEY>`);
printInfo(` Model: ${model}`);
return 0;
}

export function registerSetupCline(program) {
program
.command("setup-cline")
.description(
"Configure Cline for OmniRoute: write ~/.cline/data (CLI mode) + print VS Code extension settings"
)
.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 Cline (required unless picked interactively)")
.option("--cline-dir <dir>", "Cline data dir (default: ~/.cline/data)")
.option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem")
Comment on lines +149 to +155

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.

medium

The resolveClineTarget function attempts to read opts.context to resolve the active context, but the --context option is not registered in the CLI command definition. Adding the --context <name> option allows users to specify a context directly from the command line.

    .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("--context <name>", "OmniRoute context name")
    .option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
    .option("--model <id>", "Model id for Cline (required unless picked interactively)")
    .option("--cline-dir <dir>", "Cline data dir (default: ~/.cline/data)")
    .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 runSetupClineCommand(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 @@ -147,6 +147,7 @@ context, or `--remote <url> --api-key <key>`):
| Codex | `omniroute setup-codex` | `~/.codex/<name>.config.toml` profiles (per model) |
| Claude Code | `omniroute setup-claude` | `~/.claude/profiles/<name>/settings.json` (per model) |
| OpenCode | `omniroute setup-opencode` | `~/.config/opencode/opencode.json` — the `omniroute` openai-compatible provider with every catalog model (run `opencode -m omniroute/<model>`) |
| 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`) |

```bash
# OpenCode (openai-compatible provider, all catalog models, remote VPS)
Expand Down
46 changes: 46 additions & 0 deletions tests/unit/cli/setup-cline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
buildClineGlobalState,
buildClineSecrets,
resolveClineTarget,
} from "../../../bin/cli/commands/setup-cline.mjs";

test("buildClineGlobalState sets the openai provider for Plan + Act, root base URL, model", () => {
const gs = buildClineGlobalState({}, { baseUrl: "http://vps:20128", model: "glm/glm-5.2" });
assert.equal(gs.actModeApiProvider, "openai");
assert.equal(gs.planModeApiProvider, "openai");
assert.equal(gs.openAiBaseUrl, "http://vps:20128");
assert.equal(gs.openAiModelId, "glm/glm-5.2");
assert.equal(gs.planModeOpenAiModelId, "glm/glm-5.2");
});

test("buildClineGlobalState merges (preserves unrelated existing keys)", () => {
const gs = buildClineGlobalState(
{ telemetrySetting: "off", taskHistory: [1, 2, 3] },
{ baseUrl: "http://x:20128", model: "m" }
);
assert.equal(gs.telemetrySetting, "off");
assert.deepEqual(gs.taskHistory, [1, 2, 3]);
assert.equal(gs.openAiBaseUrl, "http://x:20128");
});

test("buildClineSecrets stores the key (separate secrets file), preserving others", () => {
const sec = buildClineSecrets({ anthropicApiKey: "keepme" }, { apiKey: "sk-omni" });
assert.equal(sec.openAiApiKey, "sk-omni");
assert.equal(sec.anthropicApiKey, "keepme");
});

test("buildClineSecrets falls back to a placeholder when no key", () => {
assert.equal(buildClineSecrets({}, { apiKey: "" }).openAiApiKey, "sk_omniroute");
});
Comment on lines +34 to +36

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.

medium

Add a unit test to verify that buildClineSecrets preserves the existing key when no new key is provided.

Suggested change
test("buildClineSecrets falls back to a placeholder when no key", () => {
assert.equal(buildClineSecrets({}, { apiKey: "" }).openAiApiKey, "sk_omniroute");
});
test("buildClineSecrets falls back to a placeholder when no key", () => {
assert.equal(buildClineSecrets({}, { apiKey: "" }).openAiApiKey, "sk_omniroute");
});
test("buildClineSecrets preserves existing key if no new key is provided", () => {
const sec = buildClineSecrets({ openAiApiKey: "existing-key" }, { apiKey: "" });
assert.equal(sec.openAiApiKey, "existing-key");
});


test("resolveClineTarget strips /v1 from --remote (Cline wants the ROOT url)", () => {
const { baseUrl } = resolveClineTarget({ remote: "http://vps:20128/v1/" });
assert.equal(baseUrl, "http://vps:20128");
});

test("resolveClineTarget: explicit --api-key wins", () => {
const { apiKey } = resolveClineTarget({ remote: "http://x:20128", apiKey: "sk-explicit" });
assert.equal(apiKey, "sk-explicit");
});
Loading