mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 07:42:13 +03:00
feat(cli): setup-kilo — configure Kilo Code for OmniRoute (CLI auth + VS Code settings) (#4284)
CLI #5 of the series. `omniroute setup-kilo` configures Kilo Code (kilocode.kilo-code, a Cline/Roo descendant) to use OmniRoute. Two surfaces (both written, matching the dashboard cli-tools/kilo-settings): - ~/.local/share/kilo/auth.json — CLI mode: auth["openai-compatible"] = { apiKey, baseUrl (WITH /v1 — Kilo appends /chat/completions), model }. - VS Code settings.json — extension: kilocode.customProvider (name/baseURL/apiKey) + kilocode.defaultModel. Only touched when the file already exists. Remote-aware (--remote/--api-key → active context → localhost). Model via --model or an interactive pick from /v1/models (Kilo's extension has no auto-discovery). Prints the exact UI settings to paste. Merges both files (preserves existing). Researched against current Kilo docs: confirmed openAiBaseUrl needs /v1 (unlike Cline's root url), the openai-compatible keys, and the export/import + CLI surfaces. Kilo's wire (/v1/chat/completions) already validated → "OK". Tests: buildKiloAuth (provider + /v1 + merge + key fallback), buildKiloVscodeSettings (kilocode.* keys + preserve), resolveKiloTarget (/v1 ensure, key win). 6 unit tests; check:cli-i18n green.
This commit is contained in:
committed by
GitHub
parent
6f16faa039
commit
70bd6fbcc9
@@ -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);
|
||||
|
||||
178
bin/cli/commands/setup-kilo.mjs
Normal file
178
bin/cli/commands/setup-kilo.mjs
Normal file
@@ -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 <id> (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: <your OMNIROUTE_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 <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 Kilo (required unless picked interactively)")
|
||||
.option("--auth-path <path>", "Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.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 runSetupKiloCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
@@ -148,6 +148,7 @@ context, or `--remote <url> --api-key <key>`):
|
||||
| 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`) |
|
||||
| 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)
|
||||
|
||||
45
tests/unit/cli/setup-kilo.test.ts
Normal file
45
tests/unit/cli/setup-kilo.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user