feat(cli): add connect command for remote 9router servers
This commit is contained in:
1 parent
ca6e8407f1
commit
31704db73e
7 files changed
+815
-1
No files matched your search
@@ -90,6 +90,24 @@ That's it! Start coding with FREE AI models.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Connect to a Remote 9Router
|
||||
|
||||
Already running 9Router on another machine (e.g. a team server on your LAN)? Point this machine's CLI tools at it — no local server is started:
|
||||
|
||||
```bash
|
||||
npx 9router connect http://<server-host>:20128 # pick tools interactively
|
||||
npx 9router connect http://<server-host>:20128 --tools claude,codex # or choose up front
|
||||
npx 9router connect --reset --tools claude,codex # undo
|
||||
```
|
||||
|
||||
It logs in with the dashboard password (hidden prompt), reuses or creates an API key named `cli-<hostname>`, and writes each tool's config (backing up the original once as `*.bak-9router`).
|
||||
|
||||
Supported: `claude`, `codex`, `opencode`, `droid`, `crush`, `kilo`, `cline`, or `all`. Other options: `--model`, `--opus/--sonnet/--haiku/--fable`, `--api-key`, `--key-name`, `--print-env`. See `9router connect --help`.
|
||||
|
||||
> ⚠️ Over plain `http://` the password and API key are sent unencrypted — use a trusted LAN/VPN or put HTTPS in front. The API key is stored in each tool's config file.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Supported CLI Tools
|
||||
|
||||
Claude-Code • OpenClaw • Codex • OpenCode • Cursor • Antigravity • Cline • Continue • Droid • Roo • Copilot • Kilo Code • Gemini CLI • Qwen Code • iFlow • Crush • Crusher • Aider
|
||||
|
||||
+15
@@ -80,6 +80,19 @@ if (args[0] === "xai" && args[1] === "video") {
|
||||
return;
|
||||
}
|
||||
|
||||
// `9router connect <url>` configures local CLI tools against a remote server —
|
||||
// no local server, no runtime deps. Usable via `npx 9router connect …`.
|
||||
if (args[0] === "connect") {
|
||||
const { run } = require("./src/cli/commands/connect");
|
||||
run(args.slice(1))
|
||||
.then((code) => process.exit(code))
|
||||
.catch((err) => {
|
||||
console.error(`❌ ${err?.message || err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime
|
||||
// so the server can resolve them via NODE_PATH. Best-effort — sql.js is required,
|
||||
// better-sqlite3 is optional. Logs to stderr only on failure.
|
||||
@@ -154,6 +167,8 @@ Options:
|
||||
-v, --version Show version
|
||||
|
||||
Commands:
|
||||
connect <server-url> Configure Claude Code for a remote 9router server
|
||||
(npx 9router connect http://host:20128 — no install needed)
|
||||
xai video --prompt "..." --output video.mp4
|
||||
Generate a Grok Imagine video via the running gateway
|
||||
(see: ${APP_NAME} xai video --help)
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
// Postinstall: warm-up SQLite deps into ~/.9router/runtime so the first
|
||||
// `9router` start doesn't need network. Failure here is non-fatal —
|
||||
// cli.js will retry at runtime if anything is missing.
|
||||
// `npx 9router …` (npm_command=exec) is typically a one-shot `connect` — skip
|
||||
// the runtime warm-up; cli.js self-heals it if the server is started later.
|
||||
if (process.env.npm_command === "exec") process.exit(0);
|
||||
|
||||
const { ensureSqliteRuntime } = require("./sqliteRuntime");
|
||||
const { ensureTrayRuntime } = require("./trayRuntime");
|
||||
|
||||
|
||||
+2
-1
@@ -27,7 +27,8 @@
|
||||
"node-forge": "^1.3.3",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1"
|
||||
"react-dom": "19.2.1",
|
||||
"confbox": "^0.2.4"
|
||||
},
|
||||
"comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into ~/.9router/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.",
|
||||
"comment_systray": "systray2 is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails to load on modern macOS dyld. Neither package ships an arm64 macOS binary, so on Apple Silicon hooks/trayRuntime.js overlays our own arm64 build from the tray-binaries GitHub release; without it the tray requires Rosetta 2.",
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* `9router connect <server-url>` — point local CLI tools (Claude Code, Codex, …) at a
|
||||
* REMOTE 9router server. Nothing runs locally: we log in with the dashboard
|
||||
* password, reuse/create an API key for this machine, then write the tool's
|
||||
* settings files (see connectTools.js). Works via `npx 9router connect …` with no global install.
|
||||
*
|
||||
* The password and API key are never printed (key is masked).
|
||||
*/
|
||||
|
||||
const os = require("os");
|
||||
const { TOOL_IDS, CLAUDE_MODELS, resolveTools } = require("./connectTools");
|
||||
|
||||
const DEFAULT_MODEL = "cc/claude-sonnet-5";
|
||||
|
||||
const HELP = `
|
||||
Usage: 9router connect <server-url> [options]
|
||||
|
||||
Configure CLI tools on THIS machine to use a remote 9router server.
|
||||
No local server is started. Run without installing:
|
||||
|
||||
npx 9router connect http://<server-host>:20128
|
||||
npx 9router connect http://<server-host>:20128 --tools claude,codex,opencode
|
||||
|
||||
Options:
|
||||
--tools <list> Comma-separated tools to configure (prompted if omitted
|
||||
in a terminal; default: claude). Supported:
|
||||
${TOOL_IDS.join(", ")}, all
|
||||
--password <pw> Dashboard password (or env NINE_ROUTER_PASSWORD;
|
||||
prompted if omitted — preferred, keeps it out of shell history)
|
||||
--key-name <name> API key name to reuse/create (default: cli-<hostname>)
|
||||
--api-key <key> Use this API key, skip login + key lookup
|
||||
--model <model> Model for non-Claude tools (default: ${DEFAULT_MODEL})
|
||||
--fable|--opus|--sonnet|--haiku <model>
|
||||
Override Claude Code model mapping
|
||||
--print-env Also print OpenAI-compatible env vars for other CLIs
|
||||
--reset Remove 9router settings from the selected tools and exit
|
||||
-h, --help Show this help
|
||||
`;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = {
|
||||
password: process.env.NINE_ROUTER_PASSWORD || null,
|
||||
keyName: `cli-${os.hostname()}`.slice(0, 64),
|
||||
apiKey: null,
|
||||
models: {},
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const next = () => {
|
||||
const v = argv[++i];
|
||||
if (v === undefined) throw new Error(`Missing value for ${a}`);
|
||||
return v;
|
||||
};
|
||||
if (a === "--password") opts.password = next();
|
||||
else if (a === "--key-name") opts.keyName = next();
|
||||
else if (a === "--api-key") opts.apiKey = next();
|
||||
else if (a === "--tools") opts.tools = next().split(",");
|
||||
else if (a === "--model") opts.model = next();
|
||||
else if (a === "--print-env") opts.printEnv = true;
|
||||
else if (a === "--reset") opts.reset = true;
|
||||
else if (a === "-h" || a === "--help") opts.help = true;
|
||||
else if (a.startsWith("--") && CLAUDE_MODELS.some((m) => `--${m.flag}` === a)) opts.models[a.slice(2)] = next();
|
||||
else if (!a.startsWith("-") && !opts.url) opts.url = a;
|
||||
else throw new Error(`Unknown option: ${a}`);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function normalizeServerUrl(input) {
|
||||
let raw = String(input || "").trim();
|
||||
if (!/^https?:\/\//i.test(raw)) raw = `http://${raw}`;
|
||||
const u = new URL(raw);
|
||||
// Accept pasted dashboard/API URLs: keep only origin.
|
||||
return u.origin;
|
||||
}
|
||||
|
||||
function maskKey(key) {
|
||||
if (!key || key.length < 12) return "****";
|
||||
return `${key.slice(0, 6)}…${key.slice(-4)}`;
|
||||
}
|
||||
|
||||
// Enquirer rejects with an empty value on Ctrl+C / Esc.
|
||||
class Cancelled extends Error {}
|
||||
const prompt = (p) => p.run().catch((err) => { throw err || new Cancelled("Cancelled"); });
|
||||
|
||||
async function promptPassword() {
|
||||
if (!process.stdin.isTTY) throw new Error("Password required: pass --password or set NINE_ROUTER_PASSWORD");
|
||||
const { Password } = require("enquirer");
|
||||
return prompt(new Password({ message: "9router dashboard password" }));
|
||||
}
|
||||
|
||||
async function request(url, { method = "GET", body, cookie, apiKey } = {}) {
|
||||
const headers = { Accept: "application/json" };
|
||||
if (body) headers["Content-Type"] = "application/json";
|
||||
if (cookie) headers.Cookie = cookie;
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined, redirect: "manual" });
|
||||
} catch (err) {
|
||||
throw new Error(`Cannot reach ${new URL(url).origin}: ${err.cause?.code || err.message}`);
|
||||
}
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch { /* non-JSON */ }
|
||||
// Server-controlled strings get printed later — strip control chars (terminal escape injection).
|
||||
if (typeof data?.error === "string") data.error = data.error.replace(/[\x00-\x1f\x7f]/g, "");
|
||||
return { status: res.status, headers: res.headers, data };
|
||||
}
|
||||
|
||||
function extractAuthCookie(headers) {
|
||||
const list = typeof headers.getSetCookie === "function" ? headers.getSetCookie() : [headers.get("set-cookie") || ""];
|
||||
for (const c of list) {
|
||||
const m = /(?:^|,\s*)auth_token=([^;]+)/.exec(c);
|
||||
if (m) return `auth_token=${m[1]}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function login(server, password) {
|
||||
const res = await request(`${server}/api/auth/login`, { method: "POST", body: { password } });
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
const cookie = extractAuthCookie(res.headers);
|
||||
if (!cookie) throw new Error("Login succeeded but server returned no session cookie");
|
||||
return cookie;
|
||||
}
|
||||
throw new Error(`Login failed (${res.status}): ${res.data?.error || "unknown error"}`);
|
||||
}
|
||||
|
||||
async function getOrCreateApiKey(server, cookie, keyName) {
|
||||
const list = await request(`${server}/api/keys`, { cookie });
|
||||
if (list.status === 401) throw new Error("Unauthorized listing API keys — wrong password or session rejected");
|
||||
if (list.status !== 200) throw new Error(`Failed to list API keys (${list.status}): ${list.data?.error || ""}`);
|
||||
const keys = (list.data?.keys || []).filter((k) => k.isActive !== false);
|
||||
const existing = keys.find((k) => k.name === keyName);
|
||||
if (existing) return { key: existing.key, created: false };
|
||||
|
||||
const created = await request(`${server}/api/keys`, { method: "POST", cookie, body: { name: keyName } });
|
||||
if (created.status !== 201 || !created.data?.key) {
|
||||
throw new Error(`Failed to create API key (${created.status}): ${created.data?.error || ""}`);
|
||||
}
|
||||
return { key: created.data.key, created: true };
|
||||
}
|
||||
|
||||
async function listModels(server, apiKey) {
|
||||
const res = await request(`${server}/v1/models`, { apiKey });
|
||||
if (res.status === 401) throw new Error("API key rejected by server (/v1/models returned 401)");
|
||||
if (res.status !== 200) return null;
|
||||
return new Set((res.data?.data || []).map((m) => m.id));
|
||||
}
|
||||
|
||||
async function promptTools() {
|
||||
const { MultiSelect } = require("enquirer");
|
||||
const { TOOLS } = require("./connectTools");
|
||||
return prompt(new MultiSelect({
|
||||
message: "Select CLI tools to configure (space to toggle, enter to confirm)",
|
||||
choices: TOOLS.map((t) => ({ name: t.id, message: t.name, hint: t.paths()[0], enabled: t.id === "claude" })),
|
||||
validate: (v) => v.length > 0 || "Select at least one tool",
|
||||
}));
|
||||
}
|
||||
|
||||
async function selectTools(opts) {
|
||||
if (opts.tools) return resolveTools(opts.tools);
|
||||
if (process.stdin.isTTY) return resolveTools(await promptTools());
|
||||
return resolveTools(["claude"]);
|
||||
}
|
||||
|
||||
async function run(argv) {
|
||||
try {
|
||||
return await runConnect(argv);
|
||||
} catch (err) {
|
||||
if (err instanceof Cancelled) {
|
||||
console.log("Cancelled.");
|
||||
return 130;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function runConnect(argv) {
|
||||
const opts = parseArgs(argv);
|
||||
if (opts.help) {
|
||||
console.log(HELP);
|
||||
return 0;
|
||||
}
|
||||
if (!opts.reset && !opts.url) {
|
||||
console.log(HELP);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Pick tools before any network call: bad names fail fast, and cancelling
|
||||
// the picker never leaves a freshly created key on the server.
|
||||
const tools = await selectTools(opts);
|
||||
|
||||
if (opts.reset) {
|
||||
let failed = 0;
|
||||
for (const t of tools) {
|
||||
try {
|
||||
const files = await t.reset();
|
||||
console.log(files.length ? `✅ ${t.name}: removed 9router settings (${files.join(", ")})` : `• ${t.name}: nothing to reset`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.log(`❌ ${t.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
const server = normalizeServerUrl(opts.url);
|
||||
const isRemoteHttp = server.startsWith("http://") && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(server);
|
||||
if (isRemoteHttp) {
|
||||
console.log("\x1b[33m⚠ Plain HTTP: password and API key travel unencrypted. Use only on a trusted LAN/VPN.\x1b[0m");
|
||||
}
|
||||
|
||||
let apiKey = opts.apiKey;
|
||||
if (apiKey) {
|
||||
console.log("• Using provided API key");
|
||||
} else {
|
||||
const password = opts.password ?? (await promptPassword());
|
||||
console.log(`• Logging in to ${server}`);
|
||||
const cookie = await login(server, password);
|
||||
const result = await getOrCreateApiKey(server, cookie, opts.keyName);
|
||||
apiKey = result.key;
|
||||
console.log(`• ${result.created ? "Created" : "Reusing"} API key "${opts.keyName}" (${maskKey(apiKey)})`);
|
||||
}
|
||||
|
||||
const available = await listModels(server, apiKey);
|
||||
const warnMissing = (label, model, flag) => {
|
||||
if (available && !available.has(model)) {
|
||||
console.log(`\x1b[33m⚠ ${label}: "${model}" not listed by server — override with ${flag} <model>\x1b[0m`);
|
||||
}
|
||||
};
|
||||
|
||||
const claudeModels = {};
|
||||
if (tools.some((t) => t.id === "claude")) {
|
||||
for (const m of CLAUDE_MODELS) {
|
||||
claudeModels[m.envKey] = opts.models[m.flag] || m.defaultValue;
|
||||
warnMissing(`claude ${m.flag}`, claudeModels[m.envKey], `--${m.flag}`);
|
||||
}
|
||||
}
|
||||
const model = opts.model || DEFAULT_MODEL;
|
||||
if (tools.some((t) => t.id !== "claude")) warnMissing("model", model, "--model");
|
||||
|
||||
const ctx = { baseUrl: server, apiKey, model, claudeModels };
|
||||
let failed = 0;
|
||||
for (const t of tools) {
|
||||
try {
|
||||
const files = await t.apply(ctx);
|
||||
console.log(`✅ ${t.name} → ${files.join(", ")}`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.log(`❌ ${t.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
console.log(` Base URL: ${server}/v1`);
|
||||
if (tools.some((t) => t.id === "claude")) {
|
||||
for (const m of CLAUDE_MODELS) console.log(` ${m.envKey}=${claudeModels[m.envKey]}`);
|
||||
}
|
||||
if (tools.some((t) => t.id !== "claude")) console.log(` Model (other tools): ${model}`);
|
||||
console.log(` Restart the tools to apply. Undo: npx 9router connect --reset --tools ${tools.map((t) => t.id).join(",")}`);
|
||||
|
||||
if (opts.printEnv) {
|
||||
console.log("\nOpenAI-compatible CLIs (codex, opencode, aider, …):");
|
||||
console.log(` OPENAI_BASE_URL=${server}/v1`);
|
||||
console.log(` OPENAI_API_KEY=${apiKey}`);
|
||||
}
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
module.exports = { run, __test__: { parseArgs, normalizeServerUrl, extractAuthCookie, maskKey, Cancelled } };
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Per-tool config writers for `9router connect`. File layout and keys mirror
|
||||
* the dashboard routes in src/app/api/cli-tools/<tool>-settings/route.js so a
|
||||
* tool configured here looks identical to one applied from the dashboard.
|
||||
*
|
||||
* Each tool: { id, name, paths(), apply(ctx) → string[] written, reset() → string[] touched }
|
||||
* ctx: { baseUrl (origin, no /v1), apiKey, model, claudeModels: { envKey: model } }
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
const home = () => os.homedir();
|
||||
const v1 = (baseUrl) => (baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`);
|
||||
|
||||
// Drop trailing commas (JSONC) outside string literals, so values like "a,}" survive.
|
||||
function stripTrailingCommas(text) {
|
||||
return text.replace(/("(?:\\.|[^"\\])*")|,(\s*[}\]])/g, (m, str, tail) => str ?? tail);
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
try {
|
||||
return JSON.parse(stripTrailingCommas(fs.readFileSync(file, "utf8")));
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") return null;
|
||||
throw new Error(`Cannot parse ${file}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Files hold the API key → owner-only (0600) on POSIX; no-op on Windows.
|
||||
const SECRET_MODE = 0o600;
|
||||
|
||||
// Rewrite an existing file on reset (no backup, existing mode kept).
|
||||
function rewriteFile(file, content) {
|
||||
fs.writeFileSync(file, content, { mode: SECRET_MODE });
|
||||
}
|
||||
|
||||
// One-time backup of the user's pre-9router file, then write.
|
||||
function writeFile(file, content) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const backup = `${file}.bak-9router`;
|
||||
if (fs.existsSync(file) && !fs.existsSync(backup)) {
|
||||
fs.copyFileSync(file, backup);
|
||||
fs.chmodSync(backup, SECRET_MODE);
|
||||
}
|
||||
fs.writeFileSync(file, content, { mode: SECRET_MODE });
|
||||
fs.chmodSync(file, SECRET_MODE); // mode above only applies when creating
|
||||
}
|
||||
|
||||
const writeJson = (file, data) => writeFile(file, JSON.stringify(data, null, 2));
|
||||
|
||||
// confbox is ESM-only; loaded lazily so non-codex runs don't need it.
|
||||
async function toml() {
|
||||
return import("confbox");
|
||||
}
|
||||
|
||||
// ── Claude Code ─────────────────────────────────────────────────────────────
|
||||
const CLAUDE_MODELS = [
|
||||
{ flag: "fable", envKey: "ANTHROPIC_DEFAULT_FABLE_MODEL", defaultValue: "cc/claude-fable-5" },
|
||||
{ flag: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-5" },
|
||||
{ flag: "sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-5" },
|
||||
{ flag: "haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" },
|
||||
];
|
||||
const CLAUDE_RESET_KEYS = ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", ...CLAUDE_MODELS.map((m) => m.envKey)];
|
||||
const claudePath = () => path.join(home(), ".claude", "settings.json");
|
||||
|
||||
const claude = {
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
paths: () => [claudePath()],
|
||||
async apply({ baseUrl, apiKey, claudeModels }) {
|
||||
const file = claudePath();
|
||||
const cur = readJson(file) || {};
|
||||
cur.hasCompletedOnboarding = true;
|
||||
cur.env = { ...(cur.env || {}), ANTHROPIC_BASE_URL: v1(baseUrl), ANTHROPIC_AUTH_TOKEN: apiKey, ...claudeModels };
|
||||
writeJson(file, cur);
|
||||
return [file];
|
||||
},
|
||||
async reset() {
|
||||
const file = claudePath();
|
||||
const cur = readJson(file);
|
||||
if (!cur) return [];
|
||||
if (cur.env) {
|
||||
CLAUDE_RESET_KEYS.forEach((k) => delete cur.env[k]);
|
||||
if (Object.keys(cur.env).length === 0) delete cur.env;
|
||||
}
|
||||
rewriteFile(file, JSON.stringify(cur, null, 2));
|
||||
return [file];
|
||||
},
|
||||
};
|
||||
|
||||
// ── OpenAI Codex CLI ────────────────────────────────────────────────────────
|
||||
const codexPath = () => path.join(home(), ".codex", "config.toml");
|
||||
|
||||
const codex = {
|
||||
id: "codex",
|
||||
name: "OpenAI Codex CLI",
|
||||
paths: () => [codexPath()],
|
||||
async apply({ baseUrl, apiKey, model }) {
|
||||
const { parseTOML, stringifyTOML } = await toml();
|
||||
const file = codexPath();
|
||||
let cfg = {};
|
||||
try { cfg = parseTOML(fs.readFileSync(file, "utf8")) || {}; } catch (err) { if (err.code !== "ENOENT") throw err; }
|
||||
cfg.model = model;
|
||||
cfg.model_provider = "9router";
|
||||
cfg.model_providers = cfg.model_providers || {};
|
||||
// Custom providers ignore auth.json — key must travel as a static header.
|
||||
cfg.model_providers["9router"] = {
|
||||
name: "9Router",
|
||||
base_url: v1(baseUrl),
|
||||
wire_api: "responses",
|
||||
http_headers: { Authorization: `Bearer ${apiKey}` },
|
||||
};
|
||||
cfg.agents = cfg.agents || {};
|
||||
delete cfg.agents.subagent;
|
||||
cfg.agents.default_subagent_model = model;
|
||||
writeFile(file, stringifyTOML(cfg));
|
||||
return [file];
|
||||
},
|
||||
async reset() {
|
||||
const { parseTOML, stringifyTOML } = await toml();
|
||||
const file = codexPath();
|
||||
let cfg;
|
||||
try { cfg = parseTOML(fs.readFileSync(file, "utf8")) || {}; } catch (err) { if (err.code === "ENOENT") return []; throw err; }
|
||||
if (cfg.model_provider === "9router") { delete cfg.model; delete cfg.model_provider; }
|
||||
if (cfg.model_providers) delete cfg.model_providers["9router"];
|
||||
if (cfg.agents) { delete cfg.agents.default_subagent_model; delete cfg.agents.subagent; }
|
||||
for (const k of ["model_providers", "agents"]) {
|
||||
if (cfg[k] && Object.keys(cfg[k]).length === 0) delete cfg[k];
|
||||
}
|
||||
rewriteFile(file, stringifyTOML(cfg));
|
||||
return [file];
|
||||
},
|
||||
};
|
||||
|
||||
// ── OpenCode ────────────────────────────────────────────────────────────────
|
||||
const opencodePath = () => path.join(home(), ".config", "opencode", "opencode.json");
|
||||
|
||||
const opencode = {
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
paths: () => [opencodePath()],
|
||||
async apply({ baseUrl, apiKey, model }) {
|
||||
const file = opencodePath();
|
||||
const cfg = readJson(file) || {};
|
||||
cfg.provider = cfg.provider || {};
|
||||
const p = cfg.provider["9router"] || { npm: "@ai-sdk/openai-compatible", options: {}, models: {} };
|
||||
p.options = { ...p.options, baseURL: v1(baseUrl), apiKey };
|
||||
p.models = p.models || {};
|
||||
p.models[model] = { name: model, modalities: { input: ["text", "image"], output: ["text"] } };
|
||||
cfg.provider["9router"] = p;
|
||||
cfg.model = `9router/${model}`;
|
||||
cfg.agent = cfg.agent || {};
|
||||
cfg.agent.explorer = {
|
||||
description: "Fast explorer subagent for codebase exploration",
|
||||
mode: "subagent",
|
||||
model: `9router/${model}`,
|
||||
};
|
||||
writeJson(file, cfg);
|
||||
return [file];
|
||||
},
|
||||
async reset() {
|
||||
const file = opencodePath();
|
||||
const cfg = readJson(file);
|
||||
if (!cfg) return [];
|
||||
if (cfg.provider) delete cfg.provider["9router"];
|
||||
if (cfg.model?.startsWith("9router/")) delete cfg.model;
|
||||
if (cfg.agent?.explorer?.model?.startsWith("9router/")) {
|
||||
delete cfg.agent.explorer;
|
||||
if (Object.keys(cfg.agent).length === 0) delete cfg.agent;
|
||||
}
|
||||
rewriteFile(file, JSON.stringify(cfg, null, 2));
|
||||
return [file];
|
||||
},
|
||||
};
|
||||
|
||||
// ── Factory Droid ───────────────────────────────────────────────────────────
|
||||
const droidPath = () => path.join(home(), ".factory", "settings.json");
|
||||
const isDroid9r = (m) => m.id?.startsWith("custom:9Router");
|
||||
|
||||
const droid = {
|
||||
id: "droid",
|
||||
name: "Factory Droid",
|
||||
paths: () => [droidPath()],
|
||||
async apply({ baseUrl, apiKey, model }) {
|
||||
const file = droidPath();
|
||||
const cfg = readJson(file) || {};
|
||||
const others = (cfg.customModels || []).filter((m) => !isDroid9r(m));
|
||||
cfg.customModels = [
|
||||
{
|
||||
model,
|
||||
id: "custom:9Router-0",
|
||||
index: 0,
|
||||
baseUrl: v1(baseUrl),
|
||||
apiKey,
|
||||
displayName: model,
|
||||
maxOutputTokens: 131072,
|
||||
noImageSupport: false,
|
||||
provider: "openai",
|
||||
},
|
||||
...others,
|
||||
];
|
||||
cfg.customModels.forEach((m, i) => { m.index = i; });
|
||||
writeJson(file, cfg);
|
||||
return [file];
|
||||
},
|
||||
async reset() {
|
||||
const file = droidPath();
|
||||
const cfg = readJson(file);
|
||||
if (!cfg) return [];
|
||||
if (cfg.customModels) {
|
||||
cfg.customModels = cfg.customModels.filter((m) => !isDroid9r(m));
|
||||
if (cfg.customModels.length === 0) delete cfg.customModels;
|
||||
}
|
||||
rewriteFile(file, JSON.stringify(cfg, null, 2));
|
||||
return [file];
|
||||
},
|
||||
};
|
||||
|
||||
// ── Crush ───────────────────────────────────────────────────────────────────
|
||||
const crushPath = () => path.join(process.env.XDG_CONFIG_HOME || path.join(home(), ".config"), "crush", "crush.json");
|
||||
|
||||
const crush = {
|
||||
id: "crush",
|
||||
name: "Crush",
|
||||
paths: () => [crushPath()],
|
||||
async apply({ baseUrl, apiKey, model }) {
|
||||
const file = crushPath();
|
||||
const cfg = readJson(file) || {};
|
||||
cfg.providers = cfg.providers || {};
|
||||
cfg.providers["9router"] = {
|
||||
type: "openai-compat",
|
||||
base_url: v1(baseUrl),
|
||||
api_key: apiKey,
|
||||
models: [{ id: model, name: model, context_window: 128000 }],
|
||||
};
|
||||
writeJson(file, cfg);
|
||||
return [file];
|
||||
},
|
||||
async reset() {
|
||||
const file = crushPath();
|
||||
const cfg = readJson(file);
|
||||
if (!cfg?.providers?.["9router"]) return [];
|
||||
delete cfg.providers["9router"];
|
||||
if (Object.keys(cfg.providers).length === 0) delete cfg.providers;
|
||||
rewriteFile(file, JSON.stringify(cfg, null, 2));
|
||||
return [file];
|
||||
},
|
||||
};
|
||||
|
||||
// ── Kilo Code (CLI auth only; VS Code settings left to the dashboard) ───────
|
||||
const kiloPath = () => path.join(home(), ".local", "share", "kilo", "auth.json");
|
||||
|
||||
const kilo = {
|
||||
id: "kilo",
|
||||
name: "Kilo Code CLI",
|
||||
paths: () => [kiloPath()],
|
||||
async apply({ baseUrl, apiKey, model }) {
|
||||
const file = kiloPath();
|
||||
const auth = readJson(file) || {};
|
||||
auth["openai-compatible"] = { type: "api-key", apiKey, baseUrl: v1(baseUrl), model };
|
||||
writeJson(file, auth);
|
||||
return [file];
|
||||
},
|
||||
async reset() {
|
||||
const file = kiloPath();
|
||||
const auth = readJson(file);
|
||||
if (!auth) return [];
|
||||
delete auth["openai-compatible"];
|
||||
delete auth["9router"];
|
||||
rewriteFile(file, JSON.stringify(auth, null, 2));
|
||||
return [file];
|
||||
},
|
||||
};
|
||||
|
||||
// ── Cline CLI ───────────────────────────────────────────────────────────────
|
||||
const clineDir = () => path.join(home(), ".cline", "data");
|
||||
const clineState = () => path.join(clineDir(), "globalState.json");
|
||||
const clineSecrets = () => path.join(clineDir(), "secrets.json");
|
||||
|
||||
const cline = {
|
||||
id: "cline",
|
||||
name: "Cline CLI",
|
||||
paths: () => [clineState(), clineSecrets()],
|
||||
async apply({ baseUrl, apiKey, model }) {
|
||||
const state = readJson(clineState()) || {};
|
||||
state.actModeApiProvider = "openai";
|
||||
state.planModeApiProvider = "openai";
|
||||
state.openAiBaseUrl = baseUrl; // Cline expects base WITHOUT /v1
|
||||
state.openAiModelId = model;
|
||||
state.planModeOpenAiModelId = model;
|
||||
writeJson(clineState(), state);
|
||||
const secrets = readJson(clineSecrets()) || {};
|
||||
secrets.openAiApiKey = apiKey;
|
||||
writeJson(clineSecrets(), secrets);
|
||||
return [clineState(), clineSecrets()];
|
||||
},
|
||||
async reset() {
|
||||
const state = readJson(clineState());
|
||||
if (!state) return [];
|
||||
if (state.actModeApiProvider === "openai") {
|
||||
delete state.openAiBaseUrl;
|
||||
delete state.openAiModelId;
|
||||
delete state.planModeOpenAiModelId;
|
||||
state.actModeApiProvider = "cline";
|
||||
state.planModeApiProvider = "cline";
|
||||
}
|
||||
rewriteFile(clineState(), JSON.stringify(state, null, 2));
|
||||
const touched = [clineState()];
|
||||
const secrets = readJson(clineSecrets());
|
||||
if (secrets) {
|
||||
delete secrets.openAiApiKey;
|
||||
rewriteFile(clineSecrets(), JSON.stringify(secrets, null, 2));
|
||||
touched.push(clineSecrets());
|
||||
}
|
||||
return touched;
|
||||
},
|
||||
};
|
||||
|
||||
const TOOLS = [claude, codex, opencode, droid, crush, kilo, cline];
|
||||
const TOOL_IDS = TOOLS.map((t) => t.id);
|
||||
const TOOL_ALIASES = { "claude-code": "claude", "claudecode": "claude", "factory": "droid", "kilocode": "kilo" };
|
||||
|
||||
function resolveTools(list) {
|
||||
const ids = new Set();
|
||||
for (const raw of list) {
|
||||
const id = String(raw).trim().toLowerCase();
|
||||
if (!id) continue;
|
||||
if (id === "all") { TOOL_IDS.forEach((t) => ids.add(t)); continue; }
|
||||
const real = TOOL_ALIASES[id] || id;
|
||||
if (!TOOL_IDS.includes(real)) throw new Error(`Unknown tool "${raw}". Supported: ${TOOL_IDS.join(", ")}, all`);
|
||||
ids.add(real);
|
||||
}
|
||||
return TOOLS.filter((t) => ids.has(t.id));
|
||||
}
|
||||
|
||||
module.exports = { TOOLS, TOOL_IDS, CLAUDE_MODELS, resolveTools, __test__: { stripTrailingCommas } };
|
||||
@@ -0,0 +1,169 @@
|
||||
// `9router connect` — arg parsing, URL/cookie helpers and per-tool config writers.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const connect = require("../../cli/src/cli/commands/connect.js");
|
||||
const tools = require("../../cli/src/cli/commands/connectTools.js");
|
||||
const { parseArgs, normalizeServerUrl, extractAuthCookie, maskKey } = connect.__test__;
|
||||
const { stripTrailingCommas } = tools.__test__;
|
||||
|
||||
const CTX = {
|
||||
baseUrl: "http://gw.test:20128",
|
||||
apiKey: "sk-unit-test-key-0000",
|
||||
model: "cc/claude-opus-5",
|
||||
claudeModels: { ANTHROPIC_DEFAULT_OPUS_MODEL: "cc/claude-opus-5" },
|
||||
};
|
||||
const tool = (id) => tools.TOOLS.find((t) => t.id === id);
|
||||
const readJson = (p) => JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
|
||||
describe("connect helpers", () => {
|
||||
it("parseArgs reads url, tools, model and claude tier overrides", () => {
|
||||
const o = parseArgs(["http://h:1", "--tools", "claude,codex", "--model", "m1", "--opus", "o1", "--password", "p"]);
|
||||
expect(o.url).toBe("http://h:1");
|
||||
expect(o.tools).toEqual(["claude", "codex"]);
|
||||
expect(o.model).toBe("m1");
|
||||
expect(o.models).toEqual({ opus: "o1" });
|
||||
expect(o.password).toBe("p");
|
||||
});
|
||||
|
||||
it("parseArgs rejects unknown options and missing values", () => {
|
||||
expect(() => parseArgs(["--nope"])).toThrow(/Unknown option/);
|
||||
expect(() => parseArgs(["--tools"])).toThrow(/Missing value/);
|
||||
});
|
||||
|
||||
it("normalizeServerUrl keeps only the origin and defaults to http", () => {
|
||||
expect(normalizeServerUrl("http://h:20128/dashboard/cli-tools")).toBe("http://h:20128");
|
||||
expect(normalizeServerUrl("h:20128")).toBe("http://h:20128");
|
||||
expect(normalizeServerUrl("https://h/v1/")).toBe("https://h");
|
||||
});
|
||||
|
||||
it("extractAuthCookie picks auth_token from Set-Cookie", () => {
|
||||
const h = new Headers();
|
||||
h.append("set-cookie", "other=1; Path=/");
|
||||
h.append("set-cookie", "auth_token=abc.def.ghi; Path=/; HttpOnly");
|
||||
expect(extractAuthCookie(h)).toBe("auth_token=abc.def.ghi");
|
||||
expect(extractAuthCookie(new Headers())).toBeNull();
|
||||
});
|
||||
|
||||
it("maskKey never reveals the middle of the key", () => {
|
||||
expect(maskKey("sk-1234567890abcdef")).toBe("sk-123…cdef");
|
||||
expect(maskKey("short")).toBe("****");
|
||||
});
|
||||
|
||||
it("stripTrailingCommas leaves commas inside strings alone", () => {
|
||||
expect(JSON.parse(stripTrailingCommas('{"a":"x,}","b":[1,2,],}'))).toEqual({ a: "x,}", b: [1, 2] });
|
||||
expect(JSON.parse(stripTrailingCommas('{"a":"q\\",}",}'))).toEqual({ a: 'q",}' });
|
||||
});
|
||||
|
||||
it("resolveTools handles aliases, all, and unknown names", () => {
|
||||
expect(tools.resolveTools(["claude-code", "factory"]).map((t) => t.id)).toEqual(["claude", "droid"]);
|
||||
expect(tools.resolveTools(["all"]).map((t) => t.id)).toEqual(tools.TOOL_IDS);
|
||||
expect(() => tools.resolveTools(["bogus"])).toThrow(/Unknown tool/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connect tool writers", () => {
|
||||
let home;
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "9r-connect-"));
|
||||
vi.spyOn(os, "homedir").mockReturnValue(home);
|
||||
vi.stubEnv("XDG_CONFIG_HOME", "");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("every tool applies then resets cleanly on an empty home", async () => {
|
||||
for (const t of tools.TOOLS) {
|
||||
const written = await t.apply(CTX);
|
||||
expect(written.length).toBeGreaterThan(0);
|
||||
// Key may live in just one of the files (cline: secrets.json).
|
||||
expect(written.some((f) => fs.readFileSync(f, "utf8").includes(CTX.apiKey))).toBe(true);
|
||||
if (process.platform !== "win32") {
|
||||
for (const f of written) expect(fs.statSync(f).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
await t.reset();
|
||||
for (const f of written) expect(fs.readFileSync(f, "utf8")).not.toContain(CTX.apiKey);
|
||||
}
|
||||
});
|
||||
|
||||
it("claude merges env and keeps unrelated settings; reset removes only 9router keys", async () => {
|
||||
const f = path.join(home, ".claude", "settings.json");
|
||||
fs.mkdirSync(path.dirname(f), { recursive: true });
|
||||
fs.writeFileSync(f, JSON.stringify({ theme: "dark", env: { KEEP: "1" } }));
|
||||
await tool("claude").apply(CTX);
|
||||
const cfg = readJson(f);
|
||||
expect(cfg.theme).toBe("dark");
|
||||
expect(cfg.env).toMatchObject({ KEEP: "1", ANTHROPIC_BASE_URL: "http://gw.test:20128/v1", ANTHROPIC_AUTH_TOKEN: CTX.apiKey });
|
||||
expect(fs.existsSync(`${f}.bak-9router`)).toBe(true);
|
||||
await tool("claude").reset();
|
||||
expect(readJson(f)).toEqual({ theme: "dark", hasCompletedOnboarding: true, env: { KEEP: "1" } });
|
||||
});
|
||||
|
||||
it("codex keeps other TOML tables and drops empty ones on reset", async () => {
|
||||
const f = path.join(home, ".codex", "config.toml");
|
||||
fs.mkdirSync(path.dirname(f), { recursive: true });
|
||||
fs.writeFileSync(f, '[mcp_servers.x]\ncommand = "foo"\n');
|
||||
await tool("codex").apply(CTX);
|
||||
const text = fs.readFileSync(f, "utf8");
|
||||
expect(text).toContain('model_provider = "9router"');
|
||||
expect(text).toContain("[mcp_servers.x]");
|
||||
await tool("codex").reset();
|
||||
const after = fs.readFileSync(f, "utf8");
|
||||
expect(after).toContain("[mcp_servers.x]");
|
||||
expect(after).not.toMatch(/9router|model_providers|\[agents\]/);
|
||||
});
|
||||
|
||||
it("droid keeps user models and puts 9router first", async () => {
|
||||
const f = path.join(home, ".factory", "settings.json");
|
||||
fs.mkdirSync(path.dirname(f), { recursive: true });
|
||||
fs.writeFileSync(f, JSON.stringify({ customModels: [{ id: "mine", model: "m" }] }));
|
||||
await tool("droid").apply(CTX);
|
||||
expect(readJson(f).customModels.map((m) => m.id)).toEqual(["custom:9Router-0", "mine"]);
|
||||
await tool("droid").reset();
|
||||
expect(readJson(f).customModels.map((m) => m.id)).toEqual(["mine"]);
|
||||
});
|
||||
|
||||
it("cline uses base URL without /v1 and reset reports both files", async () => {
|
||||
await tool("cline").apply(CTX);
|
||||
const state = readJson(path.join(home, ".cline", "data", "globalState.json"));
|
||||
expect(state.openAiBaseUrl).toBe("http://gw.test:20128");
|
||||
expect((await tool("cline").reset()).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connect run()", () => {
|
||||
let home;
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "9r-connect-run-"));
|
||||
vi.spyOn(os, "homedir").mockReturnValue(home);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("unknown tool fails before any network call", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
await expect(connect.run(["http://gw.test", "--tools", "bogus", "--password", "x"])).rejects.toThrow(/Unknown tool/);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reset keeps going when one tool fails and returns 1", async () => {
|
||||
const f = path.join(home, ".codex", "config.toml");
|
||||
fs.mkdirSync(path.dirname(f), { recursive: true });
|
||||
fs.writeFileSync(f, "this is = = not toml [[[");
|
||||
const code = await connect.run(["--reset", "--tools", "codex,claude"]);
|
||||
expect(code).toBe(1);
|
||||
const lines = console.log.mock.calls.map((c) => c[0]);
|
||||
expect(lines.some((l) => l.startsWith("❌ OpenAI Codex CLI"))).toBe(true);
|
||||
expect(lines.some((l) => l.includes("Claude Code"))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user