diff --git a/cli/README.md b/cli/README.md index 19ad230f..60d9be55 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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://:20128 # pick tools interactively +npx 9router connect http://: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-`, 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 diff --git a/cli/cli.js b/cli/cli.js index 703039dd..f3032f9b 100755 --- a/cli/cli.js +++ b/cli/cli.js @@ -80,6 +80,19 @@ if (args[0] === "xai" && args[1] === "video") { return; } +// `9router connect ` 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 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) diff --git a/cli/hooks/postinstall.js b/cli/hooks/postinstall.js index 3a59332f..728b9406 100644 --- a/cli/hooks/postinstall.js +++ b/cli/hooks/postinstall.js @@ -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"); diff --git a/cli/package.json b/cli/package.json index 1a67862a..86755891 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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.", diff --git a/cli/src/cli/commands/connect.js b/cli/src/cli/commands/connect.js new file mode 100644 index 00000000..700d3cb9 --- /dev/null +++ b/cli/src/cli/commands/connect.js @@ -0,0 +1,269 @@ +/** + * `9router connect ` β€” 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 [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://:20128 + npx 9router connect http://:20128 --tools claude,codex,opencode + +Options: + --tools Comma-separated tools to configure (prompted if omitted + in a terminal; default: claude). Supported: + ${TOOL_IDS.join(", ")}, all + --password Dashboard password (or env NINE_ROUTER_PASSWORD; + prompted if omitted β€” preferred, keeps it out of shell history) + --key-name API key name to reuse/create (default: cli-) + --api-key Use this API key, skip login + key lookup + --model Model for non-Claude tools (default: ${DEFAULT_MODEL}) + --fable|--opus|--sonnet|--haiku + 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} \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 } }; diff --git a/cli/src/cli/commands/connectTools.js b/cli/src/cli/commands/connectTools.js new file mode 100644 index 00000000..aa7cb6a0 --- /dev/null +++ b/cli/src/cli/commands/connectTools.js @@ -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/-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 } }; diff --git a/tests/unit/cli-connect.test.js b/tests/unit/cli-connect.test.js new file mode 100644 index 00000000..c20643a6 --- /dev/null +++ b/tests/unit/cli-connect.test.js @@ -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); + }); +});