diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 7899ebc8d2..0bf9a2b4c2 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -61,6 +61,7 @@ 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 { registerSetupKilo } from "./setup-kilo.mjs"; import { registerConnect } from "./connect.mjs"; import { registerTokens } from "./tokens.mjs"; import { registerConfigure } from "./configure.mjs"; @@ -132,6 +133,7 @@ export function registerCommands(program) { registerSetupClaude(program); registerSetupOpencode(program); registerSetupCline(program); + registerSetupKilo(program); registerConnect(program); registerTokens(program); registerConfigure(program); diff --git a/bin/cli/commands/setup-kilo.mjs b/bin/cli/commands/setup-kilo.mjs new file mode 100644 index 0000000000..c42e4d8246 --- /dev/null +++ b/bin/cli/commands/setup-kilo.mjs @@ -0,0 +1,178 @@ +/** + * omniroute setup-kilo — configure Kilo Code to use OmniRoute. + * + * Kilo Code (kilocode.kilo-code, a Cline/Roo descendant) has two surfaces: + * - CLI/standalone mode reads ~/.local/share/kilo/auth.json. + * - The VS Code extension reads `kilocode.*` keys from VS Code settings.json. + * This writes BOTH (matching the OmniRoute dashboard) and prints the UI settings. + * + * Unlike Cline, Kilo's openAi baseURL INCLUDES /v1 (it appends /chat/completions). + */ + +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"; + +/** Ensure the URL ends with /v1 (Kilo appends /chat/completions to it). */ +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 resolveKiloTarget(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 }; +} + +/** Merge the OmniRoute openai-compatible provider into Kilo's CLI auth.json. */ +export function buildKiloAuth(existing, { apiKey, baseUrl, model }) { + const auth = { ...(existing || {}) }; + auth["openai-compatible"] = { + ...(auth["openai-compatible"] || {}), + apiKey: apiKey || "sk_omniroute", + baseUrl, + model, + }; + return auth; +} + +/** Merge the kilocode.* keys into VS Code settings.json (extension surface). */ +export function buildKiloVscodeSettings(existing, { apiKey, baseUrl, model }) { + const s = { ...(existing || {}) }; + s["kilocode.customProvider"] = { name: "OmniRoute", baseURL: baseUrl, apiKey: apiKey || "sk_omniroute" }; + s["kilocode.defaultModel"] = model; + return s; +} + +function readJson(path) { + try { + if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8")); + } catch { + /* corrupt/missing */ + } + return {}; +} + +async function fetchModelIds(root, apiKey) { + try { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch(`${root.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 runSetupKiloCommand(opts = {}) { + const { baseUrl, apiKey } = resolveKiloTarget(opts); + const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); + const authPath = opts.authPath ?? opts["auth-path"] ?? join(os.homedir(), ".local", "share", "kilo", "auth.json"); + const vscodePath = + opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json"); + + printHeading("OmniRoute → Kilo 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 prompt = createPrompt(); + try { + model = await prompt.ask("Model id for Kilo"); + } finally { + prompt.close(); + } + } + } + if (!model) { + printError("A model is required. Pass --model (Kilo's extension has no model auto-discovery)."); + return 2; + } + + const auth = buildKiloAuth(readJson(authPath), { apiKey, baseUrl, model }); + // Only touch VS Code settings.json if it already exists (avoid creating a + // bogus one for users who don't use that VS Code variant). + const vscodeExists = existsSync(vscodePath); + const vscodeSettings = vscodeExists + ? buildKiloVscodeSettings(readJson(vscodePath), { apiKey, baseUrl, model }) + : null; + + if (dryRun) { + console.log(`\n── [dry-run] ${authPath} ──`); + console.log( + JSON.stringify( + { "openai-compatible": { ...auth["openai-compatible"], apiKey: apiKey ? "set" : "sk_omniroute" } }, + null, + 2 + ) + ); + console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would merge kilocode.* keys)" : "(skipped — file absent)"}`); + } else { + mkdirSync(join(authPath, ".."), { recursive: true }); + writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n", "utf8"); + printSuccess(`Wrote ${authPath}`); + if (vscodeSettings) { + writeFileSync(vscodePath, JSON.stringify(vscodeSettings, null, 2) + "\n", "utf8"); + printSuccess(`Updated ${vscodePath} (kilocode.customProvider + defaultModel)`); + } else { + printInfo(`Skipped VS Code settings (${vscodePath} not found).`); + } + } + + printInfo("\nFor the Kilo Code VS Code extension, set Settings → Providers → OpenAI Compatible:"); + printInfo(` Base URL: ${baseUrl} (Kilo expects /v1)`); + printInfo(` API Key: `); + printInfo(` Model: ${model}`); + return 0; +} + +export function registerSetupKilo(program) { + program + .command("setup-kilo") + .description( + "Configure Kilo Code for OmniRoute: write ~/.local/share/kilo/auth.json (CLI) + VS Code kilocode.* 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 Kilo (required unless picked interactively)") + .option("--auth-path ", "Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.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 runSetupKiloCommand(opts); + if (code !== 0) process.exit(code); + }); +} diff --git a/docs/guides/REMOTE-MODE.md b/docs/guides/REMOTE-MODE.md index a2ee00934d..bb7212efcb 100644 --- a/docs/guides/REMOTE-MODE.md +++ b/docs/guides/REMOTE-MODE.md @@ -148,6 +148,7 @@ context, or `--remote --api-key `): | 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`) | +| Kilo Code | `omniroute setup-kilo` | `~/.local/share/kilo/auth.json` (CLI) + VS Code `kilocode.*` settings — OpenAI-compatible, Base URL **with** `/v1` | ```bash # OpenCode (openai-compatible provider, all catalog models, remote VPS) diff --git a/tests/unit/cli/setup-kilo.test.ts b/tests/unit/cli/setup-kilo.test.ts new file mode 100644 index 0000000000..d1c6875440 --- /dev/null +++ b/tests/unit/cli/setup-kilo.test.ts @@ -0,0 +1,45 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildKiloAuth, + buildKiloVscodeSettings, + resolveKiloTarget, +} from "../../../bin/cli/commands/setup-kilo.mjs"; + +test("buildKiloAuth sets the openai-compatible provider (baseUrl WITH /v1, model)", () => { + const auth = buildKiloAuth({}, { apiKey: "sk-x", baseUrl: "http://vps:20128/v1", model: "glm/glm-5.2" }); + assert.equal(auth["openai-compatible"].apiKey, "sk-x"); + assert.equal(auth["openai-compatible"].baseUrl, "http://vps:20128/v1"); + assert.equal(auth["openai-compatible"].model, "glm/glm-5.2"); +}); + +test("buildKiloAuth merges (preserves other providers/keys)", () => { + const auth = buildKiloAuth({ anthropic: { apiKey: "keep" } }, { apiKey: "k", baseUrl: "http://x/v1", model: "m" }); + assert.equal(auth.anthropic.apiKey, "keep"); + assert.equal(auth["openai-compatible"].model, "m"); +}); + +test("buildKiloAuth falls back to a placeholder key", () => { + const auth = buildKiloAuth({}, { apiKey: "", baseUrl: "http://x/v1", model: "m" }); + assert.equal(auth["openai-compatible"].apiKey, "sk_omniroute"); +}); + +test("buildKiloVscodeSettings sets kilocode.customProvider + defaultModel, preserving others", () => { + const s = buildKiloVscodeSettings( + { "editor.fontSize": 14 }, + { apiKey: "k", baseUrl: "http://vps:20128/v1", model: "glm/glm-5.2" } + ); + assert.equal(s["editor.fontSize"], 14); + assert.equal(s["kilocode.customProvider"].name, "OmniRoute"); + assert.equal(s["kilocode.customProvider"].baseURL, "http://vps:20128/v1"); + assert.equal(s["kilocode.defaultModel"], "glm/glm-5.2"); +}); + +test("resolveKiloTarget ensures /v1 on the base URL (Kilo wants it)", () => { + assert.equal(resolveKiloTarget({ remote: "http://vps:20128" }).baseUrl, "http://vps:20128/v1"); + assert.equal(resolveKiloTarget({ remote: "http://vps:20128/v1/" }).baseUrl, "http://vps:20128/v1"); +}); + +test("resolveKiloTarget: explicit --api-key wins", () => { + assert.equal(resolveKiloTarget({ remote: "http://x:20128", apiKey: "sk-explicit" }).apiKey, "sk-explicit"); +});