From 3c013ddeadb016cf5ecec464328e04d99af5e30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chastel?= Date: Sat, 3 Oct 2026 16:01:50 -0400 Subject: [PATCH] fix: route keys by their variable, never by their shape TypeSafe issues apikey_ keys as well as ts_ ones, and key shapes can change, so a key's prefix no longer decides where it is sent. The variable a key sits in decides its host; a key that looks like another host's only earns a hint in doctor. TYPESAFE_API_KEY keeps following TYPESAFE_BASE_URL and JEV_CODE_PROVIDER to OpenRouter and Vercel the way the TypeSafe SDK does; host-specific variables never travel, and the generic key never follows to OpenAI or Ollama. setup now asks which host a pasted key is for, defaulting to the JEV_CODE_PROVIDER host, else the prefix hint, else TypeSafe. The prompts settle when input ends and hand text after a newline to the next prompt. With JEV_CODE_PROVIDER=ollama, a stale TYPESAFE_BASE_URL on another known host is refused instead of carrying Ollama's key there. --- .env.example | 2 +- CHANGELOG.md | 13 +++ README.md | 34 ++++--- SECURITY.md | 20 ++-- docs/harnesses/claude-code.md | 12 +-- docs/harnesses/codex.md | 10 +- docs/harnesses/opencode.md | 10 +- skills/jev/references/cli.md | 2 +- src/cli/help.ts | 8 +- src/cli/prompt.ts | 149 ++++++++++++++++++++++------- src/cli/run.ts | 46 +++++++-- src/core/config.ts | 174 +++++++++++++++++++++------------- src/core/providers.ts | 9 +- tests/cli/prompt.test.ts | 68 ++++++++++++- tests/cli/run.test.ts | 71 ++++++++++++-- tests/core/config.test.ts | 165 ++++++++++++++++++++++---------- tests/core/providers.test.ts | 7 +- tests/setup/configs.test.ts | 9 +- 18 files changed, 594 insertions(+), 215 deletions(-) diff --git a/.env.example b/.env.example index 5391ba6..4b166fc 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# One key is required. The key's prefix picks the host, whichever variable holds it. +# One key is required, in its host's variable. The variable decides the host, not the key's shape. # TypeSafe direct: https://console.typesafe.ai/keys TYPESAFE_API_KEY= # OpenRouter: https://openrouter.ai/settings/keys diff --git a/CHANGELOG.md b/CHANGELOG.md index 67ae355..1f79d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Fixed + +- A key's shape no longer decides where it is sent. TypeSafe issues `apikey_...` keys as well as + `ts_...`, and shapes change, so the variable a key sits in now decides its host; a key that + looks like another host's only earns a hint in doctor. `TYPESAFE_API_KEY` keeps following + `TYPESAFE_BASE_URL` and `JEV_CODE_PROVIDER` the way the TypeSafe SDK does, so the OpenRouter and + Vercel setup guides for the SDK work unchanged; host-specific variables never travel. +- `setup` asks which host a pasted key is for, with its best guess as the default, instead of + deciding from the key's shape. Both setup prompts now settle when input ends, and a paste + holding both answers is read as two. +- With `JEV_CODE_PROVIDER=ollama`, a stale `TYPESAFE_BASE_URL` on another known host is refused + like everywhere else instead of carrying Ollama's key and the payload there. + ## [0.4.0] - 2026-10-03 Two new hosts, both opt-in. OpenAI's Decisions API, verified end to end against a stand-in that diff --git a/README.md b/README.md index 8a7dec3..a82851f 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ npx -y @french-castle/jev-code@latest setup ``` That detects the harnesses on your machine and, for each one, copies the skill and registers the -tool. When no API key is in your shell, it asks for one right there (hidden input). Any key -works: TypeSafe (`ts_...`), OpenRouter (`sk-or-...`), or Vercel AI Gateway (`vck_...`); the prefix -picks the host, so there is nothing else to configure. +tool. When no API key is in your shell, it asks for one right there (hidden input) and which host +it belongs to: TypeSafe, OpenRouter, Vercel AI Gateway, or OpenAI. There is nothing else to +configure. Add harness names to be explicit (`setup claude codex pi opencode`), `--project` to install into the current repository instead of your user profile, or `--dry-run` to see the plan first. @@ -64,7 +64,7 @@ without a prompt. This is also what Pi and the CLI read, so `setup` prints the l shell profile after you paste a key: ```bash -export TYPESAFE_API_KEY=ts_... # TypeSafe direct: console.typesafe.ai/keys +export TYPESAFE_API_KEY=... # TypeSafe direct: console.typesafe.ai/keys export OPENROUTER_API_KEY=sk-or-... # OpenRouter: already set if you use it elsewhere export AI_GATEWAY_API_KEY=vck_... # Vercel AI Gateway ``` @@ -236,26 +236,30 @@ tools behave identically and only the account you pay through changes. The last models that answer the same kinds of questions: OpenAI's Decisions API, and Ollama's local decision models, which need no key at all. -| Host | Key variable | Key prefix | Default model | Get a key | -| --- | --- | --- | --- | --- | -| TypeSafe (direct) | `TYPESAFE_API_KEY` | `ts_` | `jev-latest` | [console.typesafe.ai/keys](https://console.typesafe.ai/keys) | -| OpenRouter | `OPENROUTER_API_KEY` | `sk-or-` | `jev-latest` | [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) | -| Vercel AI Gateway | `AI_GATEWAY_API_KEY` | `vck_` | `typesafe-ai/jev` | [AI Gateway API keys](https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys) | -| OpenAI Decisions API (preview, opt-in) | `OPENAI_API_KEY` + `JEV_CODE_PROVIDER=openai` | `sk-proj-` | `gpt-6-luna` | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) | -| Ollama (local, opt-in) | no key; `JEV_CODE_PROVIDER=ollama`, `OLLAMA_HOST` optional | none | `nimble` | [docs.ollama.com/capabilities/decision](https://docs.ollama.com/capabilities/decision) | +| Host | Key variable | Default model | Get a key | +| --- | --- | --- | --- | +| TypeSafe (direct) | `TYPESAFE_API_KEY` | `jev-latest` | [console.typesafe.ai/keys](https://console.typesafe.ai/keys) | +| OpenRouter | `OPENROUTER_API_KEY` | `jev-latest` | [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) | +| Vercel AI Gateway | `AI_GATEWAY_API_KEY` | `typesafe-ai/jev` | [AI Gateway API keys](https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys) | +| OpenAI Decisions API (preview, opt-in) | `OPENAI_API_KEY` + `JEV_CODE_PROVIDER=openai` | `gpt-6-luna` | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) | +| Ollama (local, opt-in) | no key; `JEV_CODE_PROVIDER=ollama`, `OLLAMA_HOST` optional | `nimble` | [docs.ollama.com/capabilities/decision](https://docs.ollama.com/capabilities/decision) | How the host is chosen: -- The key's prefix decides, whichever variable holds it: `TYPESAFE_API_KEY=sk-or-...` goes to - OpenRouter. +- The variable decides. A key is sent to the host of the variable it sits in, whatever it looks + like; key shapes change (TypeSafe issues both `ts_...` and `apikey_...` keys), so jev-code never + guesses from them. `doctor` adds a hint when a key looks like it belongs elsewhere. +- `TYPESAFE_API_KEY` is the generic variable the TypeSafe SDK reads, so it follows + `TYPESAFE_BASE_URL` and `JEV_CODE_PROVIDER` the way the SDK does: pointing the base URL at + OpenRouter or Vercel AI Gateway sends it there. A host-specific variable never travels. - When several keys are set, the first row in the table wins. `doctor` says which one is in use. - `JEV_CODE_PROVIDER=openrouter` (or `typesafe`, `vercel`, `openai`, `ollama`) forces a host. OpenAI is never picked up from an ambient `OPENAI_API_KEY`: that variable is set in many shells for other reasons, so sending agent evidence there is a decision you make with `JEV_CODE_PROVIDER=openai` (or by pasting an OpenAI key when `setup` asks, which stores both). Ollama has no key to detect, so it is chosen the same way. -- A key is only ever sent to the host that issued it; a mismatch is refused before any request. - The exact rules are in [SECURITY.md](SECURITY.md). +- A host-specific key is only ever sent to its own host; a base URL that disagrees is refused + before any request. The exact rules are in [SECURITY.md](SECURITY.md). | Variable | Default | Purpose | | --- | --- | --- | diff --git a/SECURITY.md b/SECURITY.md index 21b7ac3..b87c16b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,7 +19,8 @@ One `POST .../v1/systemone` per tool call, to the host your key belongs to: - `https://api.openai.com` (OpenAI Decisions API), only when `JEV_CODE_PROVIDER=openai` - `http://localhost:11434`, or `OLLAMA_HOST` (Ollama), only when `JEV_CODE_PROVIDER=ollama`; no key is sent unless `OLLAMA_API_KEY` is set -- or `TYPESAFE_BASE_URL` when set, under the proxy rule below. +- or `TYPESAFE_BASE_URL` when set, under the proxy rule below. A base URL on one of the known + hosts above while `JEV_CODE_PROVIDER` names another is refused, for Ollama too. The payload is what the agent passed to a tool: items, questions, optional context. Through OpenRouter or Vercel AI Gateway it transits that gateway on its way to TypeSafe. jev-code never @@ -27,16 +28,19 @@ reads files, git state, or session history on its own. ### Credentials -- The key is read from `TYPESAFE_API_KEY`, `OPENROUTER_API_KEY`, or `AI_GATEWAY_API_KEY`. Its - prefix (`ts_`, `sk-or-`, `vck_`) says which host issued it; a key with an unknown prefix - belongs to the host of the variable it sits in. +- The key is read from `TYPESAFE_API_KEY`, `OPENROUTER_API_KEY`, or `AI_GATEWAY_API_KEY`. The + variable decides the host; a key's shape never does, because shapes change (TypeSafe issues + `ts_` and `apikey_` keys). A key that looks like another host's earns a hint in doctor, nothing + more. - `OPENAI_API_KEY` is never adopted on its own, even when it is the only key set, because many unrelated tools set it. OpenAI's Decisions API is used only with `JEV_CODE_PROVIDER=openai`; until then doctor and the missing-key error say the key is there and how to opt in. -- A key is only ever sent to the host that issued it. A base URL on a different known host is - refused before any request. A base URL on an unrecognised host (a proxy) is honoured for - TypeSafe keys, and for other keys only together with `JEV_CODE_PROVIDER`, so a key another - tool exported never follows a stray override. +- `TYPESAFE_API_KEY` is the generic variable the TypeSafe SDK reads, so it follows + `TYPESAFE_BASE_URL` and `JEV_CODE_PROVIDER` as the SDK does, including to a proxy. A + host-specific variable (`OPENROUTER_API_KEY`, `AI_GATEWAY_API_KEY`, `OPENAI_API_KEY`) is sent + only to its own host: a base URL on another known host is refused before any request, and a + proxy needs `JEV_CODE_PROVIDER` to state the intent, so a key another tool exported never + follows a stray override. - Keys are never echoed or logged. `doctor`, `setup`, and the MCP server's one stderr line show a masked hint (`sk-o…1234`); printed commands show ``; a harness CLI's output is scrubbed of the key before it is shown. diff --git a/docs/harnesses/claude-code.md b/docs/harnesses/claude-code.md index 8e65951..8211c3c 100644 --- a/docs/harnesses/claude-code.md +++ b/docs/harnesses/claude-code.md @@ -36,7 +36,7 @@ The skill is then invoked as `/jev-code:jev`, and the tools appear as Register the server yourself: ```bash -claude mcp add --scope user jev -e TYPESAFE_API_KEY=ts_... -- npx -y @french-castle/jev-code@0.4.0 mcp +claude mcp add --scope user jev -e TYPESAFE_API_KEY= -- npx -y @french-castle/jev-code@0.4.0 mcp ``` or add it to a project's `.mcp.json`: @@ -47,16 +47,16 @@ or add it to a project's `.mcp.json`: "jev": { "command": "npx", "args": ["-y", "@french-castle/jev-code@0.4.0", "mcp"], - "env": { "TYPESAFE_API_KEY": "ts_..." } + "env": { "TYPESAFE_API_KEY": "" } } } } ``` -Any provider key works in place of `TYPESAFE_API_KEY`: `OPENROUTER_API_KEY=sk-or-...` routes -through OpenRouter and `AI_GATEWAY_API_KEY=vck_...` through Vercel AI Gateway. OpenAI's Decisions -API (preview) needs both `OPENAI_API_KEY` and `JEV_CODE_PROVIDER=openai` in the environment; a -local Ollama needs only `JEV_CODE_PROVIDER=ollama`. +Any provider key works in place of `TYPESAFE_API_KEY`: `OPENROUTER_API_KEY` routes through +OpenRouter and `AI_GATEWAY_API_KEY` through Vercel AI Gateway (the variable decides the host). +OpenAI's Decisions API (preview) needs both `OPENAI_API_KEY` and `JEV_CODE_PROVIDER=openai` in the +environment; a local Ollama needs only `JEV_CODE_PROVIDER=ollama`. Install the skill with `npx skills add FrancoisChastel/jev-code --skill jev -a claude-code`, or copy `skills/jev/` into `~/.claude/skills/`. diff --git a/docs/harnesses/codex.md b/docs/harnesses/codex.md index 0e74a4f..711beea 100644 --- a/docs/harnesses/codex.md +++ b/docs/harnesses/codex.md @@ -30,13 +30,13 @@ command = "npx" args = ["-y", "@french-castle/jev-code@0.4.0", "mcp"] [mcp_servers.jev.env] -TYPESAFE_API_KEY = "ts_..." +TYPESAFE_API_KEY = "" ``` -Any provider key works in place of `TYPESAFE_API_KEY`: `OPENROUTER_API_KEY=sk-or-...` routes -through OpenRouter and `AI_GATEWAY_API_KEY=vck_...` through Vercel AI Gateway. OpenAI's Decisions -API (preview) needs both `OPENAI_API_KEY` and `JEV_CODE_PROVIDER=openai` in the environment; a -local Ollama needs only `JEV_CODE_PROVIDER=ollama`. +Any provider key works in place of `TYPESAFE_API_KEY`: `OPENROUTER_API_KEY` routes through +OpenRouter and `AI_GATEWAY_API_KEY` through Vercel AI Gateway (the variable decides the host). +OpenAI's Decisions API (preview) needs both `OPENAI_API_KEY` and `JEV_CODE_PROVIDER=openai` in the +environment; a local Ollama needs only `JEV_CODE_PROVIDER=ollama`. Install the skill with `npx skills add FrancoisChastel/jev-code --skill jev -a codex`, or copy `skills/jev/` into `~/.agents/skills/`. diff --git a/docs/harnesses/opencode.md b/docs/harnesses/opencode.md index 2a68527..77a5e67 100644 --- a/docs/harnesses/opencode.md +++ b/docs/harnesses/opencode.md @@ -30,16 +30,16 @@ Add to `opencode.json`: "type": "local", "command": ["npx", "-y", "@french-castle/jev-code@0.4.0", "mcp"], "enabled": true, - "environment": { "TYPESAFE_API_KEY": "ts_..." } + "environment": { "TYPESAFE_API_KEY": "" } } } } ``` -Any provider key works in place of `TYPESAFE_API_KEY`: `OPENROUTER_API_KEY=sk-or-...` routes -through OpenRouter and `AI_GATEWAY_API_KEY=vck_...` through Vercel AI Gateway. OpenAI's Decisions -API (preview) needs both `OPENAI_API_KEY` and `JEV_CODE_PROVIDER=openai` in the environment; a -local Ollama needs only `JEV_CODE_PROVIDER=ollama`. +Any provider key works in place of `TYPESAFE_API_KEY`: `OPENROUTER_API_KEY` routes through +OpenRouter and `AI_GATEWAY_API_KEY` through Vercel AI Gateway (the variable decides the host). +OpenAI's Decisions API (preview) needs both `OPENAI_API_KEY` and `JEV_CODE_PROVIDER=openai` in the +environment; a local Ollama needs only `JEV_CODE_PROVIDER=ollama`. Install the skill with `npx skills add FrancoisChastel/jev-code --skill jev -a opencode`, or copy `skills/jev/` into `~/.config/opencode/skills/`. diff --git a/skills/jev/references/cli.md b/skills/jev/references/cli.md index b9309a9..928bd55 100644 --- a/skills/jev/references/cli.md +++ b/skills/jev/references/cli.md @@ -2,7 +2,7 @@ When the `jev_*` tools are not registered in the current harness, the same operations are available from bash. The CLI reads the API key from the environment (`TYPESAFE_API_KEY`, -`OPENROUTER_API_KEY`, or `AI_GATEWAY_API_KEY`; the key's prefix picks the host). +`OPENROUTER_API_KEY`, or `AI_GATEWAY_API_KEY`; the variable decides the host). ```bash jev-code doctor # is the key set, which harnesses are wired diff --git a/src/cli/help.ts b/src/cli/help.ts index 1c2b792..e8cfa29 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -6,8 +6,7 @@ function providerLines(): string { return PROVIDERS.map((provider) => { const optIn = provider.explicitOnly ? ` (opt in: ${ENV.provider}=${provider.name})` : ""; const variable = provider.keyless ? `${provider.hostEnv ?? ""} (optional)` : provider.keyEnv; - const prefix = provider.keyPrefix ? `${provider.keyPrefix}...` : "no key"; - return ` ${variable.padEnd(23)} ${prefix.padEnd(12)} ${provider.label.padEnd(20)} ${provider.keysUrl}${optIn}`; + return ` ${variable.padEnd(23)} ${provider.label.padEnd(20)} ${provider.keysUrl}${optIn}`; }).join("\n"); } @@ -49,10 +48,11 @@ Setup options --pi-source Package spec for \`pi install\` (default: npm:${PACKAGE_NAME}). Environment - One key is required. Its prefix picks the host, whichever variable holds it: + One key is required, in its host's variable; the variable decides, not the key's shape. + TYPESAFE_API_KEY also follows TYPESAFE_BASE_URL and JEV_CODE_PROVIDER, as the TypeSafe SDK does. ${providerLines()} JEV_CODE_PROVIDER typesafe | openrouter | vercel | openai | ollama. Forces the host; required for openai and ollama. - TYPESAFE_BASE_URL Override the base URL (a proxy). TypeSafe keys only, unless JEV_CODE_PROVIDER is set. + TYPESAFE_BASE_URL Override the base URL (a proxy). Followed by TYPESAFE_API_KEY; other keys need JEV_CODE_PROVIDER. TYPESAFE_DEFAULT_MODEL Override the host's default model (jev-latest; typesafe-ai/jev on Vercel). JEV_CODE_TIMEOUT_MS Default 30000 JEV_CODE_MAX_RETRIES Default 2 diff --git a/src/cli/prompt.ts b/src/cli/prompt.ts index b308bd4..c8d2f22 100644 --- a/src/cli/prompt.ts +++ b/src/cli/prompt.ts @@ -1,4 +1,4 @@ -/** The parts of `process.stdin` a hidden prompt needs, narrowed so tests can fake them. */ +/** The parts of `process.stdin` a prompt needs, narrowed so tests can fake them. */ export interface SecretInput { isTTY?: boolean; setRawMode?(mode: boolean): unknown; @@ -6,8 +6,10 @@ export interface SecretInput { resume(): unknown; pause(): unknown; on(event: "data", listener: (chunk: string) => void): unknown; + on(event: "end", listener: () => void): unknown; on(event: "error", listener: (error: Error) => void): unknown; off(event: "data", listener: (chunk: string) => void): unknown; + off(event: "end", listener: () => void): unknown; off(event: "error", listener: (error: Error) => void): unknown; } @@ -15,6 +17,90 @@ export interface SecretOutput { write(text: string): unknown; } +type Outcome = { value: string } | { error: Error }; + +/** + * Text that arrived after the line a prompt consumed, kept for the next prompt on the same + * stream so a paste holding two answers is not cut to one. + */ +const carried = new WeakMap(); + +function takeCarried(input: SecretInput): string { + const text = carried.get(input) ?? ""; + carried.delete(input); + return text; +} + +function carry(input: SecretInput, rest: string): void { + if (rest) carried.set(input, rest); +} + +/** + * Wire one prompt to the stream: feed it anything carried over, then listen until `onData` + * reports completion, the stream ends, or it fails. Listeners are removed before settling. + */ +function readFrom( + input: SecretInput, + consume: (chunk: string) => Outcome | undefined, + onEnd: () => Outcome, + settle: (outcome: Outcome) => void, +): void { + let done = false; + const finish = (outcome: Outcome): void => { + done = true; + input.off("data", onData); + input.off("end", onStreamEnd); + input.off("error", onError); + // The terminal is restored by `settle` before the stream is paused. + settle(outcome); + input.pause(); + }; + const onData = (chunk: string): void => { + const outcome = consume(chunk); + if (outcome) finish(outcome); + }; + const onStreamEnd = (): void => finish(onEnd()); + const onError = (error: Error): void => finish({ error }); + input.setEncoding("utf8"); + const pending = takeCarried(input); + if (pending) onData(pending); + if (done) return; + input.on("data", onData); + input.on("end", onStreamEnd); + input.on("error", onError); + input.resume(); +} + +const NEWLINE = /\r\n|\r|\n/; + +/** + * Ask a visible question and resolve with the line typed, without its newline. The terminal + * echoes the answer itself, so raw mode is switched off first. End of input counts as Enter. + */ +export function readLine( + question: string, + input: SecretInput = process.stdin, + output: SecretOutput = process.stdout, +): Promise { + return new Promise((resolve, reject) => { + output.write(question); + if (input.isTTY === true && typeof input.setRawMode === "function") input.setRawMode(false); + let value = ""; + readFrom( + input, + (chunk) => { + value += chunk; + const match = NEWLINE.exec(value); + if (!match) return undefined; + carry(input, value.slice(match.index + match[0].length)); + return { value: value.slice(0, match.index) }; + }, + () => ({ value }), + (outcome) => ("error" in outcome ? reject(outcome.error) : resolve(outcome.value)), + ); + }); +} + const ENTER = new Set(["\r", "\n"]); /** Ctrl-C and Ctrl-D. */ const CANCEL = new Set(["\u0003", "\u0004"]); @@ -26,7 +112,7 @@ const CONTROL_SEQUENCE = new RegExp(`${ESCAPE}\\[[0-9;?]*[ -/]*[@-~]`, "g"); /** * Ask for a secret without echoing it. Raw mode is used on a terminal so nothing is printed * while typing; elsewhere input is read as it arrives. Resolves with the text typed when - * Enter is pressed; rejects on Ctrl-C or Ctrl-D. + * Enter is pressed; rejects on Ctrl-C, Ctrl-D, or end of input. */ export function readSecret( question: string, @@ -37,38 +123,35 @@ export function readSecret( output.write(question); const raw = input.isTTY === true && typeof input.setRawMode === "function"; if (raw) input.setRawMode?.(true); - input.setEncoding("utf8"); - input.resume(); let value = ""; - const finish = (outcome: { value: string } | { error: Error }): void => { - input.off("data", onData); - input.off("error", onError); - if (raw) input.setRawMode?.(false); - input.pause(); - output.write("\n"); - if ("error" in outcome) reject(outcome.error); - else resolve(outcome.value); - }; - const onError = (error: Error): void => finish({ error }); - const onData = (chunk: string): void => { - for (const char of chunk.replace(CONTROL_SEQUENCE, "")) { - if (ENTER.has(char)) { - finish({ value }); - return; - } - if (CANCEL.has(char)) { - finish({ error: new Error("Cancelled.") }); - return; - } - if (BACKSPACE.has(char)) { - value = value.slice(0, -1); - continue; + readFrom( + input, + (chunk) => { + const chars = Array.from(chunk.replace(CONTROL_SEQUENCE, "")); + for (const [index, char] of chars.entries()) { + if (ENTER.has(char)) { + const rest = chars.slice(index + 1).join(""); + // A paste may hold the next answer too; drop only the newline pair's second half. + carry(input, char === "\r" && rest.startsWith("\n") ? rest.slice(1) : rest); + return { value }; + } + if (CANCEL.has(char)) return { error: new Error("Cancelled.") }; + if (BACKSPACE.has(char)) { + value = value.slice(0, -1); + continue; + } + if (char < " ") continue; + value += char; } - if (char < " ") continue; - value += char; - } - }; - input.on("data", onData); - input.on("error", onError); + return undefined; + }, + () => ({ error: new Error("Cancelled: input ended before Enter.") }), + (outcome) => { + if (raw) input.setRawMode?.(false); + output.write("\n"); + if ("error" in outcome) reject(outcome.error); + else resolve(outcome.value); + }, + ); }); } diff --git a/src/cli/run.ts b/src/cli/run.ts index 5ce7f1d..a2a8398 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -3,7 +3,13 @@ import { homedir } from "node:os"; import { JevClient } from "../core/client.js"; import { describeConfig, describeProviderInUse, ENV } from "../core/config.js"; import { errorMessage, JevConfigError, JevValidationError } from "../core/errors.js"; -import { DEFAULT_PROVIDER, providerForKey } from "../core/providers.js"; +import { + DEFAULT_PROVIDER, + PROVIDERS, + type Provider, + providerByName, + providerForKey, +} from "../core/providers.js"; import { serveStdio } from "../mcp/server.js"; import { type Exec, realExec, type Which, whichBinary } from "../setup/exec.js"; import { type Harness, parseHarness } from "../setup/harnesses.js"; @@ -14,7 +20,7 @@ import { PACKAGE_NAME, VERSION } from "../version.js"; import { flagBool, flagString, parseArgs } from "./args.js"; import { runDoctor } from "./doctor.js"; import { helpText } from "./help.js"; -import { readSecret } from "./prompt.js"; +import { readLine, readSecret } from "./prompt.js"; export interface CliIO { stdout: (text: string) => void; @@ -31,6 +37,8 @@ export interface CliIO { serve: () => Promise; /** Asks for a secret without echoing it; absent means setup never prompts. */ promptSecret?: (question: string) => Promise; + /** Asks a visible question; used to confirm which host a pasted key is for. */ + promptLine?: (question: string) => Promise; fetch?: ConstructorParameters[0]["fetch"]; } @@ -56,6 +64,7 @@ function defaultIO(): CliIO { which: (binary) => whichBinary(binary), serve: () => serveStdio(), promptSecret: (question) => readSecret(question), + promptLine: (question) => readLine(question), }; } @@ -215,9 +224,9 @@ const KEY_PROMPT = /** * Offer to take the key interactively when setup would otherwise register a tool that - * cannot work. Only in a terminal, only when no key is set, and never on a dry run. The key - * lands in the variable of the host its prefix names (TypeSafe's when the prefix is unknown) - * and is then judged like any other configuration, so a conflicting override still surfaces. + * cannot work. Only in a terminal, only when no key is set, and never on a dry run. The user + * says which host the key is for (key shapes are not reliable); the answer picks the variable, + * and the result is judged like any other configuration, so a conflicting override surfaces. */ async function promptForKey( flags: ReturnType["flags"], @@ -229,7 +238,9 @@ async function promptForKey( if (describeConfig(io.env).hasApiKey) return undefined; const key = (await io.promptSecret(KEY_PROMPT)).trim(); if (!key) return undefined; - const provider = providerForKey(key) ?? DEFAULT_PROVIDER; + const named = providerByName(io.env[ENV.provider] ?? ""); + const suggested = named && !named.keyless ? named : (providerForKey(key) ?? DEFAULT_PROVIDER); + const provider = await askHost(io, suggested); // A pasted key is a decision, so an opt-in host gets its JEV_CODE_PROVIDER alongside. const env: Record = { [provider.keyEnv]: key, @@ -240,6 +251,29 @@ async function promptForKey( return { env }; } +/** + * Key shapes are not reliable, so the host is asked, with the best guess as the default. Only + * hosts that take a key are offered; a number, a name, or Enter answers. + */ +async function askHost(io: CliIO, suggested: Provider): Promise { + const hosts = PROVIDERS.filter((provider) => !provider.keyless); + if (!io.promptLine) return suggested; + const menu = hosts.map((provider, i) => `[${i + 1}] ${provider.label}`).join(" "); + const answer = ( + await io.promptLine( + `Which host is this key for? ${menu}. Number or name, Enter for ${suggested.label}: `, + ) + ) + .trim() + .toLowerCase(); + if (!answer) return suggested; + const byNumber = hosts[Number(answer) - 1]; + const byName = hosts.find((p) => p.name === answer || p.label.toLowerCase() === answer); + const picked = byNumber ?? byName; + if (!picked) io.stdout(`Did not recognise "${answer}"; using ${suggested.label}.\n`); + return picked ?? suggested; +} + function pastedKeyAdvice(env: Record): string { const exports = Object.keys(env).map((name) => name === ENV.provider ? ` export ${name}=${env[name]}` : ` export ${name}=...`, diff --git a/src/core/config.ts b/src/core/config.ts index 5cb9d6e..9554691 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,6 +1,7 @@ import { errorMessage, JevConfigError } from "./errors.js"; import { DEFAULT_PROVIDER, + keyPrefixes, PROVIDER_NAMES, PROVIDERS, type Provider, @@ -53,10 +54,8 @@ type Env = Record; interface KeyCandidate { keyEnv: string; key: string; - /** Host implied by the key's prefix, or by the variable when the prefix is unknown. */ + /** The host of the variable the key sits in. The variable decides, never the key's shape. */ provider: Provider; - /** True when the prefix, not the variable, decided the host. */ - byPrefix: boolean; } export interface ProviderSelection { @@ -85,9 +84,18 @@ function explicitProvider(env: Env): Provider | undefined { return name ? providerByName(name) : undefined; } +/** The variable the TypeSafe SDK reads. It follows TYPESAFE_BASE_URL and JEV_CODE_PROVIDER. */ +const GENERIC_KEY = ENV.apiKey; + +/** Hosts that TYPESAFE_API_KEY may follow: the ones that speak the TypeSafe SDK's protocol. */ +function acceptsGenericKey(provider: Provider): boolean { + return (provider.wire ?? "systemone") === "systemone" && !provider.keyless; +} + /** - * Every key variable that is set, in precedence order, each routed by its prefix. Keys that - * belong to an opt-in host are left out unless JEV_CODE_PROVIDER names that host. + * Every key variable that is set, in precedence order. The variable decides the host: key + * shapes change (TypeSafe issues `ts_` and `apikey_` keys, for one), so a key's prefix is never + * trusted to reroute it. Keys of opt-in hosts count only when JEV_CODE_PROVIDER names the host. */ function findKeys(env: Env): KeyCandidate[] { const explicit = explicitProvider(env); @@ -95,32 +103,28 @@ function findKeys(env: Env): KeyCandidate[] { for (const provider of PROVIDERS) { const key = env[provider.keyEnv]?.trim(); if (!key) continue; - const issuer = providerForKey(key); - const target = issuer ?? provider; - if (target.explicitOnly && explicit?.name !== target.name) continue; - out.push({ keyEnv: provider.keyEnv, key, provider: target, byPrefix: !!issuer }); + if (provider.explicitOnly && explicit?.name !== provider.name) continue; + out.push({ keyEnv: provider.keyEnv, key, provider }); } return out; } -/** Keys that are set but belong to an opt-in host nobody asked for, so the user can be told. */ +/** Opt-in hosts whose own variable is set while nobody asked for them, so the user can be told. */ function heldBackKeys(env: Env): KeyCandidate[] { const explicit = explicitProvider(env); const out: KeyCandidate[] = []; for (const provider of PROVIDERS) { const key = env[provider.keyEnv]?.trim(); - if (!key) continue; - const target = providerForKey(key) ?? provider; - if (target.explicitOnly && explicit?.name !== target.name) { - out.push({ keyEnv: provider.keyEnv, key, provider: target, byPrefix: true }); - } + if (!key || !provider.explicitOnly || provider.keyless || explicit?.name === provider.name) + continue; + out.push({ keyEnv: provider.keyEnv, key, provider }); } return out; } function heldBackNote(candidate: KeyCandidate): string { const { keyEnv, provider } = candidate; - return `${keyEnv} holds ${article(provider.label)} ${provider.label} key; that host is used only when ${ENV.provider}=${provider.name} is set.`; + return `${keyEnv} is set, but ${provider.label} is used only when ${ENV.provider}=${provider.name} is set.`; } /** @@ -158,30 +162,46 @@ function missingKeyError(env: Env): JevConfigError { (p) => `${p.keyEnv} (${p.label}, ${p.keysUrl})`, ); const last = options.pop(); - const optIn = PROVIDERS.filter((p) => p.explicitOnly).map((p) => + const held = heldBackKeys(env); + const optIn = PROVIDERS.filter( + (p) => p.explicitOnly && !held.some((c) => c.provider.name === p.name), + ).map((p) => p.keyless ? ` ${p.label}: set ${ENV.provider}=${p.name} (no key; ${p.hostEnv} names a server other than ${p.baseUrl}).` : ` ${p.label}: export ${p.keyEnv} and set ${ENV.provider}=${p.name}.`, ); - const held = heldBackKeys(env).map((c) => ` ${heldBackNote(c)}`); + const hints = [...held.map((c) => ` ${heldBackNote(c)}`), ...optIn]; return new JevConfigError( - `No API key found. Export one of ${options.join(", ")}, or ${last}.${held.join("") || optIn.join("")}`, + `No API key found. Export one of ${options.join(", ")}, or ${last}.${hints.join("")}`, ); } +/** The setting that redirected the key away from its variable's own host, and why. */ +interface Override { + variable: string; + reason: string; +} + function article(word: string): string { return /^[aeiou]/i.test(word) ? "an" : "a"; } -/** Describe what is set, for the error raised when none of it fits the requested host. */ -function describeCandidates(candidates: readonly KeyCandidate[]): string { - return candidates - .map((c) => - c.byPrefix - ? `${c.keyEnv} (${article(c.provider.label)} ${c.provider.label} key)` - : `${c.keyEnv} (unrecognised prefix)`, - ) - .join(", "); +/** A key shaped like another host's earns a hint, never a different route. */ +function looksLikeNote( + chosen: KeyCandidate, + provider: Provider, + override: Override | undefined, +): string | undefined { + const lookalike = providerForKey(chosen.key); + if (!lookalike || lookalike.name === provider.name) return undefined; + const prefix = keyPrefixes(lookalike).find((p) => chosen.key.startsWith(p)) ?? ""; + const fix = + lookalike.keyEnv === chosen.keyEnv && override + ? `unset ${override.variable}` + : lookalike.explicitOnly + ? `move it to ${lookalike.keyEnv} and set ${ENV.provider}=${lookalike.name}` + : `move it to ${lookalike.keyEnv}`; + return `${chosen.keyEnv} looks like ${article(lookalike.label)} ${lookalike.label} key (${prefix}…) but is sent to ${provider.label}, as set. If it belongs to ${lookalike.label}, ${fix}.`; } /** Observations worth showing next to a successful selection. */ @@ -190,6 +210,7 @@ function selectionNotes( candidates: readonly KeyCandidate[], provider: Provider, baseUrlOverride: string | undefined, + override: Override | undefined, ): string[] { const notes: string[] = []; if (baseUrlOverride && !providerForUrl(baseUrlOverride)) { @@ -197,17 +218,25 @@ function selectionNotes( `Requests go to ${baseUrlOverride} (${ENV.baseUrl}) with the ${provider.label} key.`, ); } - if (chosen.byPrefix && chosen.keyEnv !== chosen.provider.keyEnv) { + // A lookalike note already says where the key goes, so the SDK explanation would only nudge + // the other way; one hint at a time. + const lookalike = looksLikeNote(chosen, provider, override); + if (lookalike) notes.push(lookalike); + else if (override && chosen.keyEnv === GENERIC_KEY && provider.keyEnv !== GENERIC_KEY) { notes.push( - `${chosen.keyEnv} holds ${article(chosen.provider.label)} ${chosen.provider.label} key; requests go to ${chosen.provider.label}. Prefer ${chosen.provider.keyEnv} for that key.`, + `${GENERIC_KEY} is sent to ${provider.label} because ${override.reason}, as the TypeSafe SDK would; ${provider.keyEnv} says the same more plainly.`, ); } const others = candidates.filter((c) => c !== chosen); if (others.length > 0) { const names = [chosen, ...others].map((c) => c.keyEnv); - const switches = [...new Set(others.map((c) => c.provider.name))] - .filter((name) => name !== provider.name) - .map((name) => `${ENV.provider}=${name}`); + // Switching under a base URL override means changing the URL, so no one-liner is offered. + const switches = + override?.variable === ENV.baseUrl + ? [] + : [...new Set(others.map((c) => c.provider.name))] + .filter((name) => name !== provider.name) + .map((name) => `${ENV.provider}=${name}`); const hint = switches.length ? ` Set ${switches.join(" or ")} to switch.` : ""; notes.push( `${names.join(" and ")} are ${names.length > 2 ? "all" : "both"} set; using ${provider.label} (${chosen.keyEnv}).${hint}`, @@ -216,14 +245,33 @@ function selectionNotes( return notes; } +/** The host was named, its own variable is empty, and no generic key may follow. */ +function noKeyForTarget( + env: Env, + target: Provider, + candidates: readonly KeyCandidate[], + override: Override, +): JevConfigError { + const held = heldBackKeys(env).find((c) => c.provider.name === target.name); + if (held) return new JevConfigError(`${override.reason}; ${heldBackNote(held)}`); + const alsoGeneric = + acceptsGenericKey(target) && target.keyEnv !== GENERIC_KEY + ? ` (or ${GENERIC_KEY}, which follows ${override.variable} as the TypeSafe SDK does)` + : ""; + const set = candidates.map((c) => c.keyEnv).join(", "); + return new JevConfigError( + `${override.reason}, but no key is set for ${target.label}: export ${target.keyEnv}${alsoGeneric}. Set now: ${set}; a host-specific key is never sent to another host.`, + ); +} + /** * Pick the host and the key from the environment. * - * A key's prefix says which host issued it; a key with an unknown prefix belongs to the host - * of the variable it sits in. `JEV_CODE_PROVIDER`, or a `TYPESAFE_BASE_URL` on a known host, - * forces the host, and then only a key that belongs to that host is used. Otherwise the first - * key set wins. A key is never sent to a host that did not issue it. Throws `JevConfigError` - * when nothing usable is set. + * The variable a key sits in decides its host; a key's shape never does. `JEV_CODE_PROVIDER`, + * or a `TYPESAFE_BASE_URL` on a known host, names the host instead: that host's own variable + * is used, or else `TYPESAFE_API_KEY`, which follows the override the way the TypeSafe SDK + * does. A host-specific variable is never sent anywhere else. Otherwise the first variable set + * wins. Throws `JevConfigError` when nothing usable is set. */ export function selectProvider(env: Env): ProviderSelection { const explicitName = env[ENV.provider]?.trim(); @@ -233,7 +281,6 @@ export function selectProvider(env: Env): ProviderSelection { `${ENV.provider} must be one of ${PROVIDER_NAMES.join(", ")}, got "${explicitName}".`, ); } - if (explicit?.keyless) return selectKeyless(env, explicit); const baseUrlOverride = env[ENV.baseUrl]?.trim() || undefined; const urlProvider = baseUrlOverride ? providerForUrl(baseUrlOverride) : undefined; if (explicit && urlProvider && explicit.name !== urlProvider.name) { @@ -241,39 +288,33 @@ export function selectProvider(env: Env): ProviderSelection { `${ENV.provider}=${explicit.name} but ${ENV.baseUrl} points at ${urlProvider.label} (${urlProvider.host}). Change one of them.`, ); } + // A keyless host is reached only after the check above, so a stale base URL on another known + // host cannot carry its key or the payload there; an unrecognised URL is a proxy it asked for. + if (explicit?.keyless) return selectKeyless(env, explicit); const candidates = findKeys(env); const [first] = candidates; if (!first) throw missingKeyError(env); const target = explicit ?? urlProvider; - const chosen = target ? candidates.find((c) => c.provider.name === target.name) : first; - if (!chosen || !target) { - if (chosen) { - return finishSelection(env, chosen, candidates, chosen.provider, baseUrlOverride); - } - const wanted = target ?? first.provider; - const held = heldBackKeys(env).find((c) => c.provider.name === wanted.name); - if (held) { - throw new JevConfigError( - `${ENV.baseUrl} points at ${wanted.label} (${wanted.host}), but ${heldBackNote(held)}`, - ); - } - const reason = explicit - ? `${ENV.provider}=${explicit.name}` - : `${ENV.baseUrl} points at ${wanted.label} (${wanted.host})`; - const fix = explicit ? `unset ${ENV.provider}` : `unset ${ENV.baseUrl}`; - throw new JevConfigError( - `${reason}, but no ${wanted.label} key is set; found ${describeCandidates(candidates)}. Keys are sent only to the host that issued them: export ${wanted.keyEnv}, or ${fix}.`, - ); - } - return finishSelection(env, chosen, candidates, target, baseUrlOverride); + if (!target) return finishSelection(env, first, candidates, first.provider, baseUrlOverride); + + const override: Override = explicit + ? { variable: ENV.provider, reason: `${ENV.provider}=${explicit.name}` } + : { variable: ENV.baseUrl, reason: `${ENV.baseUrl} points at ${target.label}` }; + const own = candidates.find((c) => c.provider.name === target.name); + const generic = acceptsGenericKey(target) + ? candidates.find((c) => c.keyEnv === GENERIC_KEY) + : undefined; + const chosen = own ?? generic; + if (!chosen) throw noKeyForTarget(env, target, candidates, override); + return finishSelection(env, chosen, candidates, target, baseUrlOverride, override); } /** - * A base URL on an unrecognised host (a proxy) is applied to a TypeSafe key as it always was. - * For another host's key it needs `JEV_CODE_PROVIDER` to state the intent; otherwise an - * ambient OpenRouter or Vercel key must not follow a TypeSafe-named override to a stranger. + * A base URL on an unrecognised host (a proxy) is followed by TYPESAFE_API_KEY, as the SDK + * does. A host-specific key follows it only when `JEV_CODE_PROVIDER` states the intent, so an + * ambient OpenRouter or Vercel key never follows a TypeSafe-named override to a stranger. */ function assertProxyIsIntended( env: Env, @@ -282,9 +323,9 @@ function assertProxyIsIntended( baseUrlOverride: string | undefined, ): void { if (!baseUrlOverride || providerForUrl(baseUrlOverride)) return; - if (provider.name === DEFAULT_PROVIDER.name || env[ENV.provider]?.trim()) return; + if (chosen.keyEnv === GENERIC_KEY || env[ENV.provider]?.trim()) return; throw new JevConfigError( - `${ENV.baseUrl} points at an unrecognised host (${baseUrlOverride}) while the key in use is ${article(provider.label)} ${provider.label} key (${chosen.keyEnv}). A custom base URL applies to TypeSafe keys only, unless ${ENV.provider}=${provider.name} says the proxy is meant for ${provider.label}.`, + `${ENV.baseUrl} points at an unrecognised host (${baseUrlOverride}) while the key in use is ${chosen.keyEnv}, which belongs to ${provider.label}. A custom base URL is followed by ${GENERIC_KEY} only, unless ${ENV.provider}=${provider.name} says the proxy is meant for ${provider.label}.`, ); } @@ -294,6 +335,7 @@ function finishSelection( candidates: readonly KeyCandidate[], provider: Provider, baseUrlOverride: string | undefined, + override?: Override, ): ProviderSelection { assertProxyIsIntended(env, provider, chosen, baseUrlOverride); return { @@ -302,7 +344,7 @@ function finishSelection( apiKey: chosen.key, baseUrl: (baseUrlOverride ?? provider.baseUrl).replace(/\/+$/, ""), model: env[ENV.model]?.trim() || provider.model, - notes: selectionNotes(chosen, candidates, provider, baseUrlOverride), + notes: selectionNotes(chosen, candidates, provider, baseUrlOverride, override), }; } diff --git a/src/core/providers.ts b/src/core/providers.ts index d851815..5c44d63 100644 --- a/src/core/providers.ts +++ b/src/core/providers.ts @@ -14,7 +14,11 @@ export interface Provider { readonly label: string; /** Environment variable that conventionally holds this host's key. */ readonly keyEnv: string; - /** Prefix of the keys this host issues; it routes a key whichever variable holds it. */ + /** + * Prefix the host's keys usually carry. A hint only: it suggests a variable when a key is + * pasted and flags a key that looks misplaced. It never decides where a key is sent, because + * key shapes change (TypeSafe issues `ts_` and `apikey_` keys). + */ readonly keyPrefix?: string; /** The host works without a key (a local server); a key is sent only when one is set. */ readonly keyless?: true; @@ -48,6 +52,7 @@ const TYPESAFE: Provider = Object.freeze({ label: "TypeSafe", keyEnv: "TYPESAFE_API_KEY", keyPrefix: "ts_", + altPrefixes: Object.freeze(["apikey_"]), baseUrl: "https://api.typesafe.ai", host: "api.typesafe.ai", model: "jev-latest", @@ -143,7 +148,7 @@ export function keyPrefixes(provider: Provider): readonly string[] { return [...(provider.keyPrefix ? [provider.keyPrefix] : []), ...(provider.altPrefixes ?? [])]; } -/** The host that issued a key, judged by its prefix, or undefined when the prefix is unknown. */ +/** The host a key looks like it came from, by prefix; a hint, never a routing decision. */ export function providerForKey(key: string): Provider | undefined { return PROVIDERS.find((provider) => keyPrefixes(provider).some((p) => key.startsWith(p))); } diff --git a/tests/cli/prompt.test.ts b/tests/cli/prompt.test.ts index 1e2cdcf..ee524b1 100644 --- a/tests/cli/prompt.test.ts +++ b/tests/cli/prompt.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from "node:events"; import { describe, expect, it } from "vitest"; -import { readSecret } from "../../src/cli/prompt.js"; +import { readLine, readSecret } from "../../src/cli/prompt.js"; function fakeInput(isTTY: boolean) { const emitter = new EventEmitter(); @@ -36,8 +36,10 @@ function fakeInput(isTTY: boolean) { input, calls, type: (chunk: string) => emitter.emit("data", chunk), + end: () => emitter.emit("end"), fail: (error: Error) => emitter.emit("error", error), - listeners: () => emitter.listenerCount("data") + emitter.listenerCount("error"), + listeners: () => + emitter.listenerCount("data") + emitter.listenerCount("end") + emitter.listenerCount("error"), }; } @@ -91,3 +93,65 @@ describe("readSecret", () => { expect(calls).toEqual(["resume", "pause"]); }); }); + +describe("readLine", () => { + it("echoes the question, resolves with the typed line without its newline, and detaches", async () => { + const { input, calls, type, listeners } = fakeInput(true); + const out: string[] = []; + const pending = readLine("Which host? ", input, { write: (t: string) => out.push(t) }); + expect(out).toEqual(["Which host? "]); + type("2"); + type("\r\n"); + await expect(pending).resolves.toBe("2"); + expect(calls).toEqual(["raw:false", "resume", "pause"]); + expect(listeners()).toBe(0); + const { input: second, type: typeSecond } = fakeInput(false); + const whole = readLine("? ", second, { write: () => undefined }); + typeSecond("openrouter\n"); + await expect(whole).resolves.toBe("openrouter"); + }); + + it("treats end of input as Enter, and rejects when the stream fails", async () => { + const { input, type, end, listeners } = fakeInput(true); + const pending = readLine("? ", input, { write: () => undefined }); + type("vercel"); + end(); + await expect(pending).resolves.toBe("vercel"); + expect(listeners()).toBe(0); + const { input: failing, fail } = fakeInput(true); + const doomed = readLine("? ", failing, { write: () => undefined }); + fail(new Error("stdin closed")); + await expect(doomed).rejects.toThrow("stdin closed"); + }); + + it("carries text after the newline to the next prompt on the same stream", async () => { + const { input, type, listeners } = fakeInput(true); + const out: string[] = []; + const output = { write: (t: string) => out.push(t) }; + const first = readLine("first? ", input, output); + type("one\r\ntwo\nthree"); + await expect(first).resolves.toBe("one"); + // The second answer is already waiting, so it resolves without touching the stream. + await expect(readLine("second? ", input, output)).resolves.toBe("two"); + expect(listeners()).toBe(0); + const third = readLine("third? ", input, output); + expect(listeners()).toBe(3); + type("\n"); + await expect(third).resolves.toBe("three"); + expect(out).toEqual(["first? ", "second? ", "third? "]); + }); + + it("hands a secret's trailing paste to the visible prompt that follows", async () => { + const { input, type } = fakeInput(true); + const output = { write: () => undefined }; + const secret = readSecret("key? ", input, output); + type("\u001b[200~apikey_pasted\r\n2\r\u001b[201~"); + await expect(secret).resolves.toBe("apikey_pasted"); + await expect(readLine("host? ", input, output)).resolves.toBe("2"); + const { input: ended, type: typeEnded, end } = fakeInput(false); + const cut = readSecret("key? ", ended, output); + typeEnded("half"); + end(); + await expect(cut).rejects.toThrow("input ended before Enter"); + }); +}); diff --git a/tests/cli/run.test.ts b/tests/cli/run.test.ts index 9cdf7ec..f9a6943 100644 --- a/tests/cli/run.test.ts +++ b/tests/cli/run.test.ts @@ -34,6 +34,7 @@ function io(overrides: Partial = {}) { promptSecret: async () => { throw new Error("stub promptSecret in this test"); }, + promptLine: async () => "", ...overrides, }; return { io: base, out: () => out.join(""), err: () => err.join(""), home, cwd }; @@ -240,22 +241,33 @@ describe("runCli", () => { ]); const openrouter = io({ fetch, - env: { OPENROUTER_API_KEY: "sk-or-v1-1234567890", TYPESAFE_API_KEY: "sk-or-v1-0987654321" }, + env: { + OPENROUTER_API_KEY: "sk-or-v1-1234567890", + TYPESAFE_API_KEY: "ts_0987654321", + JEV_CODE_PROVIDER: "openrouter", + }, }); expect(await runCli(["doctor", "--live"], openrouter.io)).toBe(0); - expect(openrouter.out()).toMatch(/provider\s+OpenRouter \(TYPESAFE_API_KEY sk-o…4321\)/); + expect(openrouter.out()).toMatch(/provider\s+OpenRouter \(OPENROUTER_API_KEY sk-o…7890\)/); expect(openrouter.out()).toContain("https://openrouter.ai/api"); - expect(openrouter.out()).toContain("both set"); + expect(openrouter.out()).toContain("both set; using OpenRouter (OPENROUTER_API_KEY)"); + expect(openrouter.out()).toContain("Set JEV_CODE_PROVIDER=typesafe to switch"); expect(openrouter.out()).toContain("via OpenRouter (typesafe/jev-1.13)"); expect(openrouter.out()).not.toContain("1234567890"); + expect(openrouter.out()).not.toContain("0987654321"); + // A key that merely looks like another host's stays where it is, with a hint. + const lookalike = io({ env: { TYPESAFE_API_KEY: "sk-or-v1-0987654321" } }); + expect(await runCli(["doctor"], lookalike.io)).toBe(0); + expect(lookalike.out()).toMatch(/provider\s+TypeSafe \(TYPESAFE_API_KEY sk-o…4321\)/); + expect(lookalike.out()).toContain("looks like an OpenRouter key"); const broken = io({ - env: { TYPESAFE_API_KEY: "ts_a", TYPESAFE_BASE_URL: "https://openrouter.ai/api" }, + env: { OPENROUTER_API_KEY: "sk-or-a", TYPESAFE_BASE_URL: "https://api.typesafe.ai" }, }); expect(await runCli(["doctor"], broken.io)).toBe(1); expect(broken.out()).toContain("PROBLEM"); - expect(broken.out()).toContain("OpenRouter"); + expect(broken.out()).toContain("export TYPESAFE_API_KEY"); const brokenLive = io({ - env: { TYPESAFE_API_KEY: "ts_a", TYPESAFE_BASE_URL: "https://openrouter.ai/api" }, + env: { OPENROUTER_API_KEY: "sk-or-a", TYPESAFE_BASE_URL: "https://api.typesafe.ai" }, }); expect(await runCli(["doctor", "--live"], brokenLive.io)).toBe(1); expect(brokenLive.out()).toContain("Live check: skipped, fix the configuration problem"); @@ -270,10 +282,17 @@ describe("runCli", () => { asked.push(question); return " sk-or-v1-pasted "; }, + promptLine: async (question) => { + asked.push(question); + return ""; + }, }); expect(await runCli(["setup", "opencode"], s.io)).toBe(0); - expect(asked).toHaveLength(1); + expect(asked).toHaveLength(2); expect(asked[0]).toMatch(/TypeSafe, OpenRouter, Vercel AI Gateway, or OpenAI key/); + expect(asked[1]).toMatch( + /Which host is this key for\? \[1\] TypeSafe {2}\[2\] OpenRouter {2}\[3\] Vercel AI Gateway {2}\[4\] OpenAI Decisions API\. Number or name, Enter for OpenRouter: /, + ); const config = JSON.parse( readFileSync(join(s.home, ".config", "opencode", "opencode.json"), "utf8"), ); @@ -384,18 +403,52 @@ describe("runCli", () => { expect(prompt.out()).toContain("Using Ollama (no key)"); }); - it("stores an unrecognised pasted key as TypeSafe's, and reports a conflicting override", async () => { + it("stores a pasted key where the user says, defaulting to TypeSafe or the named host", async () => { const plain = io({ env: {}, promptSecret: async () => "opaque-key-1234" }); expect(await runCli(["setup", "opencode"], plain.io)).toBe(0); expect(plain.out()).toContain("Using TypeSafe (TYPESAFE_API_KEY"); expect(plain.out()).toContain("export TYPESAFE_API_KEY="); + const named = io({ + env: { JEV_CODE_PROVIDER: "vercel" }, + promptSecret: async () => "opaque-key-1234", + promptLine: async (question) => { + expect(question).toContain("Enter for Vercel AI Gateway"); + return ""; + }, + }); + expect(await runCli(["setup", "opencode"], named.io)).toBe(0); + expect(named.out()).toContain("Using Vercel AI Gateway (AI_GATEWAY_API_KEY"); + const byNumber = io({ + env: {}, + promptSecret: async () => "opaque-key-1234", + promptLine: async () => " 2 ", + }); + expect(await runCli(["setup", "opencode"], byNumber.io)).toBe(0); + expect(byNumber.out()).toContain("Using OpenRouter (OPENROUTER_API_KEY"); + const byName = io({ + env: {}, + promptSecret: async () => "opaque-key-1234", + promptLine: async () => "Vercel AI Gateway", + }); + expect(await runCli(["setup", "opencode"], byName.io)).toBe(0); + expect(byName.out()).toContain("Using Vercel AI Gateway (AI_GATEWAY_API_KEY"); + const unknown = io({ + env: {}, + promptSecret: async () => "opaque-key-1234", + promptLine: async () => "7", + }); + expect(await runCli(["setup", "opencode"], unknown.io)).toBe(0); + expect(unknown.out()).toContain('Did not recognise "7"; using TypeSafe.'); + expect(unknown.out()).toContain("Using TypeSafe (TYPESAFE_API_KEY"); + // A host-specific key chosen against JEV_CODE_PROVIDER is a conflict, so nothing is written. const conflicting = io({ env: { JEV_CODE_PROVIDER: "vercel" }, promptSecret: async () => "opaque-key-1234", + promptLine: async () => "openrouter", }); expect(await runCli(["setup", "opencode"], conflicting.io)).toBe(0); expect(conflicting.out()).not.toContain("Using "); - expect(conflicting.out()).toContain("no Vercel AI Gateway key is set"); + expect(conflicting.out()).toContain("no key is set for Vercel AI Gateway"); expect(conflicting.out()).toContain("No key was written into any config"); }); }); diff --git a/tests/core/config.test.ts b/tests/core/config.test.ts index d727b93..185381e 100644 --- a/tests/core/config.test.ts +++ b/tests/core/config.test.ts @@ -51,11 +51,29 @@ describe("resolveConfig", () => { }); }); - it("routes by key prefix whichever variable holds the key", () => { - const config = resolveConfig({ TYPESAFE_API_KEY: "sk-or-v1-abc" }); - expect(config.provider).toBe("openrouter"); - expect(config.keyEnv).toBe("TYPESAFE_API_KEY"); - expect(config.baseUrl).toBe("https://openrouter.ai/api"); + it("sends a key to its variable's host whatever it looks like, and only hints at a lookalike", () => { + // TypeSafe issues ts_ and apikey_ keys and shapes change, so the variable decides. + expect(resolveConfig({ TYPESAFE_API_KEY: "apikey_abc" })).toMatchObject({ + provider: "typesafe", + apiKey: "apikey_abc", + }); + expect(describeConfig({ TYPESAFE_API_KEY: "apikey_abc" }).notes).toEqual([]); + expect(resolveConfig({ TYPESAFE_API_KEY: "sk-or-v1-abc" })).toMatchObject({ + provider: "typesafe", + keyEnv: "TYPESAFE_API_KEY", + baseUrl: "https://api.typesafe.ai", + }); + expect(resolveConfig({ OPENROUTER_API_KEY: "ts_abc" })).toMatchObject({ + provider: "openrouter", + apiKey: "ts_abc", + baseUrl: "https://openrouter.ai/api", + }); + expect(describeConfig({ OPENROUTER_API_KEY: "ts_abc" }).notes.join(" ")).toMatch( + /OPENROUTER_API_KEY looks like a TypeSafe key \(ts_…\) but is sent to OpenRouter.*move it to TYPESAFE_API_KEY/, + ); + expect(describeConfig({ TYPESAFE_API_KEY: "sk-proj-abc" }).notes.join(" ")).toMatch( + /looks like an OpenAI Decisions API key.*move it to OPENAI_API_KEY and set JEV_CODE_PROVIDER=openai/, + ); }); it("prefers TypeSafe when several keys are set, and JEV_CODE_PROVIDER switches", () => { @@ -90,7 +108,7 @@ describe("resolveConfig", () => { TYPESAFE_BASE_URL: "https://proxy.internal/or/", }; expect(() => resolveConfig(ambient)).toThrow( - /unrecognised host.*OpenRouter key.*JEV_CODE_PROVIDER=openrouter/s, + /unrecognised host.*OPENROUTER_API_KEY, which belongs to OpenRouter.*JEV_CODE_PROVIDER=openrouter/s, ); const intended = resolveConfig({ ...ambient, JEV_CODE_PROVIDER: "openrouter" }); expect(intended).toMatchObject({ @@ -99,7 +117,56 @@ describe("resolveConfig", () => { }); }); - it("keeps an unprefixed key on its own variable's host and never lends it to another", () => { + it("lets TYPESAFE_API_KEY follow JEV_CODE_PROVIDER and TYPESAFE_BASE_URL like the TypeSafe SDK", () => { + // The SDK's own OpenRouter and Vercel guides set TYPESAFE_API_KEY plus the base URL. + const viaUrl = resolveConfig({ + TYPESAFE_API_KEY: "legacy", + AI_GATEWAY_API_KEY: "legacy2", + TYPESAFE_BASE_URL: "https://openrouter.ai/api", + }); + expect(viaUrl).toMatchObject({ + provider: "openrouter", + keyEnv: "TYPESAFE_API_KEY", + apiKey: "legacy", + baseUrl: "https://openrouter.ai/api", + headers: { "HTTP-Referer": expect.any(String) }, + }); + const viaName = describeConfig({ + TYPESAFE_API_KEY: "legacy-key-without-prefix", + JEV_CODE_PROVIDER: "openrouter", + }); + expect(viaName).toMatchObject({ provider: "openrouter", keyEnv: "TYPESAFE_API_KEY" }); + expect(viaName.notes.join(" ")).toMatch( + /TYPESAFE_API_KEY is sent to OpenRouter because JEV_CODE_PROVIDER=openrouter, as the TypeSafe SDK would; OPENROUTER_API_KEY says the same/, + ); + expect( + resolveConfig({ + TYPESAFE_API_KEY: "legacy-key", + TYPESAFE_BASE_URL: "https://ai-gateway.vercel.sh/typesafe", + }), + ).toMatchObject({ provider: "vercel", keyEnv: "TYPESAFE_API_KEY", model: "typesafe-ai/jev" }); + // A ts_ key redirected by an override is still sent, with a hint naming the override. + const redirected = describeConfig({ + TYPESAFE_API_KEY: "ts_a", + TYPESAFE_BASE_URL: "https://openrouter.ai/api", + }); + expect(redirected.provider).toBe("openrouter"); + expect(redirected.notes).toHaveLength(1); + expect(redirected.notes.join(" ")).toMatch( + /TYPESAFE_API_KEY looks like a TypeSafe key \(ts_…\) but is sent to OpenRouter.*unset TYPESAFE_BASE_URL/, + ); + // The host's own variable wins over the generic one, and no switch is offered under a URL. + const both = describeConfig({ + TYPESAFE_API_KEY: "ts_a", + OPENROUTER_API_KEY: "sk-or-b", + TYPESAFE_BASE_URL: "https://openrouter.ai/api", + }); + expect(both).toMatchObject({ provider: "openrouter", keyEnv: "OPENROUTER_API_KEY" }); + expect(both.notes.join(" ")).toContain("both set; using OpenRouter (OPENROUTER_API_KEY)."); + expect(both.notes.join(" ")).not.toContain("to switch"); + }); + + it("never sends a host-specific key to another host", () => { expect(resolveConfig({ OPENROUTER_API_KEY: "opaque-token" })).toMatchObject({ provider: "openrouter", apiKey: "opaque-token", @@ -108,45 +175,30 @@ describe("resolveConfig", () => { resolveConfig({ AI_GATEWAY_API_KEY: "eyJhbGciOi.oidc", JEV_CODE_PROVIDER: "vercel" }), ).toMatchObject({ provider: "vercel" }); expect(() => - resolveConfig({ - TYPESAFE_API_KEY: "legacy-key-without-prefix", - JEV_CODE_PROVIDER: "openrouter", - }), + resolveConfig({ OPENROUTER_API_KEY: "sk-or-b", JEV_CODE_PROVIDER: "typesafe" }), ).toThrow( - /no OpenRouter key is set; found TYPESAFE_API_KEY \(unrecognised prefix\).*OPENROUTER_API_KEY/s, + /JEV_CODE_PROVIDER=typesafe, but no key is set for TypeSafe: export TYPESAFE_API_KEY\. Set now: OPENROUTER_API_KEY; a host-specific key is never sent to another host/, ); expect(() => - resolveConfig({ - TYPESAFE_API_KEY: "legacy", - AI_GATEWAY_API_KEY: "legacy2", - TYPESAFE_BASE_URL: "https://openrouter.ai/api", - }), - ).toThrow(/OpenRouter.*TYPESAFE_API_KEY.*AI_GATEWAY_API_KEY/s); + resolveConfig({ OPENROUTER_API_KEY: "sk-or-b", JEV_CODE_PROVIDER: "vercel" }), + ).toThrow( + /no key is set for Vercel AI Gateway: export AI_GATEWAY_API_KEY \(or TYPESAFE_API_KEY, which follows JEV_CODE_PROVIDER as the TypeSafe SDK does\)/, + ); expect(() => resolveConfig({ - TYPESAFE_API_KEY: "legacy-key", + OPENROUTER_API_KEY: "sk-or-b", TYPESAFE_BASE_URL: "https://ai-gateway.vercel.sh/typesafe", }), - ).toThrow(/AI_GATEWAY_API_KEY/); - }); - - it("never sends a key to a host that did not issue it", () => { - expect(() => - resolveConfig({ TYPESAFE_API_KEY: "ts_a", TYPESAFE_BASE_URL: "https://openrouter.ai/api" }), - ).toThrow(/OpenRouter.*TypeSafe key/s); - expect(() => - resolveConfig({ TYPESAFE_API_KEY: "ts_a", JEV_CODE_PROVIDER: "openrouter" }), - ).toThrow(/OPENROUTER_API_KEY/); - expect(() => - resolveConfig({ OPENROUTER_API_KEY: "sk-or-b", JEV_CODE_PROVIDER: "typesafe" }), - ).toThrow(/TYPESAFE_API_KEY/); + ).toThrow( + /TYPESAFE_BASE_URL points at Vercel AI Gateway, but no key is set.*AI_GATEWAY_API_KEY/s, + ); expect(() => resolveConfig({ OPENROUTER_API_KEY: "sk-or-b", JEV_CODE_PROVIDER: "openrouter", TYPESAFE_BASE_URL: "https://api.typesafe.ai", }), - ).toThrow(/JEV_CODE_PROVIDER/); + ).toThrow(/JEV_CODE_PROVIDER=openrouter but TYPESAFE_BASE_URL points at TypeSafe/); }); it("uses OpenAI's Decisions API only when JEV_CODE_PROVIDER asks for it", () => { @@ -158,8 +210,9 @@ describe("resolveConfig", () => { } expect(error).toBeInstanceOf(JevConfigError); expect((error as Error).message).toMatch(/^No API key found/); - expect((error as Error).message).toContain("OPENAI_API_KEY holds an OpenAI Decisions API key"); - expect((error as Error).message).toContain("JEV_CODE_PROVIDER=openai"); + expect((error as Error).message).toContain( + "OPENAI_API_KEY is set, but OpenAI Decisions API is used only when JEV_CODE_PROVIDER=openai is set.", + ); const summary = describeConfig({ OPENAI_API_KEY: "sk-proj-abc" }); expect(summary.hasApiKey).toBe(false); expect(summary.notes.join(" ")).toContain("JEV_CODE_PROVIDER=openai"); @@ -185,21 +238,27 @@ describe("resolveConfig", () => { OPENAI_API_KEY: "sk-proj-abc", TYPESAFE_BASE_URL: "https://api.openai.com", }), - ).toThrow(/points at OpenAI Decisions API.*OPENAI_API_KEY holds.*JEV_CODE_PROVIDER=openai/s); - // An OpenAI key in another variable is held back the same way, and usable once asked for. - expect(() => resolveConfig({ TYPESAFE_API_KEY: "sk-proj-abc" })).toThrow( - /JEV_CODE_PROVIDER=openai/, + ).toThrow( + /points at OpenAI Decisions API; OPENAI_API_KEY is set, but.*JEV_CODE_PROVIDER=openai/s, ); - expect( + // TYPESAFE_API_KEY never follows to OpenAI: the TypeSafe SDK does not speak that wire. + expect(() => resolveConfig({ TYPESAFE_API_KEY: "sk-proj-abc", JEV_CODE_PROVIDER: "openai" }), - ).toMatchObject({ - provider: "openai", - keyEnv: "TYPESAFE_API_KEY", - }); + ).toThrow( + /no key is set for OpenAI Decisions API: export OPENAI_API_KEY\. Set now: TYPESAFE_API_KEY/, + ); }); it("uses Ollama without a key when JEV_CODE_PROVIDER names it, honouring OLLAMA_HOST", () => { expect(() => resolveConfig({})).toThrow(/Ollama: set JEV_CODE_PROVIDER=ollama \(no key/); + // An Ollama key alone is not a held-back key; the opt-in hint still names the host. + expect(() => resolveConfig({ OLLAMA_API_KEY: "oll_abc" })).toThrow( + /^No API key found\..*OpenAI Decisions API: export OPENAI_API_KEY.*Ollama: set JEV_CODE_PROVIDER=ollama/s, + ); + // A held-back OpenAI key and the other opt-in hint are both shown. + expect(() => resolveConfig({ OPENAI_API_KEY: "sk-proj-abc" })).toThrow( + /OPENAI_API_KEY is set, but OpenAI Decisions API is used only when JEV_CODE_PROVIDER=openai is set\. Ollama: set JEV_CODE_PROVIDER=ollama/, + ); const local = resolveConfig({ JEV_CODE_PROVIDER: "ollama" }); expect(local).toMatchObject({ provider: "ollama", @@ -224,6 +283,14 @@ describe("resolveConfig", () => { TYPESAFE_BASE_URL: "http://127.0.0.1:9", }), ).toMatchObject({ baseUrl: "http://127.0.0.1:9" }); + // A stale base URL on another known host never carries Ollama's key or the payload there. + expect(() => + resolveConfig({ + JEV_CODE_PROVIDER: "ollama", + OLLAMA_API_KEY: "oll_abc", + TYPESAFE_BASE_URL: "https://api.openai.com", + }), + ).toThrow(/JEV_CODE_PROVIDER=ollama but TYPESAFE_BASE_URL points at OpenAI Decisions API/); const keyed = resolveConfig({ JEV_CODE_PROVIDER: "ollama", OLLAMA_API_KEY: "oll_abc", @@ -303,19 +370,19 @@ describe("describeConfig", () => { /TYPESAFE_API_KEY and OPENROUTER_API_KEY are both set.*using TypeSafe.*JEV_CODE_PROVIDER=openrouter/, ); const misplaced = describeConfig({ TYPESAFE_API_KEY: "sk-or-b" }); - expect(misplaced.provider).toBe("openrouter"); + expect(misplaced.provider).toBe("typesafe"); expect(misplaced.notes.join(" ")).toMatch( - /TYPESAFE_API_KEY holds an OpenRouter key.*OPENROUTER_API_KEY/, + /TYPESAFE_API_KEY looks like an OpenRouter key \(sk-or-…\) but is sent to TypeSafe, as set\. If it belongs to OpenRouter, move it to OPENROUTER_API_KEY/, ); }); it("surfaces a broken combination as a problem instead of throwing", () => { const summary = describeConfig({ - TYPESAFE_API_KEY: "ts_a", - TYPESAFE_BASE_URL: "https://openrouter.ai/api", + OPENROUTER_API_KEY: "sk-or-b", + TYPESAFE_BASE_URL: "https://api.typesafe.ai", }); expect(summary.hasApiKey).toBe(true); - expect(summary.problem).toMatch(/OpenRouter/); + expect(summary.problem).toMatch(/points at TypeSafe, but no key is set for TypeSafe/); expect(summary.provider).toBeUndefined(); }); }); diff --git a/tests/core/providers.test.ts b/tests/core/providers.test.ts index 4cc2a24..d6ef4c1 100644 --- a/tests/core/providers.test.ts +++ b/tests/core/providers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + DEFAULT_PROVIDER, keyPrefixes, PROVIDER_NAMES, PROVIDERS, @@ -27,7 +28,7 @@ describe("providers", () => { } }); - it("keeps prefixes distinct and the table frozen, since it decides where keys go", () => { + it("keeps prefixes distinct and the table frozen", () => { for (const a of PROVIDERS) { for (const b of PROVIDERS) { if (a === b) continue; @@ -41,8 +42,10 @@ describe("providers", () => { expect(Object.isFrozen(PROVIDERS)).toBe(true); }); - it("routes a key by its prefix", () => { + it("recognises the usual key shapes as hints", () => { + expect(keyPrefixes(DEFAULT_PROVIDER)).toEqual(["ts_", "apikey_"]); expect(providerForKey("ts_abc")?.name).toBe("typesafe"); + expect(providerForKey("apikey_abc")?.name).toBe("typesafe"); expect(providerForKey("sk-or-v1-abc")?.name).toBe("openrouter"); expect(providerForKey("vck_abc")?.name).toBe("vercel"); expect(providerForKey("sk-proj-abc")?.name).toBe("openai"); diff --git a/tests/setup/configs.test.ts b/tests/setup/configs.test.ts index eea9806..3fa39e2 100644 --- a/tests/setup/configs.test.ts +++ b/tests/setup/configs.test.ts @@ -70,7 +70,14 @@ describe("config editors", () => { { TYPESAFE_API_KEY: "ts_a", TYPESAFE_BASE_URL: "https://openrouter.ai/api" }, { includeApiKey: true }, ), - ).toEqual({ TYPESAFE_BASE_URL: "https://openrouter.ai/api" }); + ).toEqual({ TYPESAFE_API_KEY: "ts_a", TYPESAFE_BASE_URL: "https://openrouter.ai/api" }); + // A broken combination bakes no key at all. + expect( + serverEnvFromProcess( + { OPENROUTER_API_KEY: "sk-or-b", TYPESAFE_BASE_URL: "https://api.typesafe.ai" }, + { includeApiKey: true }, + ), + ).toEqual({ TYPESAFE_BASE_URL: "https://api.typesafe.ai" }); }); it("upserts the OpenCode entry, keeps other keys, backs up, and is idempotent", () => {