/** * `auto-model-router connect --url --key `: point this machine * at a remote router (a shared one on a LAN, or the team edition). Writes * `/remote.json` (the omp extensions then run in remote mode and * never bind a local router), and configures every harness it finds: * * omp the four extensions are added to ~/.omp/agent/config.yml, and * the remote is written into ~/.omp/agent/models.yml so * `auto-model-router/auto` resolves at STARTUP — omp builds the * main model's handle before extensions load, so without that * entry only the late-resolved roles (smol, tiny) reach the * router and the main turns fall back to another provider * Hermes the provider plugin and the native plugin are copied into * $HERMES_HOME/plugins and .env points them at the remote * Codex ~/.codex/config.toml gains the auto-model-router provider * Aider ~/.aider.conf.yml gains the base URL, key and model * Claude Code ~/.claude/settings.json gains the base URL (its `env` block) and * `apiKeyHelper` running `auto-model-router token`, so no key * sits in its environment or on disk for it * OpenCode ~/.config/opencode/opencode.json gains the provider block, and * plugin/ the native plugin * Cline ~/.cline/data/settings/providers.json gains the * openai-compatible provider — the CLI and the extension share it * Continue ~/.continue/config.yaml gains the three profiles as models * * Cursor and Windsurf keep their provider settings in application state rather * than in a file, so they are PRINTED, not written; see configureExtraHarnesses * for where that line is drawn and why. * * Every write is idempotent and announced. `--profile` persists the * environment lines (shell rc on POSIX, user environment on Windows). * `--dry-run` prints what would change. Honours PI_CODING_AGENT_DIR, * HERMES_HOME and AUTO_MODEL_ROUTER_HOME, so a test can point it anywhere. */ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir, hostname } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { readRemoteRouter, refreshAccountOf, remoteFilePath } from "../../omp-extension/remote-logic.ts"; import { ORIGIN_ENV, SCOPE_ENV } from "../context/scope.ts"; import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts"; import { fetchSkills, installSkills, type SkillsBundle, type SkillsInstallReport, type SkillsTarget } from "./skills.ts"; import { contextAccountOf, pickStore, saveContextToken, saveRefreshToken, type StoreDeps, type StoreKind } from "./credential-store.ts"; import { ensureContextToken, type ContextToken, type McpAuth } from "./context-token.ts"; import { flagString, type CliArgs } from "./args.ts"; import { cursorSnippet, mergeClineProviders, mergeContinueConfig, mergeOpenCodeConfig, windsurfSnippet, type ManualSnippet } from "./harnesses.ts"; export interface ConnectOptions { url: string; key: string; userId: string; name: string; profile: boolean; dryRun: boolean; /** Restrict to these harnesses (omp, hermes, codex, aider, claude, opencode, cline, continue, cursor, windsurf); empty ⇒ every one detected. */ only: string[]; env: Record; home: string; /** Where this package lives (the extensions are referenced from here). */ packageDir: string; /** Cost figures omp shows for the remote's virtual models, USD per million tokens. */ blend?: { inputPerMtok: number; outputPerMtok: number }; /** Pins `X-Agentdox-Scope` in omp's models.yml entry to one slug, machine-wide. Without it the entry follows the workspace (see renderRemoteModelsYml). Undefined keeps what the managed block already has. */ agentdoxScope?: string; /** Short-lived credential fields from a remote that issues them; absent for a permanent key. */ refreshToken?: string; /** Which store takes the refresh token; picked from the platform when absent. Tests inject a backend. */ store?: StoreKind; storeDeps?: StoreDeps; keyExpiresAtMs?: number; refreshExpiresAtMs?: number; device?: string; /** * The compiled executable running this, when one is (see embedded.ts). It * becomes Claude Code's key helper and goes on PATH with --profile, and * remote.json records it so a refresh from omp keeps pointing at it. */ exePath?: string; /** The remote's skills bundle, installed into every configured harness that reads user-level skills. */ skills?: SkillsBundle; /** * The remote's MCP endpoint (a team edition serving shared context): written as the * `team-context` server for omp and Claude Code. `null` removes an entry a previous * connect wrote. * * `token` is the team's **context token** when it mints them (`/setup/info` says * `mcpAuth: "context-token"`): a year-long credential good for nothing but that member's * shared context, which is what the entry carries so a key rotation every 72 hours does * not kill a running MCP client. It is filed in the OS credential store like the refresh * token, and remote.json records only the store, the expiry and the id. Without one — * an older team edition — the entry carries the access key exactly as it always did. */ mcp?: { url: string | null; token?: string; tokenExpiresAtMs?: number; tokenId?: string }; platform: string; pathHas: (bin: string) => boolean; } export interface ConnectReport { remoteFile: string; configured: string[]; skipped: string[]; envLines: string[]; notes: string[]; /** * Harnesses whose provider settings live in application state rather than a * documented file: what to paste into their settings UI. Never a guess at a * file — see harnesses.ts for why the line is drawn on confidence. */ manual: ManualSnippet[]; /** What the remote's skills bundle did, when there was one. */ skills?: SkillsInstallReport; /** Files that gained (or lost) the team-context MCP server. */ mcp?: string[]; } /** The name of the MCP server connect manages in a harness's config. */ export const MCP_SERVER_NAME = "team-context"; /** * Merges the team-context server into an `mcpServers` JSON file (omp's mcp.json, Claude * Code's ~/.claude.json), leaving every other key and server alone; `url` null removes it. * `bearer` is the team's context token when it mints one, else the member's access key. * Returns the new text, or null when nothing changes. */ export function mergeMcpServers(before: string, url: string | null, bearer: string): string | null { let root: Record = {}; if (before.trim() !== "") { try { const parsed = JSON.parse(before) as unknown; if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; root = parsed as Record; } catch { return null; // a file we cannot parse is not ours to rewrite } } const servers = { ...((root.mcpServers as Record | undefined) ?? {}) }; if (url === null) { if (!(MCP_SERVER_NAME in servers)) return null; delete servers[MCP_SERVER_NAME]; } else { const next = { type: "http", url, headers: { Authorization: `Bearer ${bearer}` } }; if (JSON.stringify(servers[MCP_SERVER_NAME]) === JSON.stringify(next)) return null; servers[MCP_SERVER_NAME] = next; } return `${JSON.stringify({ ...root, mcpServers: servers }, null, 2)}\n`; } const expand = (raw: string, home: string): string => (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(home, raw.slice(1)) : raw); function routerHomeOf(o: ConnectOptions): string { return expand(o.env.AUTO_MODEL_ROUTER_HOME ?? join(o.home, ".auto-model-router"), o.home); } function ompAgentDir(o: ConnectOptions): string { const d = o.env.PI_CODING_AGENT_DIR; return d !== undefined && d !== "" ? expand(d, o.home) : join(o.home, ".omp", "agent"); } function hermesHome(o: ConnectOptions): string { const d = o.env.HERMES_HOME; if (d !== undefined && d !== "") return expand(d, o.home); return o.platform === "win32" ? join(o.env.LOCALAPPDATA ?? join(o.home, "AppData", "Local"), "hermes") : join(o.home, ".hermes"); } const wants = (o: ConnectOptions, h: string): boolean => o.only.length === 0 || o.only.includes(h); /** Adds lines to a YAML `extensions:` list by text, keeping everything else byte-identical. */ export function addExtensions(text: string, paths: readonly string[]): string { const eol = text.includes("\r\n") ? "\r\n" : "\n"; const missing = paths.filter((p) => !text.includes(p)); if (missing.length === 0) return text; const lines = missing.map((p) => ` - ${p}`); const m = /^extensions:[ \t]*\r?\n/m.exec(text); if (m === null) return `${text}${text.endsWith("\n") || text === "" ? "" : eol}extensions:${eol}${lines.join(eol)}${eol}`; const at = m.index + m[0].length; return `${text.slice(0, at)}${lines.join(eol)}${eol}${text.slice(at)}`; } /** Sets `KEY=value` lines in a dotenv-style file, replacing existing keys. */ export function setDotenv(text: string, values: Record): string { const eol = text.includes("\r\n") ? "\r\n" : "\n"; let out = text; for (const [k, v] of Object.entries(values)) { const re = new RegExp(`^${k}=.*$`, "m"); if (re.test(out)) out = out.replace(re, `${k}=${v}`); else out = `${out}${out === "" || out.endsWith("\n") ? "" : eol}${k}=${v}${eol}`; } return out; } export function codexBlock(url: string): string { return ` [model_providers.auto-model-router] name = "auto-model-router (remote)" base_url = "${url}/v1" env_key = "AUTO_MODEL_ROUTER_API_KEY" wire_api = "responses" http_headers = { "X-Omp-Harness" = "codex" } `; } /** The models a remote router advertises, and what omp should believe they cost. */ const REMOTE_MODEL_ROWS: readonly { id: string; name: string }[] = [ { id: "auto", name: "Auto (auto-model-router)" }, { id: "auto-cheap", name: "Auto Cheap (auto-model-router)" }, { id: "auto-max", name: "Auto Max (auto-model-router)" }, ]; const MODELS_YML_BEGIN = " # BEGIN auto-model-router (remote)"; const MODELS_YML_END = " # END auto-model-router (remote)"; /** * omp's `models.yml` entry for a remote router. * * A LOCAL router deliberately never writes this file: its port is ephemeral, so * a persisted entry names a dead socket on the next launch. A remote router has * neither problem — the URL and the key are stable — and the entry is what makes * omp's main model resolvable at startup, before extensions load. * * `X-Agentdox-Scope` on this entry reaches the MAIN model's turns, which the * extensions' own registration cannot (they load after the handle is built). * The file is machine-wide, so a literal slug here would label every * workspace's turns with one project; by default the value is the NAME of * `SCOPE_ENV`, which omp resolves from its environment per request, and the * embed extension sets that variable from the workspace folder as it loads. * `scope` pins a literal slug instead, for a single-project machine. * * `X-Agentdox-Origin` always names `ORIGIN_ENV`, which the extension sets from * the workspace's git remote: a pinned scope says which project's context to * draw on, and the origin still says which repository the turn came from. */ export function renderRemoteModelsYml(url: string, key: string, blend: { inputPerMtok: number; outputPerMtok: number }, scope = ""): string { const round = (v: number): number => Math.round(v * 1e4) / 1e4; const cost = { input: round(blend.inputPerMtok), output: round(blend.outputPerMtok), cacheRead: round(blend.inputPerMtok * 0.1), cacheWrite: round(blend.inputPerMtok * 1.25), }; const lines = [ MODELS_YML_BEGIN, " # Managed by `auto-model-router connect`. Remove this block to stop routing omp through the remote.", " auto-model-router:", ` baseUrl: ${url.replace(/\/+$/, "")}/v1`, " api: openai-completions", ` apiKey: ${key}`, ]; lines.push(" headers:", ` X-Agentdox-Scope: ${scope !== "" ? scope : SCOPE_ENV}`, ` X-Agentdox-Origin: ${ORIGIN_ENV}`); lines.push(" models:"); for (const m of REMOTE_MODEL_ROWS) { lines.push( ` - id: ${m.id}`, ` name: ${m.name}`, " contextWindow: 200000", " maxTokens: 32000", " input: [text, image]", ` cost: { input: ${cost.input}, output: ${cost.output}, cacheRead: ${cost.cacheRead}, cacheWrite: ${cost.cacheWrite} }`, ); } lines.push(MODELS_YML_END); return lines.join("\n"); } /** * Merges the remote block into an existing `models.yml`, replacing a previous * one and leaving every other provider alone. Returns the new file text. */ export function mergeModelsYml(before: string, blockText: string): string { const eol = before.includes("\r\n") ? "\r\n" : "\n"; const body = before.replace(/^\uFEFF/, ""); const block = blockText.split("\n").join(eol); const begin = body.indexOf(MODELS_YML_BEGIN); if (begin >= 0) { const endIdx = body.indexOf(MODELS_YML_END, begin); const end = endIdx < 0 ? body.length : endIdx + MODELS_YML_END.length; return `${body.slice(0, begin)}${block}${body.slice(end)}`; } // A provider entry for the same id from an earlier local install would shadow // ours; the caller reports it rather than editing a block it does not own. if (body.trim() === "") return `providers:${eol}${block}${eol}`; if (/^providers:\s*$/m.test(body)) { return body.replace(/^providers:\s*$/m, (m) => `${m}${eol}${block}`); } return `${body.replace(/\s*$/, "")}${eol}providers:${eol}${block}${eol}`; } /** The literal `X-Agentdox-Scope` the managed block pins, or "" when it follows the workspace (or has none). */ export function existingBlockScope(text: string): string { const begin = text.indexOf(MODELS_YML_BEGIN); if (begin < 0) return ""; const end = text.indexOf(MODELS_YML_END, begin); const block = text.slice(begin, end < 0 ? text.length : end); const m = /X-Agentdox-Scope:\s*(\S+)/.exec(block); const value = m?.[1] ?? ""; return value === SCOPE_ENV ? "" : value; } /** True when the file already defines our provider outside a block we manage. */ export function hasForeignRouterProvider(text: string): boolean { if (text.includes(MODELS_YML_BEGIN)) return false; return /^\s{2,}auto-model-router:\s*$/m.test(text); } export function connectRemote(o: ConnectOptions): ConnectReport { const report: ConnectReport = { remoteFile: "", configured: [], skipped: [], envLines: [], notes: [], manual: [] }; const write = (path: string, content: string): void => { if (o.dryRun) return; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content, "utf8"); }; // 1. remote.json: what puts the omp extensions into remote mode. const rh = routerHomeOf(o); report.remoteFile = remoteFilePath(rh); const previous = existsSync(report.remoteFile) ? (JSON.parse(readFileSync(report.remoteFile, "utf8")) as Record) : {}; // The refresh token is the long-lived secret: it goes to the OS credential store, and // remote.json only says which one. The access key stays in the file: it is short-lived, // and the extensions need it without a subprocess on every poll. let refreshTokenStore: StoreKind | undefined; const refreshAccount = refreshAccountOf(o.url, o.userId); if (o.refreshToken !== undefined && o.refreshToken !== "" && !o.dryRun) { const wanted = o.store ?? pickStore(o.platform, o.pathHas); refreshTokenStore = saveRefreshToken(rh, refreshAccount, o.refreshToken, wanted, o.storeDeps ?? { pathHas: o.pathHas }); if (refreshTokenStore !== wanted) report.notes.push(`the ${wanted} credential store was not usable; the refresh token is in ${join(rh, "refresh.token")} (owner-readable only)`); } else if (o.refreshToken !== undefined && o.refreshToken !== "") refreshTokenStore = o.store ?? pickStore(o.platform, o.pathHas); // The context token is the other long-lived secret and goes to the same store, in its own // slot. When this connect learned nothing about one (a plain router, or a /setup/info that // did not answer) what the previous remote.json recorded is carried forward rather than // dropped: the store still holds that token, and forgetting it would orphan it for a year. let contextTokenStore: StoreKind | undefined; const contextAccount = contextAccountOf(refreshAccount); const contextToken = o.mcp?.token !== undefined && o.mcp.token !== "" ? o.mcp.token : undefined; if (contextToken !== undefined && !o.dryRun) { const wanted = o.store ?? pickStore(o.platform, o.pathHas); contextTokenStore = saveContextToken(rh, contextAccount, contextToken, wanted, o.storeDeps ?? { pathHas: o.pathHas }); if (contextTokenStore !== wanted) report.notes.push(`the ${wanted} credential store was not usable; the shared-context token is in ${join(rh, "context.token")} (owner-readable only)`); } else if (contextToken !== undefined) contextTokenStore = o.store ?? pickStore(o.platform, o.pathHas); const contextFields = contextToken !== undefined ? { contextTokenStore: contextTokenStore!, contextAccount, ...(o.mcp?.tokenExpiresAtMs === undefined ? {} : { contextTokenExpiresAtMs: o.mcp.tokenExpiresAtMs }), ...(o.mcp?.tokenId === undefined ? {} : { contextTokenId: o.mcp.tokenId }) } : { ...(typeof previous.contextTokenStore === "string" ? { contextTokenStore: previous.contextTokenStore } : {}), ...(typeof previous.contextAccount === "string" ? { contextAccount: previous.contextAccount } : {}), ...(typeof previous.contextTokenExpiresAtMs === "number" ? { contextTokenExpiresAtMs: previous.contextTokenExpiresAtMs } : {}), ...(typeof previous.contextTokenId === "string" ? { contextTokenId: previous.contextTokenId } : {}), }; write( report.remoteFile, `${JSON.stringify( { url: o.url, key: o.key, userId: o.userId, name: o.name, joinedAtMs: typeof previous.joinedAtMs === "number" ? previous.joinedAtMs : Date.now(), ...(refreshTokenStore !== undefined ? { refreshTokenStore, refreshAccount } : {}), ...contextFields, ...(o.keyExpiresAtMs !== undefined ? { keyExpiresAtMs: o.keyExpiresAtMs } : {}), ...(o.refreshExpiresAtMs !== undefined ? { refreshExpiresAtMs: o.refreshExpiresAtMs } : {}), ...(o.device !== undefined && o.device !== "" ? { device: o.device } : {}), ...(o.exePath !== undefined && o.exePath !== "" ? { executable: o.exePath } : {}), }, null, 2, )}\n`, ); // 2. omp const agentDir = ompAgentDir(o); if (wants(o, "omp") && existsSync(agentDir)) { const cfgPath = join(agentDir, "config.yml"); const ext = ["router-toast", "router-embed", "router-configure", "router-digest"].map((n) => resolve(o.packageDir, "omp-extension", `${n}.ts`).replaceAll("\\", "/")); const before = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : ""; const after = addExtensions(before, ext); if (after !== before) write(cfgPath, after); // models.yml: what makes the MAIN model resolvable, since omp builds that // handle at startup, before the extensions register anything. const modelsPath = join(agentDir, "models.yml"); const modelsBefore = existsSync(modelsPath) ? readFileSync(modelsPath, "utf8") : ""; if (hasForeignRouterProvider(modelsBefore)) { report.notes.push(`${modelsPath} already defines an auto-model-router provider by hand; left alone — remove it to let connect manage the remote entry`); report.configured.push(`omp (${cfgPath}; extensions only)`); } else { // A refresh re-writes the block without knowing the scope: keep the one already there. const scope = o.agentdoxScope ?? existingBlockScope(modelsBefore); const modelsAfter = mergeModelsYml(modelsBefore, renderRemoteModelsYml(o.url, o.key, o.blend ?? { inputPerMtok: 1.1, outputPerMtok: 4.4 }, scope)); if (modelsAfter !== modelsBefore) { // Never overwrite another provider's work without a way back. if (modelsBefore !== "" && !o.dryRun) writeFileSync(`${modelsPath}.${new Date().toISOString().replaceAll(":", "-")}.bak`, modelsBefore, "utf8"); write(modelsPath, modelsAfter); } report.configured.push(`omp (${cfgPath} + ${modelsPath}; auto-model-router/auto is ready to pick)`); } } else report.skipped.push("omp (no ~/.omp/agent)"); // 3. Hermes const hh = hermesHome(o); if (wants(o, "hermes") && existsSync(hh)) { if (!o.dryRun) { cpSync(join(o.packageDir, "hermes-plugin"), join(hh, "plugins", "model-providers", "auto-model-router"), { recursive: true }); cpSync(join(o.packageDir, "hermes-plugin", "native"), join(hh, "plugins", "auto-model-router"), { recursive: true }); } const envPath = join(hh, ".env"); write(envPath, setDotenv(existsSync(envPath) ? readFileSync(envPath, "utf8") : "", { AUTO_MODEL_ROUTER_URL: o.url, AUTO_MODEL_ROUTER_API_KEY: o.key })); report.configured.push(`Hermes (${hh}/plugins; restart Hermes and select auto-model-router/auto)`); } else report.skipped.push("Hermes (no HERMES_HOME)"); // 4. Codex const codexDir = join(o.home, ".codex"); if (wants(o, "codex") && existsSync(codexDir)) { const p = join(codexDir, "config.toml"); const before = existsSync(p) ? readFileSync(p, "utf8") : ""; if (!before.includes("[model_providers.auto-model-router]")) write(p, before + codexBlock(o.url)); report.configured.push(`Codex (${p}; set model = "auto" and model_provider = "auto-model-router")`); report.envLines.push(`AUTO_MODEL_ROUTER_API_KEY=${o.key}`); } else report.skipped.push("Codex (no ~/.codex)"); // 5. Aider const aiderConf = join(o.home, ".aider.conf.yml"); if (wants(o, "aider") && (existsSync(aiderConf) || o.pathHas("aider"))) { const before = existsSync(aiderConf) ? readFileSync(aiderConf, "utf8") : ""; if (!before.includes("openai-api-base:")) write(aiderConf, `${before}${before === "" || before.endsWith("\n") ? "" : "\n"}openai-api-base: ${o.url}/v1\nopenai-api-key: ${o.key}\nmodel: openai/auto\n`); report.configured.push(`Aider (${aiderConf})`); } else report.skipped.push("Aider (not found)"); // 6. Claude Code: its settings file carries the base URL (the `env` block) and a key // helper, a command it runs for the key — so the key is never in its environment or // on disk for it. Without a refresh token the helper still works (it prints the key it // holds); the helper is what lets a short-lived key rotate underneath a running session. const claudeDir = join(o.home, ".claude"); if (wants(o, "claude") && (o.pathHas("claude") || existsSync(claudeDir))) { const settingsPath = join(claudeDir, "settings.json"); let settings: Record = {}; const before = existsSync(settingsPath) ? readFileSync(settingsPath, "utf8") : ""; try { settings = before === "" ? {} : (JSON.parse(before) as Record); } catch { report.notes.push(`${settingsPath} is not valid JSON; left alone — set env.ANTHROPIC_BASE_URL and apiKeyHelper by hand`); settings = {}; } const env: Record = { ...((settings.env as Record | undefined) ?? {}), ANTHROPIC_BASE_URL: o.url }; // Never leave a stale key beside the helper: the helper is the source now. delete env.ANTHROPIC_API_KEY; // The executable is its own helper; under bun the source entry is. const helper = o.exePath !== undefined && o.exePath !== "" ? `"${o.exePath.replaceAll("\\", "/")}" token` : `bun run "${resolve(o.packageDir, "src", "index.ts").replaceAll("\\", "/")}" token`; const next = { ...settings, env, apiKeyHelper: helper }; const after = `${JSON.stringify(next, null, 2)}\n`; if (after !== before) { if (before !== "" && !o.dryRun) writeFileSync(`${settingsPath}.${new Date().toISOString().replaceAll(":", "-")}.bak`, before, "utf8"); write(settingsPath, after); } report.configured.push(`Claude Code (${settingsPath}: env.ANTHROPIC_BASE_URL + apiKeyHelper; open a new session)`); } else report.skipped.push("Claude Code (not on PATH and no ~/.claude)"); configureExtraHarnesses(o, report, write); report.envLines.unshift(`AUTO_MODEL_ROUTER_URL=${o.url}`, `AUTO_MODEL_ROUTER_API_KEY=${o.key}`); report.envLines = [...new Set(report.envLines)]; // 6b. The remote's skills, into the harnesses configured above that read a // user-level skills directory. Hermes, Codex and Aider have none we know of. if (o.skills !== undefined) { const targets: SkillsTarget[] = []; if (report.configured.some((c) => c.startsWith("omp ("))) targets.push({ harness: "omp", dir: join(agentDir, "skills") }); if (report.configured.some((c) => c.startsWith("Claude Code ("))) targets.push({ harness: "Claude Code", dir: join(claudeDir, "skills") }); report.skills = installSkills(o.skills, targets, rh, o.dryRun); if (report.skills.placed.length > 0) report.configured.push(`skills ${o.skills.version} (${report.skills.placed.join(", ")})`); for (const s of report.skills.skipped) report.notes.push(`skill ${s}`); } // 6c. The remote's MCP endpoint (shared context tools), for the harnesses configured above // that read a user-level mcpServers file. The bearer is the team's context token when it // mints one — an MCP client reads its configuration once at startup, so an entry carrying // the 72-hour access key 401s mid-session every few days. Without a context token (an // older team edition) it is the access key, rewritten by every refresh as it always was. if (o.mcp !== undefined) { const files: string[] = []; if (report.configured.some((c) => c.startsWith("omp ("))) files.push(join(agentDir, "mcp.json")); if (report.configured.some((c) => c.startsWith("Claude Code ("))) files.push(join(o.home, ".claude.json")); report.mcp = []; for (const path of files) { const before = existsSync(path) ? readFileSync(path, "utf8") : ""; const after = mergeMcpServers(before, o.mcp.url, contextToken ?? o.key); if (after === null) continue; write(path, after); report.mcp.push(path); } if (o.mcp.url !== null && files.length > 0) report.configured.push(`MCP (${MCP_SERVER_NAME} → ${o.mcp.url}: ${files.map((f) => f.replaceAll("\\", "/")).join(", ")})`); } // 7. Persist the environment. if (o.profile && !o.dryRun) { if (o.platform === "win32") { for (const line of report.envLines) { const [k, ...v] = line.split("="); Bun.spawnSync(["setx", k!, v.join("=")], { stdout: "ignore", stderr: "ignore" }); } // Not setx for PATH: it truncates at 1024 characters and would eat the rest. if (o.exePath !== undefined && o.exePath !== "") addToUserPathWindows(dirname(o.exePath)); report.notes.push("user environment variables set with setx; open a new terminal"); } else { const shell = o.env.SHELL ?? ""; const rc = shell.includes("zsh") ? join(o.home, ".zshrc") : join(o.home, ".bashrc"); const pathLine = o.exePath !== undefined && o.exePath !== "" ? [`export PATH="${dirname(o.exePath)}:$PATH"`] : []; const block = `\n# auto-model-router remote (added by \`auto-model-router connect\`)\n${[...report.envLines.map((l) => `export ${l}`), ...pathLine].join("\n")}\n`; const before = existsSync(rc) ? readFileSync(rc, "utf8") : ""; if (!before.includes("# auto-model-router remote") && !before.includes("# auto-model-router team")) appendFileSync(rc, block, "utf8"); else write(rc, before.replace(/\n# auto-model-router (?:remote|team)[^\n]*\n(?:export [^\n]*\n)*/, block)); report.notes.push(`environment appended to ${rc}; open a new shell or source it`); } } else report.notes.push("add the environment lines to your shell profile, or re-run with --profile"); report.notes.push("omp's models.yml now carries the member key; treat that file as a secret"); if (o.refreshToken !== undefined && o.refreshToken !== "") report.notes.push("the key is short-lived: omp refreshes it at session start; `auto-model-router refresh` does it by hand, and `auto-model-router token` prints a current key for a harness key-helper"); return report; } /** * The harnesses added in 0.20.0, kept out of `connectRemote` only for its * length: same contract, same report, same idempotence. * * Two shapes appear here. The automated ones own a documented config file, and * `connect` merges its own keys into it (harnesses.ts holds those merges, and * says why each format is trusted). The manual ones keep their provider * settings in application state — a VS Code `globalState` blob, an IDE's own * database — where there is no file to edit, so `connect` prints the values to * paste and says so in the report rather than writing something that would look * like success and do nothing. * * A manual harness is announced when it is INSTALLED, or when `--harness` named * it outright; a user without Cursor should not be read a Cursor recipe. */ function configureExtraHarnesses(o: ConnectOptions, report: ConnectReport, write: (path: string, content: string) => void): void { const scope = o.agentdoxScope ?? ""; const named = (h: string): boolean => o.only.includes(h); // OpenCode: the provider block the README documents, plus the plugin that // gives it session identity, the toast and the digest — the same two-part // install Hermes gets, since OpenCode is the other harness with a real hook API. const ocDir = openCodeDir(o); if (wants(o, "opencode") && (existsSync(ocDir) || o.pathHas("opencode"))) { const p = join(ocDir, "opencode.json"); const before = existsSync(p) ? readFileSync(p, "utf8") : ""; const after = mergeOpenCodeConfig(before, o.url, o.key, scope); if (after === null && before.trim() !== "" && !isJsonObject(before)) { report.notes.push(`${p} is not a JSON object; left alone — add the auto-model-router provider by hand`); } else if (after !== null) { if (before !== "" && !o.dryRun) writeFileSync(`${p}.${backupStamp()}.bak`, before, "utf8"); write(p, after); } if (!o.dryRun) cpSync(join(o.packageDir, "opencode-plugin", "auto-model-router.ts"), join(ocDir, "plugin", "auto-model-router.ts")); report.configured.push(`OpenCode (${p} + ${join(ocDir, "plugin")}; model auto-model-router/auto)`); } else report.skipped.push("OpenCode (not on PATH and no ~/.config/opencode)"); // Cline: one provider store for the CLI and, since its settings migration, the // VS Code extension — so this single write serves both, and every editor that // hosts the extension (see the Windsurf snippet below). const clineDataDir = clineDir(o); let clineConfigured = false; if (wants(o, "cline") && (existsSync(clineDataDir) || o.pathHas("cline"))) { const p = join(clineDataDir, "settings", "providers.json"); const before = existsSync(p) ? readFileSync(p, "utf8") : ""; const after = mergeClineProviders(before, o.url, o.key, new Date().toISOString(), scope); if (after === null && before.trim() !== "" && !isJsonObject(before)) { report.notes.push(`${p} is not a JSON object; left alone — run \`cline auth -p openai -b ${o.url}/v1 -k -m auto\` instead`); } else if (after !== null) { if (before !== "" && !o.dryRun) writeFileSync(`${p}.${backupStamp()}.bak`, before, "utf8"); write(p, after); } clineConfigured = true; report.configured.push(`Cline (${p}; \`cline -m auto\`, or the VS Code extension)`); } else report.skipped.push("Cline (not on PATH and no ~/.cline)"); // Continue: one assistant file holds the models, so the three profiles go in as // three entries and the user picks between them in the model dropdown. const continueDir = join(o.home, ".continue"); if (wants(o, "continue") && (existsSync(continueDir) || o.pathHas("cn"))) { const p = join(continueDir, "config.yaml"); const before = existsSync(p) ? readFileSync(p, "utf8") : ""; const after = mergeContinueConfig(before, o.url, o.key, scope); if (after === null && before.trim() !== "") { report.notes.push(`${p} is not an assistant file we can edit; left alone — add the models entry by hand`); } else if (after !== null) { if (before !== "" && !o.dryRun) writeFileSync(`${p}.${backupStamp()}.bak`, before, "utf8"); write(p, after); // config.yaml wins over the older config.json, so say so rather than let a // user wonder why the settings they had stopped applying. if (before === "" && existsSync(join(continueDir, "config.json"))) report.notes.push(`${join(continueDir, "config.json")} is Continue's older format and config.yaml now takes precedence over it`); } report.configured.push(`Continue (${p}; pick auto-model-router in the model dropdown)`); } else report.skipped.push("Continue (no ~/.continue)"); // Cursor and Windsurf keep provider settings where no file can reach them. if (wants(o, "cursor") && (named("cursor") || existsSync(join(o.home, ".cursor")) || o.pathHas("cursor"))) report.manual.push(cursorSnippet(o.url, o.key)); else report.skipped.push("Cursor (no ~/.cursor)"); if (wants(o, "windsurf") && (named("windsurf") || existsSync(join(o.home, ".codeium")) || o.pathHas("windsurf"))) report.manual.push(windsurfSnippet(clineConfigured)); else report.skipped.push("Windsurf (no ~/.codeium)"); } /** Cline's state directory: `--data-dir`'s default, `~/.cline/data`, or `CLINE_DATA_DIR` when the user moved it. */ function clineDir(o: ConnectOptions): string { const d = o.env.CLINE_DATA_DIR; return d !== undefined && d !== "" ? expand(d, o.home) : join(o.home, ".cline", "data"); } /** OpenCode's config home: `XDG_CONFIG_HOME`, else `~/.config` — the same on Windows, where it does not use APPDATA. */ function openCodeDir(o: ConnectOptions): string { const xdg = o.env.XDG_CONFIG_HOME; return join(xdg !== undefined && xdg !== "" ? expand(xdg, o.home) : join(o.home, ".config"), "opencode"); } /** A filename-safe timestamp for the `.bak` beside a file we are about to replace. */ const backupStamp = (): string => new Date().toISOString().replaceAll(":", "-"); const isJsonObject = (text: string): boolean => { try { const v = JSON.parse(text) as unknown; return typeof v === "object" && v !== null && !Array.isArray(v); } catch { return false; } }; /** What a team's one-time setup token is traded for. */ export interface IssuedCredential { key: string; refreshToken: string; keyExpiresAtMs?: number; refreshExpiresAtMs?: number; userId: string; name: string; /** * The team's shared-context credential, when it mints them: handed over with the rest so * onboarding costs no extra round trip. Absent from an older team edition's answer, and * then `connect` either mints one at /me/context-tokens or keeps using the access key. */ contextToken?: string; contextTokenExpiresAtMs?: number; contextTokenId?: string; } /** * Trades a one-time setup token for this machine's credential at the team's * exchange route, so the install needs nothing on the machine but this * program: the token is the only secret in the install command and dies on use. */ export async function exchangeSetupToken(url: string, token: string, device: string, fetchImpl: typeof fetch = fetch): Promise { const res = await fetchImpl(`${url}/setup/exchange`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token, device }), signal: AbortSignal.timeout(15_000) }); if (res.status === 401) throw new Error("the setup token was refused (expired or already used); get a new one from the team's portal"); if (!res.ok) throw new Error(`the team's setup exchange answered ${res.status}`); const body = (await res.json()) as Record; if (typeof body.key !== "string" || body.key === "") throw new Error("the team's setup exchange returned no key"); return { key: body.key, refreshToken: typeof body.refreshToken === "string" ? body.refreshToken : "", ...(typeof body.keyExpiresAtMs === "number" ? { keyExpiresAtMs: body.keyExpiresAtMs } : {}), ...(typeof body.refreshExpiresAtMs === "number" ? { refreshExpiresAtMs: body.refreshExpiresAtMs } : {}), userId: typeof body.userId === "string" ? body.userId : "", name: typeof body.name === "string" ? body.name : "", ...(typeof body.contextToken === "string" && body.contextToken !== "" ? { contextToken: body.contextToken } : {}), ...(typeof body.contextTokenExpiresAtMs === "number" ? { contextTokenExpiresAtMs: body.contextTokenExpiresAtMs } : {}), ...(typeof body.contextTokenId === "string" && body.contextTokenId !== "" ? { contextTokenId: body.contextTokenId } : {}), }; } /** * What a team edition says about itself at /setup/info; a plain router answers nothing. * * `mcpAuth` says which credential belongs in the MCP entry. It is `member-key` whenever the * field is missing — a team edition older than context tokens, a plain router, an * unreachable remote — so nothing about those deployments changes. */ export async function fetchSetupInfo(url: string, fetchImpl: typeof fetch = fetch): Promise<{ mcp: boolean; mcpAuth: McpAuth }> { try { const res = await fetchImpl(`${url}/setup/info`, { signal: AbortSignal.timeout(10_000) }); if (!res.ok) return { mcp: false, mcpAuth: "member-key" }; const body = (await res.json()) as { mcp?: unknown; mcpAuth?: unknown }; return { mcp: body.mcp === true, mcpAuth: body.mcpAuth === "context-token" ? "context-token" : "member-key" }; } catch { return { mcp: false, mcpAuth: "member-key" }; } } /** Adds `dir` to the user's PATH on Windows, once, through the registry-backed API rather than setx. */ function addToUserPathWindows(dir: string): void { const quoted = `'${dir.replaceAll("'", "''")}'`; const script = `$d=${quoted}; $p=[Environment]::GetEnvironmentVariable('Path','User'); if ($null -eq $p) { $p='' }; if (($p -split ';') -notcontains $d) { [Environment]::SetEnvironmentVariable('Path', (($p.TrimEnd(';') + ';' + $d).TrimStart(';')), 'User') }`; Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], { stdout: "ignore", stderr: "ignore" }); } /** * Where the harness integrations are read from. Under bun that is this * package; in the compiled executable it is the copy written out from the * executable's own embedded files. */ async function resolvePackageDir(): Promise { const embedded = await readEmbeddedPackage(); if (embedded === null) return resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const raw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router"); return materializePackage(expand(raw, homedir()), embedded); } export async function connectCommand(args: CliArgs): Promise { const url = (flagString(args, "url") ?? process.env.AUTO_MODEL_ROUTER_URL ?? "").replace(/\/+$/, ""); let key = flagString(args, "key") ?? process.env.AUTO_MODEL_ROUTER_API_KEY ?? ""; const setupToken = flagString(args, "setup-token") ?? ""; if (url === "" || (key === "" && setupToken === "")) throw new Error("connect needs --url and either --key or --setup-token "); const only = (flagString(args, "harness") ?? "").split(",").map((s) => s.trim().toLowerCase()).filter((s) => s !== ""); const pathHas = (bin: string): boolean => Bun.which(bin) !== null; const packageDir = await resolvePackageDir(); const exePath = executablePath(); const fetchImpl = fetch; let name = flagString(args, "name") ?? ""; let userId = flagString(args, "user-id") ?? ""; let device = flagString(args, "device") ?? ""; // A remote that issues short-lived keys hands these over beside the key. let refreshToken = flagString(args, "refresh-token") ?? ""; let keyExpires = Number.parseInt(flagString(args, "key-expires") ?? "", 10); let refreshExpires = Number.parseInt(flagString(args, "refresh-expires") ?? "", 10); // The team's shared-context credential, when the exchange hands one over. let issuedContext: ContextToken | undefined; if (setupToken !== "") { if (device === "") device = hostname(); const issued = await exchangeSetupToken(url, setupToken, device); key = issued.key; refreshToken = issued.refreshToken; keyExpires = issued.keyExpiresAtMs ?? Number.NaN; refreshExpires = issued.refreshExpiresAtMs ?? Number.NaN; if (issued.userId !== "") userId = issued.userId; if (issued.name !== "") name = issued.name; if (issued.contextToken !== undefined) issuedContext = { value: issued.contextToken, ...(issued.contextTokenExpiresAtMs === undefined ? {} : { expiresAtMs: issued.contextTokenExpiresAtMs }), ...(issued.contextTokenId === undefined ? {} : { id: issued.contextTokenId }), }; console.log(`credential issued for ${name === "" ? userId : name} (device ${device})`); } // Verify the key against the route every router serves before touching anything. try { const res = await fetch(`${url}/v1/models`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) }); if (res.status === 401) throw new Error("the remote router rejected this key"); } catch (err) { if (err instanceof Error && err.message.includes("rejected")) throw err; console.log(`warning: could not reach ${url} to verify the key (${err instanceof Error ? err.message : String(err)}); configuring anyway`); } // HOME wins when set (Git Bash, WSL, CI) so a caller can redirect every write; the OS profile otherwise. const home = process.env.HOME !== undefined && process.env.HOME !== "" ? process.env.HOME : homedir(); // A single-project machine can label every request; a machine with several // repos should leave it off and let the extensions send the workspace's own. const scopeFlag = flagString(args, "scope"); // The remote's skills for the agents on this machine; a remote without any serves 404. const skills = await fetchSkills(url, key, fetchImpl); // Its shared-context MCP endpoint, when it is a team edition serving one. const info = await fetchSetupInfo(url, fetchImpl); // The credential that entry carries. A setup token's exchange hands one over; a credential // given with --key has no exchange, so one is minted at /me/context-tokens with the key — // both paths end up with a year-long token instead of the 72-hour access key. A machine // that already holds one keeps it (see ensureContextToken), so re-running connect does not // leave a trail of tokens in the member's portal. const rh = expand(process.env.AUTO_MODEL_ROUTER_HOME ?? join(home, ".auto-model-router"), home); const contextToken = info.mcp && info.mcpAuth === "context-token" ? await ensureContextToken({ url, key, mcpAuth: info.mcpAuth, routerHome: rh, remote: readRemoteRouter(rh), issued: issuedContext, name: device === "" ? hostname() : device, fetchImpl, mint: !args.flags.has("dry-run") }) : null; if (contextToken !== null) console.log(`shared context uses a context token (${contextToken.id ?? "new"}), not the access key: a key rotation no longer restarts your MCP client`); const report = connectRemote({ url, key, userId, name, profile: args.flags.has("profile"), dryRun: args.flags.has("dry-run"), only, env: process.env, home, packageDir, platform: process.platform, pathHas, ...(scopeFlag === undefined ? {} : { agentdoxScope: scopeFlag }), ...(refreshToken === "" ? {} : { refreshToken }), ...(Number.isFinite(keyExpires) ? { keyExpiresAtMs: keyExpires } : {}), ...(Number.isFinite(refreshExpires) ? { refreshExpiresAtMs: refreshExpires } : {}), ...(device === "" ? {} : { device }), ...(exePath === null ? {} : { exePath }), ...(skills.bundle === null ? {} : { skills: skills.bundle }), mcp: { url: info.mcp ? `${url}/mcp` : null, ...(contextToken === null ? {} : { token: contextToken.value, ...(contextToken.expiresAtMs === undefined ? {} : { tokenExpiresAtMs: contextToken.expiresAtMs }), ...(contextToken.id === undefined ? {} : { tokenId: contextToken.id }) }), }, }); if (skills.note !== undefined) report.notes.push(skills.note); if (exePath !== null) console.log(`executable ${exePath}; package files under ${packageDir}`); console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.remoteFile}${name === "" ? "" : ` for ${name}`}`); for (const c of report.configured) console.log(` configured ${c}`); for (const s of report.skipped) console.log(` skipped ${s}`); // A manual harness gets the values printed rather than a file written; see configureExtraHarnesses. for (const m of report.manual) { console.log(` manual ${m.harness} — ${m.reason}`); for (const l of m.lines) console.log(` ${l}`); } console.log("environment:"); for (const l of report.envLines) console.log(` ${process.platform === "win32" ? "$env:" : "export "}${process.platform === "win32" ? l.replace("=", '="') + '"' : l}`); for (const n of report.notes) console.log(`note: ${n}`); }