UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

320 lines • 14.3 kB
/** * Gemini CLI client configurator. * * Gemini CLI was reachable through the proxy long before this file existed — * `GOOGLE_GEMINI_BASE_URL` pointed at the proxy's `/v1beta` door works — but * nothing wrote it, so every user had to know that and export it by hand. The * proxy already serves the door (`geminiProxyRoutes`, `POST * /v1beta/models/{model}:generateContent`); this only closes the onboarding * gap. * * **Why a file and not an env script.** Copilot is configured through * `~/.neurolink/copilot-env.sh`, which the user must source from their shell * profile. On the machine this was developed against, nothing sourced it: the * writer reported success, `applyAllClients` counted it applied, and Copilot * had never once used the proxy. A file the CLI reads on its own has no such * silent-failure mode. Gemini CLI loads `~/.gemini/.env` (its own error text * says "set it in your environment or ~/.gemini/.env"), so that is what this * writes. * * The snapshot lives in `~/.neurolink/`, following `codex.ts`, never inside * the file being managed — see `openCode.ts` for what that cost. */ import { homedir } from "os"; import { join } from "path"; import { logger } from "../../utils/logger.js"; import { isUsableSnapshot, writeFileAtomic } from "./snapshot.js"; /** Gemini CLI's config directory. Fixed; it has no XDG override. */ function getGeminiConfigDir() { return join(homedir(), ".gemini"); } function getGeminiEnvPath() { return join(getGeminiConfigDir(), ".env"); } function getGeminiSnapshotPath() { return join(homedir(), ".neurolink", "gemini-proxy-snapshot.json"); } /** The two variables this writer owns. Every other line is the user's. */ const BASE_URL_VAR = "GOOGLE_GEMINI_BASE_URL"; const API_KEY_VAR = "GEMINI_API_KEY"; /** * The proxy's Gemini door needs no real credential — it terminates the * client's key and swaps in a pooled account — but Gemini CLI refuses to start * in API-key mode without *something* set, exactly as OpenCode's placeholder * does. */ const PLACEHOLDER_KEY = "neurolink-proxy"; /** * Rewrite the two managed variables, preserving every other line verbatim. * * A whole-file rewrite would be simpler and wrong: `.env` is the user's file, * it may hold unrelated keys for other tools, and comments and ordering are * theirs to keep. */ function upsertEnvVars(original, vars) { let text = original; for (const [key, value] of Object.entries(vars)) { // Match an assignment at line start, tolerating `export ` and surrounding // spaces. Anchored per-line so a key mentioned inside a comment or another // value is not rewritten. const line = `${key}=${value}`; const all = new RegExp(`^[ \t]*(?:export[ \t]+)?${key}[ \t]*=.*(?:\r?\n|$)`, "gm"); const occurrences = text.match(all)?.length ?? 0; if (occurrences === 0) { text = `${text.length > 0 && !text.endsWith("\n") ? `${text}\n` : text}${line}\n`; continue; } // Rewrite the FIRST occurrence in place — position is the user's and a // byte-exact restore depends on keeping it — then drop any later ones. // // Dropping them is not tidiness. Gemini CLI's dotenv takes the LAST // assignment: with a duplicate left in place our value is silently // overridden and Gemini talks to Google while the writer reports success. // Measured: a .env with the proxy URL first and a dead port second sent the // request to the dead port. let seen = 0; // A function replacement, never a string: `String.replace` expands `$&`, // `$1` and friends inside a replacement *string*, so a proxy key // containing `$&` would be stored as the matched assignment instead of // itself. text = text.replace(all, (match) => { seen += 1; if (seen > 1) { return ""; } // Re-emit the terminator that was matched, not an assumed LF. The regex // captures `\r?\n`, and `"…\r\n".endsWith("\n")` is true, so testing for // LF alone silently rewrote a CRLF line ending to LF — breaking the // byte-exact round-trip this function's own contract promises, on the // ordinary single-occurrence path rather than only on duplicates. // Gemini CLI is cross-platform; a Windows-authored .env is not exotic. const terminator = match.endsWith("\r\n") ? "\r\n" : match.endsWith("\n") ? "\n" : ""; return `${line}${terminator}`; }); } return text; } /** * Read the managed variables' effective values out of an `.env` body. * * Restore needs the original *values*, not the original file: replaying a * whole snapshot would discard everything the user changed after apply(). */ function readManagedVars(envText) { const out = {}; for (const key of [BASE_URL_VAR, API_KEY_VAR]) { // The LAST assignment, because that is the one dotenv resolves to. Reading // the first would restore a value the CLI never actually used. const all = [ ...envText.matchAll(new RegExp(`^[ \\t]*(?:export[ \\t]+)?${key}[ \\t]*=(.*)$`, "gm")), ]; const last = all[all.length - 1]; if (last) { out[key] = last[1] ?? ""; } } return out; } /** Remove the managed variables, leaving the rest of the file untouched. */ function removeEnvVars(original, keys) { let text = original; for (const key of keys) { // Global: a duplicated key must be cleared everywhere. Removing only the // first leaves a later assignment behind, and dotenv takes the LAST one — // so a "restored" .env would still be pointing at the proxy. const re = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${key}[ \\t]*=.*(?:\\r?\\n|$)`, "gm"); text = text.replace(re, ""); } return text; } async function readGeminiSnapshot() { const fs = await import("fs"); let parsed; try { parsed = JSON.parse(fs.readFileSync(getGeminiSnapshotPath(), "utf8")); } catch { return null; } // Without `originalEnv` there is nothing to write back, and the restore path // would hand `undefined` to writeFileAtomic and throw — which // restoreAllClients catches, leaving the user pointed at a dead proxy with // no visible failure. if (!isUsableSnapshot(parsed, "originalEnv")) { logger.debug("[proxy] Gemini: ignoring a malformed snapshot rather than treating it as empty"); return null; } const originalEnv = parsed.originalEnv; if (originalEnv !== null && typeof originalEnv !== "string") { logger.debug("[proxy] Gemini: snapshot originalEnv has the wrong type"); return null; } return parsed; } export async function setGeminiProxySettings(baseUrl, proxyKey) { const fs = await import("fs"); try { fs.accessSync(getGeminiConfigDir()); } catch { // Gemini CLI not installed. Report the skip rather than creating a config // directory for a CLI the user does not have. return false; } let original; try { original = fs.readFileSync(getGeminiEnvPath(), "utf8"); } catch { original = null; } // "No usable snapshot" is not the same as "no snapshot file", and the // difference is the user's API key. A file that exists but cannot be parsed // used to satisfy the existence check, so apply() skipped recording, wrote // the placeholder over the real key, and restore later found nothing to put // back and simply removed the variable — the key was gone with no record of // it anywhere. Refuse to touch .env instead: returning false is the // configurator contract for "nothing was written", so the caller reports a // skip rather than a success. const existingSnapshot = await readGeminiSnapshot(); if (existingSnapshot === null && fs.existsSync(getGeminiSnapshotPath())) { logger.warn("[proxy] Gemini: snapshot file is unreadable; leaving .env untouched rather than overwriting credentials with no way back"); return false; } // Snapshot once — but only while the existing record still describes the // file. If restore ran and could not delete the snapshot, or the user edited // a managed variable afterwards, the stored record is stale: reusing it would // make the NEXT restore write yesterday's values over today's. Re-capture // whenever what is on disk is not what we last wrote. const currentManaged = readManagedVars(original ?? ""); const lastWritten = existingSnapshot?.written; const snapshotIsStale = existingSnapshot !== null && (lastWritten === undefined || currentManaged[BASE_URL_VAR] !== lastWritten.baseUrl || currentManaged[API_KEY_VAR] !== lastWritten.apiKey); if (snapshotIsStale) { logger.debug("[proxy] Gemini: stored snapshot no longer matches .env; re-capturing"); } if (existingSnapshot === null || snapshotIsStale) { fs.mkdirSync(join(homedir(), ".neurolink"), { recursive: true }); // 0o600: a pre-existing .env routinely holds the user's real API key. await writeFileAtomic(getGeminiSnapshotPath(), JSON.stringify({ originalEnv: original, written: { baseUrl, apiKey: proxyKey || PLACEHOLDER_KEY }, }, null, 2), 0o600); } const next = upsertEnvVars(original ?? "", { [BASE_URL_VAR]: baseUrl, [API_KEY_VAR]: proxyKey || PLACEHOLDER_KEY, }); // 0o600 on create: this file carries credentials. An existing file keeps its // own mode, which is writeFileAtomic's documented behaviour. await writeFileAtomic(getGeminiEnvPath(), next, original === null ? 0o600 : undefined); return true; } export async function clearGeminiProxySettings(expectedBaseUrl) { const fs = await import("fs"); let current; try { current = fs.readFileSync(getGeminiEnvPath(), "utf8"); } catch { return false; } // The EFFECTIVE assignment, not the first one on the page. dotenv resolves // the last, so a user who appended their own base URL after apply() has // already repointed Gemini — even though our line is still sitting above // theirs. Checking the first saw our value, passed the ownership test, and // restore then deleted the endpoint the CLI was actually using. const configuredUrl = readManagedVars(current)[BASE_URL_VAR]; if (configuredUrl === undefined) { return false; } if (expectedBaseUrl && configuredUrl.trim() !== expectedBaseUrl) { // Pointed somewhere else — the user's choice, not ours to revert. logger.debug("[proxy] Gemini clear: base URL is not the one we wrote, leaving it intact"); return false; } const snapshot = await readGeminiSnapshot(); if (snapshot === null) { // No record of what was here before. Stripping the managed variables looks // tidy and is destructive: GEMINI_API_KEY may hold the user's real key, // and once removed there is nothing left to restore it from. Leaving a // stale base URL behind costs the user a failed request they can diagnose; // deleting a credential costs them something they cannot get back. Report // the skip instead — false is the contract for "nothing was written". logger.warn("[proxy] Gemini clear: no usable snapshot, leaving .env untouched rather than removing variables we cannot restore"); return false; } if (snapshot.originalEnv === null) { // The user had no .env before the proxy created one. Remove it, unless the // user has since added lines of their own — then keep theirs. const remainder = removeEnvVars(current, [ BASE_URL_VAR, API_KEY_VAR, ]).trim(); if (remainder.length === 0) { fs.rmSync(getGeminiEnvPath(), { force: true }); } else { await writeFileAtomic(getGeminiEnvPath(), `${remainder}\n`); } } else { // Undo our two variables against the CURRENT file rather than writing the // snapshot over it. Anything the user changed or added after apply() is // theirs and must survive; replaying the whole snapshot would silently // discard it. Restoring each managed variable in place also keeps its // original position, so an untouched file round-trips byte-for-byte. const originalValues = readManagedVars(snapshot.originalEnv); let next = current; for (const key of [BASE_URL_VAR, API_KEY_VAR]) { const originalValue = originalValues[key]; next = originalValue === undefined ? removeEnvVars(next, [key]) : upsertEnvVars(next, { [key]: originalValue }); } await writeFileAtomic(getGeminiEnvPath(), next); } try { fs.rmSync(getGeminiSnapshotPath(), { force: true }); } catch { // Harmless: the next apply() overwrites it. } return true; } /** Test-only export (CLAUDE.md rule 15 determinism exception). See openCode.ts. */ export const __geminiTestHooks = { getGeminiConfigDir, getGeminiEnvPath, getGeminiSnapshotPath, setGeminiProxySettings, clearGeminiProxySettings, upsertEnvVars, removeEnvVars, }; export const geminiConfigurator = { id: "gemini-cli", displayName: "Gemini CLI", detect: async () => { const fs = await import("fs"); try { fs.accessSync(getGeminiConfigDir()); return true; } catch { return false; } }, // Gemini CLI appends `/v1beta/models/...` itself, so it takes the bare // origin — unlike OpenCode and Qwen, which need the `/v1` suffix. apply: (proxyBaseUrl) => setGeminiProxySettings(proxyBaseUrl), restore: (proxyBaseUrl) => clearGeminiProxySettings(proxyBaseUrl), }; //# sourceMappingURL=gemini.js.map