diff --git a/AGENTS.md b/AGENTS.md index fa28b8b..367bb19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,9 @@ Single Node.js/TypeScript CLI — no server, no watch mode. an authenticated `gh` CLI. The Anthropic backend also fetches the PR diff for code context, so `GITHUB_TOKEN`/`gh` auth improves it but is non-fatal if missing. Without credentials the runner still completes, recording per-task `ERROR`/`SKIPPED` instead of crashing. +- **Persisting API keys** — `quorum auth` saves `CURSOR_API_KEY` / `ANTHROPIC_API_KEY` / `QUORUM_PROVIDER` + to `~/.config/quorum/credentials.json` (0o600) so you don't `export` them every shell. Key resolution + order: `--api-key` flag > process env > config file. Override the file path with `QUORUM_CONFIG`. - Run artifacts are written under `.quorum/` (gitignored). ## Cursor Cloud diff --git a/README.md b/README.md index cd701f5..b928081 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ exploration.md + exploration.json + Canvas + optional PR comment - `npm` - `gh` CLI, authenticated for the target GitHub repo - `jq` and `python3` for the skill scripts -- An exploration backend key for live runs: `CURSOR_API_KEY` (default Cursor Cloud backend) or `ANTHROPIC_API_KEY` (with `--provider anthropic`). An authenticated `gh` or `GITHUB_TOKEN` also lets the Anthropic backend fetch the PR diff for code context; it degrades gracefully without one. +- An exploration backend key for live runs: `CURSOR_API_KEY` (default Cursor Cloud backend) or `ANTHROPIC_API_KEY` (with `--provider anthropic`). An authenticated `gh` or `GITHUB_TOKEN` also lets the Anthropic backend fetch the PR diff for code context; it degrades gracefully without one. You can persist keys with `quorum auth` (see [Persisting API keys](#persisting-api-keys)) so you don't need to `export` them every shell. Install dependencies and build: @@ -141,11 +141,14 @@ This fetches findings, scores clusters deterministically, and upserts the synthe First set the API key for your backend (Cursor Cloud is the default): ```bash -export CURSOR_API_KEY="crsr_..." # default backend +quorum auth # interactive menu (recommended) +quorum auth --cursor-key "crsr_..." # set Cursor Cloud key non-interactively # or, for the Anthropic backend: -export ANTHROPIC_API_KEY="sk-ant-..." +quorum auth --anthropic-key "sk-ant-..." --provider anthropic ``` +You can still use environment variables instead of, or to override, the config file (see [Persisting API keys](#persisting-api-keys) for the full resolution order). + Generate the exploration DAG, report shell, and Canvas without making any cloud or GitHub calls: ```bash @@ -189,6 +192,35 @@ Set the default with the `QUORUM_PROVIDER` environment variable. Each backend ha The Anthropic backend is **code-aware**: it fetches the pull request's unified diff and injects it as the agent's view of the code (capped at 200k characters, read-only — it never edits, commits, or pushes). When the diff cannot be fetched it falls back to a "no code context" note and flags missing evidence rather than guessing, so authenticate `gh` or set `GITHUB_TOKEN` for best results. +### Persisting API keys + +`quorum auth` saves API keys and the default provider to a config file so you don't need to `export` them in every shell. The file lives at: + +```text +$XDG_CONFIG_HOME/quorum/credentials.json (or ~/.config/quorum/credentials.json) +``` + +It is created with mode `0o600` and is never committed. Override the path with the `QUORUM_CONFIG` environment variable. + +API key resolution order (highest precedence first): + +1. `--api-key` flag on the command line +2. `CURSOR_API_KEY` / `ANTHROPIC_API_KEY` environment variable +3. Persisted config file (set via `quorum auth`) + +The default provider resolves the same way: `--provider` flag > `QUORUM_PROVIDER` env var > config file > `cursor`. + +```bash +quorum auth # interactive menu (recommended) +quorum auth --cursor-key "crsr_..." # set Cursor Cloud key non-interactively +quorum auth --anthropic-key "sk-ant-..." --provider anthropic +quorum auth --provider anthropic # change only the default provider +quorum auth --show # print masked values +quorum auth --clear # remove all stored keys +``` + +`quorum setup` reads the config file too, so it reports keys as "set" whether they come from the environment or the config file. + Open or regenerate a Canvas from an existing run: ```bash @@ -230,6 +262,7 @@ Useful options: ```bash quorum setup Check prerequisites, backend keys, and skill install. +quorum auth Persist API keys / default provider to a config file. quorum eval [--log-dir DIR] Summarize past run logs (task outcomes, durations). quorum run-dag --dag dag.json --out DIR --repo OWNER/REPO Run a saved DAG directly. ``` diff --git a/src/cli.ts b/src/cli.ts index 8517a4e..ce7f5c0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { execFile } from "node:child_process"; import { mkdir, readFile, writeFile } from "node:fs/promises"; +import * as readline from "node:readline/promises"; import { basename, dirname, join } from "node:path"; import { promisify } from "node:util"; import { AnthropicAdapter } from "./adapters/anthropic.js"; @@ -11,6 +12,13 @@ import { writeCanvas, } from "./canvas.js"; import { parseClusterIds, parseScoredClusters, selectClusters } from "./clusters.js"; +import { + credentialsFilePath, + loadCredentials, + maskSecret, + saveCredentials, +} from "./config.js"; +import type { Credentials } from "./config.js"; import { CursorCloudAdapter } from "./cursor-cloud-adapter.js"; import { parseDag } from "./dag.js"; import { upsertExplorationComment } from "./github.js"; @@ -94,6 +102,10 @@ async function main(): Promise { await setupCommand(); return; } + if (parsed.command === "auth") { + await authCommand(parsed); + return; + } if (parsed.command === "eval") { await evalCommand(parsed); return; @@ -260,8 +272,10 @@ function runnerContext( canvasMirrorPath: string | undefined, ): RunnerContext { const provider = resolveProvider(parsed); + const creds = loadCredentials(); const apiKey = flag(parsed, "api-key") - ?? (provider === "anthropic" ? process.env.ANTHROPIC_API_KEY : process.env.CURSOR_API_KEY); + ?? (provider === "anthropic" ? process.env.ANTHROPIC_API_KEY : process.env.CURSOR_API_KEY) + ?? (provider === "anthropic" ? creds.ANTHROPIC_API_KEY : creds.CURSOR_API_KEY); return { repo, pr, @@ -283,7 +297,10 @@ function runnerContext( } function resolveProvider(parsed: ParsedArgs): "cursor" | "anthropic" { - const provider = flag(parsed, "provider") ?? process.env.QUORUM_PROVIDER ?? "cursor"; + const provider = flag(parsed, "provider") + ?? process.env.QUORUM_PROVIDER + ?? loadCredentials().QUORUM_PROVIDER + ?? "cursor"; return provider === "anthropic" ? "anthropic" : "cursor"; } @@ -527,14 +544,15 @@ async function setupCommand(): Promise { checks.push({ name: "python3", ok: false, help: "Install from https://python.org" }); } - // API keys - const cursorKey = !!process.env.CURSOR_API_KEY; - const anthropicKey = !!process.env.ANTHROPIC_API_KEY; + // API keys — resolve from env OR the persisted config file (see `quorum auth`). + const creds = loadCredentials(); + const cursorKey = !!process.env.CURSOR_API_KEY || !!creds.CURSOR_API_KEY; + const anthropicKey = !!process.env.ANTHROPIC_API_KEY || !!creds.ANTHROPIC_API_KEY; const githubToken = !!process.env.GITHUB_TOKEN || !!process.env.GH_TOKEN; checks.push({ name: `CURSOR_API_KEY (${cursorKey ? "set" : "not set"})`, ok: cursorKey || anthropicKey, - help: "Set CURSOR_API_KEY for Cursor Cloud or ANTHROPIC_API_KEY for Anthropic", + help: "Run `quorum auth` to persist a key, or set CURSOR_API_KEY / ANTHROPIC_API_KEY", }); checks.push({ name: `GITHUB_TOKEN (${githubToken ? "set" : "not set"}, optional)`, @@ -582,6 +600,200 @@ async function dirExists(path: string): Promise { } } +// ---- auth command ---- + +/** + * Persist API keys / default provider to a config file so users don't have to + * `export` them in every shell. Precedence at run time remains: + * --api-key flag > process.env > config file + * + * Modes: + * `quorum auth` interactive menu + * `quorum auth --cursor-key K` set CURSOR_API_KEY non-interactively + * `quorum auth --anthropic-key K` set ANTHROPIC_API_KEY non-interactively + * `quorum auth --provider P` set default provider (cursor|anthropic) + * `quorum auth --show` print masked values + * `quorum auth --clear` remove all stored keys + */ +async function authCommand(parsed: ParsedArgs): Promise { + if (hasFlag(parsed, "show")) { + printCredentials(); + return; + } + if (hasFlag(parsed, "clear")) { + saveCredentials({}); + console.log("Cleared all stored credentials."); + console.log(` File retained (now empty): ${credentialsFilePath()}`); + return; + } + + const cursorKey = flag(parsed, "cursor-key"); + const anthropicKey = flag(parsed, "anthropic-key"); + const provider = flag(parsed, "provider"); + + if (cursorKey === "true") throw new Error("Missing --cursor-key."); + if (anthropicKey === "true") throw new Error("Missing --anthropic-key."); + if (provider === "true") throw new Error("Missing --provider."); + + const hasSetValue = + cursorKey !== undefined || + anthropicKey !== undefined || + provider !== undefined; + + if (hasSetValue) { + const creds = { ...loadCredentials() }; + if (cursorKey !== undefined) { + creds.CURSOR_API_KEY = cursorKey; + } + if (anthropicKey !== undefined) { + creds.ANTHROPIC_API_KEY = anthropicKey; + } + if (provider !== undefined) { + if (provider !== "cursor" && provider !== "anthropic") { + throw new Error('--provider must be "cursor" or "anthropic".'); + } + creds.QUORUM_PROVIDER = provider; + } + saveCredentials(creds); + printSavedSummary(creds); + return; + } + + await interactiveAuth(); +} + +function printCredentials(): void { + const creds = loadCredentials(); + console.log(`Credentials file: ${credentialsFilePath()}\n`); + console.log(` CURSOR_API_KEY ${maskSecret(creds.CURSOR_API_KEY)}`); + console.log(` ANTHROPIC_API_KEY ${maskSecret(creds.ANTHROPIC_API_KEY)}`); + console.log(` QUORUM_PROVIDER ${creds.QUORUM_PROVIDER ?? "cursor (default)"}`); +} + +function printSavedSummary(creds: Credentials): void { + console.log(`\nSaved to ${credentialsFilePath()} (mode 600)\n`); + console.log(` CURSOR_API_KEY ${maskSecret(creds.CURSOR_API_KEY)}`); + console.log(` ANTHROPIC_API_KEY ${maskSecret(creds.ANTHROPIC_API_KEY)}`); + console.log(` QUORUM_PROVIDER ${creds.QUORUM_PROVIDER ?? "cursor (default)"}`); +} + +async function interactiveAuth(): Promise { + let creds = { ...loadCredentials() }; + while (true) { + console.log("\nQuorum credentials\n"); + console.log(` CURSOR_API_KEY ${maskSecret(creds.CURSOR_API_KEY)}`); + console.log(` ANTHROPIC_API_KEY ${maskSecret(creds.ANTHROPIC_API_KEY)}`); + console.log(` Default provider ${creds.QUORUM_PROVIDER ?? "cursor (default)"}\n`); + console.log("Choose an action:"); + console.log(" 1) Set Cursor Cloud key (CURSOR_API_KEY)"); + console.log(" 2) Set Anthropic key (ANTHROPIC_API_KEY)"); + console.log(" 3) Set default provider"); + console.log(" 4) Clear all stored keys"); + console.log(" 5) Exit\n"); + + const choice = (await ask("Action [1-5]: ")).trim(); + if (choice === "1") { + const key = (await askHidden("Enter CURSOR_API_KEY (input hidden): ")).trim(); + if (key) { + creds.CURSOR_API_KEY = key; + saveCredentials(creds); + console.log(` Saved. CURSOR_API_KEY ${maskSecret(key)}`); + } else { + console.log(" Skipped (empty)."); + } + } else if (choice === "2") { + const key = (await askHidden("Enter ANTHROPIC_API_KEY (input hidden): ")).trim(); + if (key) { + creds.ANTHROPIC_API_KEY = key; + saveCredentials(creds); + console.log(` Saved. ANTHROPIC_API_KEY ${maskSecret(key)}`); + } else { + console.log(" Skipped (empty)."); + } + } else if (choice === "3") { + const p = (await ask("Default provider (cursor/anthropic): ")).trim(); + if (p === "cursor" || p === "anthropic") { + creds.QUORUM_PROVIDER = p; + saveCredentials(creds); + console.log(` Saved. Default provider set to ${p}.`); + } else { + console.log(' Invalid. Use "cursor" or "anthropic".'); + } + } else if (choice === "4") { + const confirm = (await ask("Clear ALL stored keys? [y/N]: ")).trim().toLowerCase(); + if (confirm === "y" || confirm === "yes") { + creds = {}; + saveCredentials(creds); + console.log(" Cleared all stored credentials."); + } else { + console.log(" Cancelled."); + } + } else if (choice === "5") { + console.log("Bye."); + return; + } else { + console.log(" Invalid choice. Enter a number 1-5."); + } + } +} + +/** Read a line of visible input from stdin. */ +async function ask(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + return await rl.question(question); + } finally { + rl.close(); + } +} + +/** + * Read a line of input without echoing characters (for secrets). + * Falls back to visible readline input when stdin is not a TTY (pipes, CI). + */ +async function askHidden(question: string): Promise { + const stdin = process.stdin; + if (!stdin.isTTY) { + return ask(question); + } + process.stdout.write(question); + return new Promise((resolve) => { + const wasRaw = stdin.isRaw; + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding("utf8"); + let value = ""; + const onData = (chunk: string): void => { + for (const ch of chunk) { + const code = ch.charCodeAt(0); + if (ch === "\r" || ch === "\n") { + cleanup(); + process.stdout.write("\n"); + resolve(value); + return; + } + if (code === 3) { + // Ctrl-C + cleanup(); + process.stdout.write("\n^C\n"); + process.exit(130); + } else if (code === 127 || code === 8) { + // Backspace / Delete + if (value.length > 0) value = value.slice(0, -1); + } else if (code >= 32) { + value += ch; + } + } + }; + const cleanup = (): void => { + stdin.removeListener("data", onData); + stdin.pause(); + if (stdin.isTTY) stdin.setRawMode(wasRaw); + }; + stdin.on("data", onData); + }); +} + // ---- eval command ---- async function evalCommand(parsed: ParsedArgs): Promise { @@ -744,6 +956,7 @@ function printHelp(): void { quorum post-pr https://github.com/OWNER/REPO/pull/N [options] quorum canvas .quorum/runs/run-id quorum setup + quorum auth [--cursor-key K | --anthropic-key K | --provider P | --show | --clear] quorum eval [--log-dir .quorum/log] quorum explore --repo OWNER/REPO --pr N --scored clusters.scored.json [options] quorum run-dag --dag dag.json --out .quorum/runs/run-id --repo OWNER/REPO [options] @@ -756,6 +969,7 @@ Commands: post-pr Run exploration and upsert the PR exploration comment. canvas Regenerate or open a Canvas from a saved run directory. setup Validate prerequisites (Node, gh, jq, python3, API keys, skills). + auth Persist API keys / default provider to a config file (no more export). eval Compute reviewer precision and success stats from run logs. Options: @@ -781,9 +995,17 @@ Environment: ANTHROPIC_API_KEY Anthropic API key (required for --provider anthropic). GITHUB_TOKEN GitHub API token (falls back to gh CLI). QUORUM_PROVIDER Default: cursor. Set to anthropic for direct Anthropic API. + QUORUM_CONFIG Override the credentials file path (default: + $XDG_CONFIG_HOME/quorum/credentials.json or + ~/.config/quorum/credentials.json). QUORUM_MODEL_HIGH Model for HIGH complexity tasks. QUORUM_MODEL_MED Model for MED complexity tasks. QUORUM_MODEL_LOW Model for LOW complexity tasks. + +API key resolution order (--api-key > env > config file): + 1. --api-key flag + 2. CURSOR_API_KEY / ANTHROPIC_API_KEY environment variable + 3. Persisted config file (set via 'quorum auth') `); } diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..53ef84a --- /dev/null +++ b/src/config.ts @@ -0,0 +1,118 @@ +import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; + +export interface Credentials { + CURSOR_API_KEY?: string; + ANTHROPIC_API_KEY?: string; + QUORUM_PROVIDER?: "cursor" | "anthropic"; +} + +const ALLOWED_KEYS = ["CURSOR_API_KEY", "ANTHROPIC_API_KEY", "QUORUM_PROVIDER"] as const; + +/** + * Resolve the credentials file path. + * + * Resolution order: + * 1. QUORUM_CONFIG env var (explicit override — also used by tests) + * 2. $XDG_CONFIG_HOME/quorum/credentials.json + * 3. ~/.config/quorum/credentials.json (fallback) + * + * `homedir()` respects $HOME on Unix and %USERPROFILE% on Windows, so this is + * cross-platform without manual env fallbacks. + */ +export function credentialsFilePath(): string { + if (process.env.QUORUM_CONFIG) return process.env.QUORUM_CONFIG; + const xdg = process.env.XDG_CONFIG_HOME; + const base = xdg ? join(xdg, "quorum") : join(homedir(), ".config", "quorum"); + return join(base, "credentials.json"); +} + +let cache: Credentials | undefined; + +/** Reset the in-memory cache. Intended for tests. */ +export function clearCredentialsCache(): void { + cache = undefined; +} + +/** + * Load credentials from disk (cached after first call). + * + * Returns an empty object when the file is missing or malformed — never throws + * — so callers can treat it as a best-effort fallback below process.env. + */ +export function loadCredentials(): Credentials { + if (cache) return cache; + const path = credentialsFilePath(); + try { + const raw = readFileSync(path, "utf8"); + cache = sanitize(JSON.parse(raw)); + } catch { + cache = {}; + } + return cache; +} + +/** + * Persist credentials to disk with restrictive permissions (0o600). + * Writes the parent directory if it does not exist. + */ +export function saveCredentials(creds: Credentials): void { + const path = credentialsFilePath(); + const dir = dirname(path); + mkdirSync(dir, { recursive: true }); + const compact: Record = {}; + for (const key of ALLOWED_KEYS) { + const value = creds[key]; + if (typeof value === "string" && value.length > 0) { + compact[key] = value; + } + } + const tempPath = join(dir, `.${basename(path)}.${process.pid}.${Date.now()}.tmp`); + writeFileSync(tempPath, `${JSON.stringify(compact, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + try { + chmodSync(tempPath, 0o600); + } catch { + // Best-effort: chmod can fail on Windows or non-POSIX filesystems. + } + try { + renameSync(tempPath, path); + } catch (error) { + try { + unlinkSync(tempPath); + } catch { + // Best-effort cleanup for failed replacement writes. + } + throw error; + } + try { + chmodSync(path, 0o600); + } catch { + // Best-effort: chmod can fail on Windows or non-POSIX filesystems. + } + cache = { ...compact }; +} + +/** Mask a secret for display: first 4 + ellipsis + last 4 characters. */ +export function maskSecret(secret: string | undefined): string { + if (!secret) return "not set"; + if (secret.length <= 8) return "****"; + return `${secret.slice(0, 4)}\u2026${secret.slice(-4)}`; +} + +function sanitize(value: unknown): Credentials { + if (typeof value !== "object" || value === null) return {}; + const result: Credentials = {}; + const record = value as Record; + for (const key of ALLOWED_KEYS) { + const v = record[key]; + if (typeof v !== "string" || v.length === 0) continue; + if (key === "QUORUM_PROVIDER" && v !== "cursor" && v !== "anthropic") continue; + (result as Record)[key] = v; + } + return result; +} diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..b88045d --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, test } from "node:test"; +import { clearCredentialsCache, credentialsFilePath, loadCredentials, maskSecret, saveCredentials } from "../src/config.js"; + +let tempDir: string; + +function setConfigPath(filename = "credentials.json"): string { + const path = join(tempDir, filename); + process.env.QUORUM_CONFIG = path; + return path; +} + +function runCli(args: string[]): SpawnSyncReturns { + return spawnSync(process.execPath, [join(process.cwd(), "dist", "src", "cli.js"), ...args], { + cwd: process.cwd(), + encoding: "utf8", + env: { ...process.env }, + }) as SpawnSyncReturns; +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "quorum-config-")); + clearCredentialsCache(); +}); + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + delete process.env.QUORUM_CONFIG; + delete process.env.XDG_CONFIG_HOME; +}); + +describe("credentialsFilePath", () => { + test("respects QUORUM_CONFIG override", () => { + setConfigPath(); + assert.equal(credentialsFilePath(), process.env.QUORUM_CONFIG); + }); + + test("uses XDG_CONFIG_HOME when set", () => { + delete process.env.QUORUM_CONFIG; + const xdg = join(tempDir, "xdg"); + process.env.XDG_CONFIG_HOME = xdg; + assert.equal(credentialsFilePath(), join(xdg, "quorum", "credentials.json")); + delete process.env.XDG_CONFIG_HOME; + }); +}); + +describe("loadCredentials", () => { + test("returns empty object when file is missing", () => { + setConfigPath("nonexistent.json"); + assert.deepEqual(loadCredentials(), {}); + }); + + test("reads a valid credentials file", () => { + const path = setConfigPath(); + writeFileSync(path, JSON.stringify({ + CURSOR_API_KEY: "sk-cursor-123", + ANTHROPIC_API_KEY: "sk-ant-456", + QUORUM_PROVIDER: "anthropic", + })); + const creds = loadCredentials(); + assert.equal(creds.CURSOR_API_KEY, "sk-cursor-123"); + assert.equal(creds.ANTHROPIC_API_KEY, "sk-ant-456"); + assert.equal(creds.QUORUM_PROVIDER, "anthropic"); + }); + + test("caches after first read", () => { + const path = setConfigPath(); + writeFileSync(path, JSON.stringify({ CURSOR_API_KEY: "first" })); + assert.equal(loadCredentials().CURSOR_API_KEY, "first"); + // Mutate the file on disk — cached value must still win until cache reset. + writeFileSync(path, JSON.stringify({ CURSOR_API_KEY: "second" })); + assert.equal(loadCredentials().CURSOR_API_KEY, "first"); + clearCredentialsCache(); + assert.equal(loadCredentials().CURSOR_API_KEY, "second"); + }); + + test("ignores unknown keys and non-string values", () => { + const path = setConfigPath(); + writeFileSync(path, JSON.stringify({ + CURSOR_API_KEY: "keep", + EVIL_KEY: "drop", + ANTHROPIC_API_KEY: 12345, + QUORUM_PROVIDER: "invalid", + })); + const creds = loadCredentials(); + assert.equal(creds.CURSOR_API_KEY, "keep"); + assert.equal(creds.ANTHROPIC_API_KEY, undefined); + assert.equal(creds.QUORUM_PROVIDER, undefined); + assert.equal((creds as Record).EVIL_KEY, undefined); + }); + + test("returns empty object on malformed JSON", () => { + const path = setConfigPath(); + writeFileSync(path, "{ not valid json"); + assert.deepEqual(loadCredentials(), {}); + }); +}); + +describe("saveCredentials", () => { + test("writes valid keys and creates parent directories", () => { + setConfigPath("nested/deep/credentials.json"); + saveCredentials({ CURSOR_API_KEY: "sk-test", QUORUM_PROVIDER: "cursor" }); + clearCredentialsCache(); + const creds = loadCredentials(); + assert.equal(creds.CURSOR_API_KEY, "sk-test"); + assert.equal(creds.QUORUM_PROVIDER, "cursor"); + }); + + test("omits empty and undefined values", () => { + const path = setConfigPath(); + saveCredentials({ CURSOR_API_KEY: "keep", ANTHROPIC_API_KEY: "" }); + const raw = JSON.parse(readFileSync(path, "utf8")); + assert.equal(raw.CURSOR_API_KEY, "keep"); + assert.equal(raw.ANTHROPIC_API_KEY, undefined); + }); + + test("sets file permissions to 0o600", () => { + const path = setConfigPath(); + saveCredentials({ CURSOR_API_KEY: "sk-secret" }); + const mode = statSync(path).mode & 0o777; + assert.equal(mode, 0o600); + }); + + test("repairs loose permissions on existing credentials file", () => { + const path = setConfigPath(); + writeFileSync(path, JSON.stringify({ CURSOR_API_KEY: "old" })); + chmodSync(path, 0o644); + saveCredentials({ CURSOR_API_KEY: "new" }); + const mode = statSync(path).mode & 0o777; + assert.equal(mode, 0o600); + assert.equal(JSON.parse(readFileSync(path, "utf8")).CURSOR_API_KEY, "new"); + }); + + test("updates the in-memory cache", () => { + setConfigPath(); + saveCredentials({ CURSOR_API_KEY: "cached" }); + // Without clearing the cache, loadCredentials must reflect the saved value. + assert.equal(loadCredentials().CURSOR_API_KEY, "cached"); + }); +}); + +describe("auth command", () => { + test("rejects missing values for non-interactive setter flags", () => { + for (const flagName of ["cursor-key", "anthropic-key", "provider"]) { + setConfigPath(`${flagName}.json`); + const result = runCli(["auth", `--${flagName}`]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, new RegExp(`Missing --${flagName}`)); + } + }); + + test("writes credentials non-interactively", () => { + const path = setConfigPath("cli-credentials.json"); + const result = runCli(["auth", "--cursor-key", "sk-cli", "--provider", "anthropic"]); + assert.equal(result.status, 0, result.stderr); + const raw = JSON.parse(readFileSync(path, "utf8")); + assert.equal(raw.CURSOR_API_KEY, "sk-cli"); + assert.equal(raw.QUORUM_PROVIDER, "anthropic"); + assert.equal(statSync(path).mode & 0o777, 0o600); + }); +}); + +describe("maskSecret", () => { + test("returns 'not set' for undefined/empty", () => { + assert.equal(maskSecret(undefined), "not set"); + assert.equal(maskSecret(""), "not set"); + }); + + test("fully masks short secrets", () => { + assert.equal(maskSecret("abc"), "****"); + assert.equal(maskSecret("12345678"), "****"); + }); + + test("shows first 4 and last 4 of long secrets", () => { + const masked = maskSecret("sk-ant-api03-very-long-key"); + assert.ok(masked.startsWith("sk-a")); + assert.ok(masked.endsWith("-key")); + assert.ok(masked.includes("\u2026")); + }); +});