feat(cli): add connect command for remote 9router servers

This commit is contained in:
B1nh M1nh authored and decolua committed 2026-10-01 10:14:17 +07:00
1 parent ca6e8407f1
commit 31704db73e
7 files changed
+815 -1

No files matched your search

+18
View File
@@ -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
View File
@@ -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)
+4
View File
@@ -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
View File
@@ -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.",
+269
View File
@@ -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 } };
+338
View File
@@ -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 } };
+169
View File
@@ -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);
});
});