From 755cc78683e5e46a928d058c14395685f9368541 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 19 Jun 2026 12:08:06 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20setup-cline=20=E2=80=94=20configur?= =?UTF-8?q?e=20Cline=20for=20OmniRoute=20(CLI=20files=20+=20VS=20Code=20hi?= =?UTF-8?q?nts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI #4 of the series. Cline's VS Code extension keeps config in opaque VS Code globalStorage (not file-writable); its CLI/standalone mode reads ~/.cline/data/. `omniroute setup-cline`: - writes ~/.cline/data/globalState.json (act/planModeApiProvider=openai, openAiBaseUrl = ROOT url WITHOUT /v1 — Cline appends /v1/chat/completions — openAiModelId + planModeOpenAiModelId) and ~/.cline/data/secrets.json (openAiApiKey), both merged to preserve existing state. Matches the dashboard cli-tools/cline-settings schema. - remote-aware (--remote/--api-key → active context → localhost). - model resolved via --model or an interactive pick from /v1/models (Cline has no model auto-discovery). - prints the exact VS Code extension settings (Base URL/key/model) to paste, since the extension's storage can't be written directly. Researched against the current Cline docs (saoudrizwan.claude-dev): confirmed the openai-compatible keys, the Plan/Act split, and that openAiBaseUrl must be the ROOT (no /v1). Cline's wire (/v1/chat/completions) already validated → "OK". Tests: buildClineGlobalState (provider+root+model, merge-preserve), buildClineSecrets (key + placeholder), resolveClineTarget (/v1 strip, key win). 6 unit tests; check:cli-i18n green. --- bin/cli/commands/registry.mjs | 2 + bin/cli/commands/setup-cline.mjs | 160 +++++++++++++++++++++++++++++ docs/guides/REMOTE-MODE.md | 1 + tests/unit/cli/setup-cline.test.ts | 46 +++++++++ 4 files changed, 209 insertions(+) create mode 100644 bin/cli/commands/setup-cline.mjs create mode 100644 tests/unit/cli/setup-cline.test.ts diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 5aff68bb0ac..7899ebc8d22 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -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"; @@ -130,6 +131,7 @@ export function registerCommands(program) { registerSetupCodex(program); registerSetupClaude(program); registerSetupOpencode(program); + registerSetupCline(program); registerConnect(program); registerTokens(program); registerConfigure(program); diff --git a/bin/cli/commands/setup-cline.mjs b/bin/cli/commands/setup-cline.mjs new file mode 100644 index 00000000000..1a762738551 --- /dev/null +++ b/bin/cli/commands/setup-cline.mjs @@ -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" }; +} + +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 (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: `); + 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 ", "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 Cline (required unless picked interactively)") + .option("--cline-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); + }); +} diff --git a/docs/guides/REMOTE-MODE.md b/docs/guides/REMOTE-MODE.md index 091a6e39780..a2ee00934d1 100644 --- a/docs/guides/REMOTE-MODE.md +++ b/docs/guides/REMOTE-MODE.md @@ -147,6 +147,7 @@ context, or `--remote --api-key `): | Codex | `omniroute setup-codex` | `~/.codex/.config.toml` profiles (per model) | | Claude Code | `omniroute setup-claude` | `~/.claude/profiles//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/`) | +| 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) diff --git a/tests/unit/cli/setup-cline.test.ts b/tests/unit/cli/setup-cline.test.ts new file mode 100644 index 00000000000..be508f0fad0 --- /dev/null +++ b/tests/unit/cli/setup-cline.test.ts @@ -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"); +}); + +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"); +});