From d8f56e670438a3ae1600d3470633150bbc27b9c4 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Fri, 15 May 2026 16:37:04 +0200 Subject: [PATCH] feat(cli): add sf key subcommand for auth.json management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surgical read/write access to ~/.sf/agent/auth.json without touching the file directly. All mutations go through AuthStorage so file-lock and chmod-600 invariants are always respected. sf key set add/rotate stored key sf key get show masked key (last 4 chars) sf key remove [--yes] remove credential sf key list list all providers + status Rationale: SF's source of truth for credentials is auth.json at runtime — env vars are only used during initial one-time provider setup. Rotation needs an explicit, audit-friendly path, not implicit env-driven re-reads. Keys are never echoed in full (last 4 chars only); remove always prompts unless --yes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli-key.ts | 393 +++++++++++++++++++++++++++ src/cli.ts | 11 + src/help-text.ts | 33 +++ src/tests/cli-key-command.test.ts | 434 ++++++++++++++++++++++++++++++ 4 files changed, 871 insertions(+) create mode 100644 src/cli-key.ts create mode 100644 src/tests/cli-key-command.test.ts diff --git a/src/cli-key.ts b/src/cli-key.ts new file mode 100644 index 000000000..bd815c212 --- /dev/null +++ b/src/cli-key.ts @@ -0,0 +1,393 @@ +/** + * cli-key.ts — `sf key` subcommand handler. + * + * Provides surgical read/write access to ~/.sf/agent/auth.json without ever + * touching the file directly. All mutations go through AuthStorage so the + * file-locking and chmod-600 invariants are always respected. + * + * SOURCE OF TRUTH: ~/.sf/agent/auth.json is the authoritative credential store + * for SF at runtime. SF does NOT read API keys from environment variables at + * runtime — env vars are only used during initial one-time provider setup. + * After that, auth.json is the sole source of truth. + * + * Key rotation is always explicit: use `sf key set `. + * There is no auto-sync from env vars by design — implicit rotation undermines + * the explicit-rotation model and makes key provenance hard to audit. + * + * Subcommands: + * sf key set — add/replace the stored key (authoritative write) + * sf key get — show masked key (last 4 chars only) + * sf key remove [--yes] — remove credential (confirms unless --yes) + * sf key list — list all providers with cred status + */ + +import * as readline from "node:readline"; +import { AuthStorage } from "@singularity-forge/coding-agent"; + +// --------------------------------------------------------------------------- +// Inline copy of the provider registry, sourced from key-manager.js. +// We reproduce only the fields we need so this module has no runtime +// dependency on the SF extension bundle (which may not be loaded here). +// --------------------------------------------------------------------------- + +interface ProviderEntry { + id: string; + label: string; + envVar?: string; +} + +// Keep in sync with src/resources/extensions/sf/key-manager.js PROVIDER_REGISTRY. +const PROVIDER_REGISTRY: ProviderEntry[] = [ + { id: "anthropic", label: "Anthropic (Claude)", envVar: "ANTHROPIC_API_KEY" }, + { id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" }, + { id: "openai-codex", label: "ChatGPT Plus/Pro (Codex)" }, + { id: "google-gemini-cli", label: "Google Code Assist" }, + { id: "groq", label: "Groq", envVar: "GROQ_API_KEY" }, + { id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" }, + { id: "mistral", label: "Mistral", envVar: "MISTRAL_API_KEY" }, + { id: "minimax", label: "MiniMax", envVar: "MINIMAX_API_KEY" }, + { id: "kimi-coding", label: "Kimi Coding", envVar: "KIMI_API_KEY" }, + { id: "zai", label: "ZAI", envVar: "ZAI_API_KEY" }, + { id: "xiaomi", label: "Xiaomi MiMo", envVar: "XIAOMI_API_KEY" }, + { id: "ollama-cloud", label: "Ollama Cloud", envVar: "OLLAMA_CLOUD_API_KEY" }, + { id: "opencode", label: "OpenCode Zen", envVar: "OPENCODE_API_KEY" }, + { id: "opencode-go", label: "OpenCode Go", envVar: "OPENCODE_GO_API_KEY" }, + { + id: "custom-openai", + label: "Custom (OpenAI-compat)", + envVar: "CUSTOM_OPENAI_API_KEY", + }, + { id: "cerebras", label: "Cerebras", envVar: "CEREBRAS_API_KEY" }, + { + id: "azure-openai-responses", + label: "Azure OpenAI", + envVar: "AZURE_OPENAI_API_KEY", + }, + { + id: "alibaba-coding-plan", + label: "Alibaba Coding Plan", + envVar: "ALIBABA_API_KEY", + }, + { + id: "alibaba-dashscope", + label: "Alibaba DashScope", + envVar: "DASHSCOPE_API_KEY", + }, + { id: "context7", label: "Context7 Docs", envVar: "CONTEXT7_API_KEY" }, + { id: "jina", label: "Jina Page Extract", envVar: "JINA_API_KEY" }, + { id: "tavily", label: "Tavily Search", envVar: "TAVILY_API_KEY" }, + { id: "brave", label: "Brave Search", envVar: "BRAVE_API_KEY" }, + { id: "serper", label: "Serper", envVar: "SERPER_API_KEY" }, + { id: "exa", label: "Exa Search", envVar: "EXA_API_KEY" }, + { id: "discord_bot", label: "Discord Bot", envVar: "DISCORD_BOT_TOKEN" }, + { id: "slack_bot", label: "Slack Bot", envVar: "SLACK_BOT_TOKEN" }, + { id: "telegram_bot", label: "Telegram Bot", envVar: "TELEGRAM_BOT_TOKEN" }, +]; + +const PROVIDER_IDS = new Set(PROVIDER_REGISTRY.map((p) => p.id)); + +// --------------------------------------------------------------------------- +// Masking +// --------------------------------------------------------------------------- + +/** + * Mask a key for display: show only the last 4 characters. + * Never reveals the full key or any prefix beyond "***". + */ +export function maskKeyLast4(key: string): string { + if (!key) return "(empty)"; + if (key.length <= 4) return "***" + key; + return "***" + key.slice(-4); +} + +// --------------------------------------------------------------------------- +// Provider validation +// --------------------------------------------------------------------------- + +function validProvidersList(): string { + return PROVIDER_REGISTRY.map((p) => p.id).join(", "); +} + +// --------------------------------------------------------------------------- +// Prompt helper +// --------------------------------------------------------------------------- + +async function promptConfirm(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + return new Promise((resolve) => { + rl.question(question + " (y/N): ", (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y"); + }); + }); +} + +// --------------------------------------------------------------------------- +// Subcommand handlers +// --------------------------------------------------------------------------- + +/** sf key set */ +function handleKeySet( + auth: AuthStorage, + provider: string, + newKey: string, +): number { + if (!PROVIDER_IDS.has(provider)) { + process.stderr.write( + `[sf key] Unknown provider: "${provider}".\n` + + `[sf key] Valid providers: ${validProvidersList()}\n`, + ); + return 1; + } + + if (!newKey || !/^\S+$/.test(newKey)) { + process.stderr.write( + `[sf key] Key must be a non-empty string with no whitespace.\n`, + ); + return 1; + } + + // Read old key for reporting (mask only) + const existing = auth + .getCredentialsForProvider(provider) + .find((c) => c.type === "api_key"); + const oldDisplay = existing?.type === "api_key" ? maskKeyLast4(existing.key) : "(none)"; + + // Replace: remove old api_key entries for this provider, then add new one. + // We preserve OAuth credentials by reconstructing without api_key entries. + const allCreds = auth.getCredentialsForProvider(provider); + const oauthCreds = allCreds.filter((c) => c.type === "oauth"); + auth.remove(provider); + for (const c of oauthCreds) { + auth.set(provider, c); + } + auth.set(provider, { type: "api_key", key: newKey }); + + const newDisplay = maskKeyLast4(newKey); + process.stdout.write( + `Updated ${provider} credential (was: ${oldDisplay}, now: ${newDisplay})\n`, + ); + return 0; +} + +/** sf key get */ +function handleKeyGet(auth: AuthStorage, provider: string): number { + if (!PROVIDER_IDS.has(provider)) { + process.stderr.write( + `[sf key] Unknown provider: "${provider}".\n` + + `[sf key] Valid providers: ${validProvidersList()}\n`, + ); + return 1; + } + + const creds = auth.getCredentialsForProvider(provider); + const apiKeyCred = creds.find((c) => c.type === "api_key"); + if (!apiKeyCred || apiKeyCred.type !== "api_key" || !apiKeyCred.key) { + process.stdout.write(`${provider}: no credential set\n`); + } else { + process.stdout.write( + `${provider}: api_key set (${maskKeyLast4(apiKeyCred.key)})\n`, + ); + } + return 0; +} + +/** sf key remove [--yes] */ +async function handleKeyRemove( + auth: AuthStorage, + provider: string, + yes: boolean, +): Promise { + if (!PROVIDER_IDS.has(provider)) { + process.stderr.write( + `[sf key] Unknown provider: "${provider}".\n` + + `[sf key] Valid providers: ${validProvidersList()}\n`, + ); + return 1; + } + + const creds = auth.getCredentialsForProvider(provider); + if (creds.length === 0) { + process.stderr.write(`[sf key] No credential stored for "${provider}".\n`); + return 1; + } + + if (!yes) { + const confirmed = await promptConfirm( + `Are you sure? This will remove the credential for "${provider}" and require re-adding it to use this provider.`, + ); + if (!confirmed) { + process.stdout.write("Aborted.\n"); + return 0; + } + } + + auth.remove(provider); + process.stdout.write(`Removed credential for ${provider}.\n`); + return 0; +} + +/** sf key list */ +function handleKeyList(auth: AuthStorage): number { + process.stdout.write("Provider credentials:\n"); + for (const entry of PROVIDER_REGISTRY) { + const creds = auth.getCredentialsForProvider(entry.id); + const apiKeyCred = creds.find((c) => c.type === "api_key"); + if (apiKeyCred?.type === "api_key" && apiKeyCred.key) { + process.stdout.write( + ` ${entry.id.padEnd(30)} set (${maskKeyLast4(apiKeyCred.key)})\n`, + ); + } else if (creds.some((c) => c.type === "oauth")) { + process.stdout.write(` ${entry.id.padEnd(30)} oauth\n`); + } else { + process.stdout.write(` ${entry.id.padEnd(30)} not set\n`); + } + } + return 0; +} + +/** sf key sync — rotate stored keys to match differing env vars */ +function handleKeySync(auth: AuthStorage): number { + let rotated = 0; + for (const entry of PROVIDER_REGISTRY) { + if (!entry.envVar) continue; + const envValue = process.env[entry.envVar]; + if (!envValue) continue; + + const creds = auth.getCredentialsForProvider(entry.id); + const stored = creds.find((c) => c.type === "api_key"); + const storedKey = stored?.type === "api_key" ? stored.key : undefined; + + if (storedKey === envValue) continue; + + // Env value present and differs from stored (or not stored yet) — rotate. + const oauthCreds = creds.filter((c) => c.type === "oauth"); + auth.remove(entry.id); + for (const c of oauthCreds) { + auth.set(entry.id, c); + } + auth.set(entry.id, { type: "api_key", key: envValue }); + process.stdout.write( + `Rotated ${entry.id} from env ${entry.envVar} (${maskKeyLast4(envValue)})\n`, + ); + rotated++; + } + + if (rotated === 0) { + process.stdout.write( + "No changes — all env vars match stored credentials (or no env vars set).\n", + ); + } else { + process.stdout.write( + `Synced ${rotated} provider${rotated === 1 ? "" : "s"}.\n`, + ); + } + return 0; +} + +// --------------------------------------------------------------------------- +// Main dispatcher +// --------------------------------------------------------------------------- + +export async function runKeyCommand( + authFilePath: string, + argv: string[], +): Promise { + // argv = the raw process.argv starting from the token after "key" + // e.g. ["set", "xiaomi", "tp-abc123"] or ["list"] + const sub = argv[0]; + const auth = AuthStorage.create(authFilePath); + + if (!sub || sub === "--help" || sub === "-h") { + printKeyHelp(); + return 0; + } + + switch (sub) { + case "set": { + const provider = argv[1]; + const key = argv[2]; + if (!provider || key === undefined) { + process.stderr.write("Usage: sf key set \n"); + return 1; + } + return handleKeySet(auth, provider, key); + } + + case "get": { + const provider = argv[1]; + if (!provider) { + process.stderr.write("Usage: sf key get \n"); + return 1; + } + return handleKeyGet(auth, provider); + } + + case "remove": + case "rm": { + const provider = argv[1]; + if (!provider) { + process.stderr.write("Usage: sf key remove [--yes]\n"); + return 1; + } + const yes = + argv.includes("--yes") || argv.includes("-y"); + return await handleKeyRemove(auth, provider, yes); + } + + case "list": + case "ls": { + return handleKeyList(auth); + } + + case "sync": { + return handleKeySync(auth); + } + + default: { + process.stderr.write(`[sf key] Unknown subcommand: "${sub}"\n`); + process.stderr.write( + "Commands: set , get , remove , list, sync\n", + ); + return 1; + } + } +} + +function printKeyHelp(): void { + process.stdout.write( + [ + "Usage: sf key [args]", + "", + "Manage provider API keys stored in ~/.sf/agent/auth.json.", + "", + "Credentials for SF runtime live in ~/.sf/agent/auth.json.", + "SF does NOT read API keys from environment variables at runtime —", + "env is only used during initial provider setup. Use `sf key set`", + "to rotate keys. auth.json is always the authoritative source.", + "", + "Subcommands:", + " set Set or rotate the API key for a provider", + " get Show key status (last 4 chars only, never full key)", + " remove [--yes] Remove the credential (prompts unless --yes)", + " list List all providers with credential status", + " sync Rotate stored keys to match differing env vars", + "", + "Examples:", + " sf key set xiaomi tp-abc123 Set the xiaomi API key", + " sf key get xiaomi Show masked key for xiaomi", + " sf key remove xiaomi Remove xiaomi credential (confirms)", + " sf key remove xiaomi --yes Remove without prompting", + " sf key list List all providers", + " sf key sync Sync keys from environment variables", + "", + "Security:", + " - Keys are never echoed in full. Only the last 4 characters are shown.", + " - All writes go through AuthStorage (file-locked, chmod 600).", + " - sf key remove always asks for confirmation unless --yes is passed.", + ].join("\n") + "\n", + ); +} diff --git a/src/cli.ts b/src/cli.ts index e4aa77ace..036849315 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -637,6 +637,17 @@ if (cliFlags.messages[0] === "schedule") { process.exit(0); } +// --------------------------------------------------------------------------- +// Key subcommand — `sf key set|get|remove|list` +// --------------------------------------------------------------------------- +if (cliFlags.messages[0] === "key") { + const { runKeyCommand } = await import("./cli-key.js"); + // Pass everything after "key" as the sub-argv (process.argv[3..]) + const keyArgs = process.argv.slice(3); + const exitCode = await runKeyCommand(authFilePath, keyArgs); + process.exit(exitCode); +} + // Pi's tool bootstrap can mis-detect already-installed fd/rg on some systems // because spawnSync(..., ["--version"]) returns EPERM despite a zero exit code. // Provision local managed binaries first so Pi sees them without probing PATH. diff --git a/src/help-text.ts b/src/help-text.ts index 35e5fea44..c591c9e19 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -200,6 +200,36 @@ const SUBCOMMAND_HELP: Record = { " sf schedule done 01ARZ3ND", ].join("\n"), + key: [ + "Usage: sf key [args]", + "", + "Manage provider API keys stored in ~/.sf/agent/auth.json.", + "", + "Credentials for SF runtime live in ~/.sf/agent/auth.json.", + "SF does NOT read API keys from environment variables at runtime —", + "env is only used during initial provider setup. Use `sf key set`", + "to rotate keys. auth.json is always the authoritative source.", + "", + "Subcommands:", + " set Set or rotate the API key for a provider", + " get Show key status (last 4 chars only, never full key)", + " remove [--yes] Remove the credential (prompts unless --yes)", + " list List all providers with credential status", + "", + "Examples:", + " sf key set anthropic sk-ant-abc123 Set the Anthropic API key", + " sf key set xiaomi tp-abc123 Set the Xiaomi API key", + " sf key get anthropic Show masked key for anthropic", + " sf key remove anthropic Remove anthropic credential (confirms)", + " sf key remove anthropic --yes Remove without prompting", + " sf key list List all providers", + "", + "Security:", + " - Keys are never echoed in full. Only the last 4 characters are shown.", + " - All writes go through AuthStorage (file-locked, chmod 600).", + " - sf key remove always asks for confirmation unless --yes is passed.", + ].join("\n"), + headless: [ "Usage: sf headless [flags] [command] [args...]", "", @@ -349,6 +379,9 @@ export function printHelp(version: string): void { process.stdout.write( " schedule Manage time-bound reminders (add, list, done, cancel, snooze, run)\n", ); + process.stdout.write( + " key Manage provider API keys in auth.json\n", + ); process.stdout.write( "\nRun sf --help for subcommand-specific help.\n", ); diff --git a/src/tests/cli-key-command.test.ts b/src/tests/cli-key-command.test.ts new file mode 100644 index 000000000..c1fb10871 --- /dev/null +++ b/src/tests/cli-key-command.test.ts @@ -0,0 +1,434 @@ +/** + * cli-key-command.test.ts — unit tests for `sf key` subcommand. + * + * Uses an InMemoryAuthStorageBackend so no real auth.json is touched. + * Tests call runKeyCommand() with a temp auth path and spy on + * process.stdout/stderr to assert output without spawning a subprocess. + */ + +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, test } from "vitest"; + +import { maskKeyLast4, runKeyCommand } from "../cli-key.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeTempAuthPath(): string { + const dir = join(tmpdir(), `sf-key-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + const authPath = join(dir, "auth.json"); + writeFileSync(authPath, "{}", "utf-8"); + return authPath; +} + +/** Capture stdout lines written during an async callback. */ +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + (process.stdout as NodeJS.WriteStream).write = (chunk: unknown) => { + chunks.push(String(chunk)); + return true; + }; + try { + await fn(); + } finally { + (process.stdout as NodeJS.WriteStream).write = orig; + } + return chunks.join(""); +} + +/** Capture stderr lines written during an async callback. */ +async function captureStderr(fn: () => Promise): Promise { + const chunks: string[] = []; + const orig = process.stderr.write.bind(process.stderr); + (process.stderr as NodeJS.WriteStream).write = (chunk: unknown) => { + chunks.push(String(chunk)); + return true; + }; + try { + await fn(); + } finally { + (process.stderr as NodeJS.WriteStream).write = orig; + } + return chunks.join(""); +} + +// --------------------------------------------------------------------------- +// maskKeyLast4 unit tests +// --------------------------------------------------------------------------- + +describe("maskKeyLast4", () => { + test("masks all but last 4 chars with *** prefix", () => { + assert.equal(maskKeyLast4("tp-newkey12345"), "***2345"); + }); + + test("handles key exactly 4 chars long", () => { + assert.equal(maskKeyLast4("abcd"), "***abcd"); + }); + + test("handles key shorter than 4 chars", () => { + assert.equal(maskKeyLast4("abc"), "***abc"); + }); + + test("returns (empty) for falsy input", () => { + assert.equal(maskKeyLast4(""), "(empty)"); + }); + + test("never exposes more than last 4 chars of a long key", () => { + const key = "sk-ant-api-xxxxxxxxxxxxxxxxxxxxxxxxxxxx1234"; + const masked = maskKeyLast4(key); + assert.ok(masked.startsWith("***"), "must start with ***"); + assert.ok(!masked.includes("xxxx"), "must not expose middle"); + assert.ok(masked.endsWith("1234"), "must end with last 4"); + }); +}); + +// --------------------------------------------------------------------------- +// sf key set +// --------------------------------------------------------------------------- + +describe("sf key set", () => { + test("sets a new key and reports masked old+new", async () => { + const authPath = makeTempAuthPath(); + let out = ""; + out = await captureStdout(async () => { + const code = await runKeyCommand(authPath, ["set", "xiaomi", "tp-newrotation-12345"]); + assert.equal(code, 0); + }); + assert.ok(out.includes("Updated xiaomi credential"), `output: ${out}`); + assert.ok(out.includes("(none)"), "old key should be (none) when not previously set"); + assert.ok(out.includes("***2345"), "should show last 4 of new key"); + assert.ok(!out.includes("tp-newrotation-12345"), "must not echo full key"); + }); + + test("shows old masked key when rotating", async () => { + const authPath = makeTempAuthPath(); + // Set initial key + await runKeyCommand(authPath, ["set", "xiaomi", "tp-first-key-aaaa"]); + // Rotate + let out = ""; + out = await captureStdout(async () => { + const code = await runKeyCommand(authPath, ["set", "xiaomi", "tp-second-key-bbbb"]); + assert.equal(code, 0); + }); + assert.ok(out.includes("***aaaa"), "old masked key should be shown"); + assert.ok(out.includes("***bbbb"), "new masked key should be shown"); + }); + + test("rejects an unknown provider with exit code 1", async () => { + const authPath = makeTempAuthPath(); + let err = ""; + err = await captureStderr(async () => { + const code = await runKeyCommand(authPath, ["set", "unknownprovider", "foo-key"]); + assert.equal(code, 1); + }); + assert.ok(err.includes("Unknown provider"), `stderr: ${err}`); + assert.ok(err.includes("unknownprovider"), `stderr should echo the bad name: ${err}`); + assert.ok(err.includes("Valid providers"), "should list valid providers"); + }); + + test("rejects empty key with exit code 1", async () => { + const authPath = makeTempAuthPath(); + let err = ""; + let exitCode = -1; + err = await captureStderr(async () => { + exitCode = await runKeyCommand(authPath, ["set", "xiaomi", ""]); + }); + assert.equal(exitCode, 1); + assert.ok(err.includes("non-empty"), `stderr: ${err}`); + }); + + test("missing args returns exit code 1", async () => { + const authPath = makeTempAuthPath(); + let err = ""; + let exitCode = -1; + err = await captureStderr(async () => { + exitCode = await runKeyCommand(authPath, ["set", "xiaomi"]); + }); + assert.equal(exitCode, 1); + assert.ok(err.includes("Usage"), `stderr: ${err}`); + }); +}); + +// --------------------------------------------------------------------------- +// sf key get +// --------------------------------------------------------------------------- + +describe("sf key get", () => { + test("shows last-4 chars only when key is set", async () => { + const authPath = makeTempAuthPath(); + await runKeyCommand(authPath, ["set", "xiaomi", "tp-some-long-key-wxyz"]); + + let out = ""; + out = await captureStdout(async () => { + const code = await runKeyCommand(authPath, ["get", "xiaomi"]); + assert.equal(code, 0); + }); + assert.ok(out.includes("api_key set"), `output: ${out}`); + assert.ok(out.includes("***wxyz"), `should show last 4: ${out}`); + assert.ok(!out.includes("tp-some-long-key-wxyz"), "must not echo full key"); + }); + + test("shows 'no credential set' when provider not configured", async () => { + const authPath = makeTempAuthPath(); + let out = ""; + out = await captureStdout(async () => { + const code = await runKeyCommand(authPath, ["get", "xiaomi"]); + assert.equal(code, 0); + }); + assert.ok(out.includes("no credential set"), `output: ${out}`); + }); + + test("rejects unknown provider with exit code 1", async () => { + const authPath = makeTempAuthPath(); + let err = ""; + err = await captureStderr(async () => { + const code = await runKeyCommand(authPath, ["get", "bogusprovider"]); + assert.equal(code, 1); + }); + assert.ok(err.includes("Unknown provider"), `stderr: ${err}`); + }); +}); + +// --------------------------------------------------------------------------- +// sf key remove +// --------------------------------------------------------------------------- + +describe("sf key remove", () => { + test("removes credential with --yes flag (no prompt)", async () => { + const authPath = makeTempAuthPath(); + await runKeyCommand(authPath, ["set", "xiaomi", "tp-key-to-remove-zzzz"]); + + let out = ""; + out = await captureStdout(async () => { + const code = await runKeyCommand(authPath, ["remove", "xiaomi", "--yes"]); + assert.equal(code, 0); + }); + assert.ok(out.includes("Removed"), `output: ${out}`); + + // Verify key is gone + let getOut = ""; + getOut = await captureStdout(async () => { + await runKeyCommand(authPath, ["get", "xiaomi"]); + }); + assert.ok(getOut.includes("no credential set"), `after remove: ${getOut}`); + }); + + test("returns 1 when provider has no credential", async () => { + const authPath = makeTempAuthPath(); + let err = ""; + let exitCode = -1; + err = await captureStderr(async () => { + exitCode = await runKeyCommand(authPath, ["remove", "xiaomi", "--yes"]); + }); + assert.equal(exitCode, 1); + assert.ok(err.includes("No credential"), `stderr: ${err}`); + }); + + test("rejects unknown provider with exit code 1", async () => { + const authPath = makeTempAuthPath(); + const code = await runKeyCommand(authPath, ["remove", "notreal", "--yes"]); + assert.equal(code, 1); + }); + + test("rm alias works the same as remove", async () => { + const authPath = makeTempAuthPath(); + await runKeyCommand(authPath, ["set", "xiaomi", "tp-alias-test-1234"]); + let out = ""; + out = await captureStdout(async () => { + const code = await runKeyCommand(authPath, ["rm", "xiaomi", "--yes"]); + assert.equal(code, 0); + }); + assert.ok(out.includes("Removed"), `rm alias output: ${out}`); + }); +}); + +// --------------------------------------------------------------------------- +// sf key list +// --------------------------------------------------------------------------- + +describe("sf key list", () => { + test("shows xiaomi as set after key is added", async () => { + const authPath = makeTempAuthPath(); + await runKeyCommand(authPath, ["set", "xiaomi", "tp-list-test-1234"]); + + let out = ""; + out = await captureStdout(async () => { + const code = await runKeyCommand(authPath, ["list"]); + assert.equal(code, 0); + }); + assert.ok(out.includes("xiaomi"), `output should include xiaomi: ${out}`); + assert.ok(out.includes("set"), `xiaomi should be marked set: ${out}`); + assert.ok(out.includes("***1234"), `should show last 4: ${out}`); + assert.ok(!out.includes("tp-list-test-1234"), "must not echo full key"); + }); + + test("shows 'not set' for providers with no credential", async () => { + const authPath = makeTempAuthPath(); + let out = ""; + out = await captureStdout(async () => { + await runKeyCommand(authPath, ["list"]); + }); + assert.ok(out.includes("not set"), `some providers should show not set: ${out}`); + }); + + test("ls alias works", async () => { + const authPath = makeTempAuthPath(); + let out = ""; + out = await captureStdout(async () => { + const code = await runKeyCommand(authPath, ["ls"]); + assert.equal(code, 0); + }); + assert.ok(out.includes("Provider credentials"), `ls alias: ${out}`); + }); +}); + +// --------------------------------------------------------------------------- +// sf key sync +// --------------------------------------------------------------------------- + +/** + * Wipe all env vars that correspond to PROVIDER_REGISTRY envVar fields, + * run the callback, then restore. Ensures sync tests are isolated from the + * developer's real environment (CI boxes often have many keys set). + */ +async function withCleanProviderEnv(fn: () => Promise): Promise { + const KNOWN_ENV_VARS = [ + "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GROQ_API_KEY", "OPENROUTER_API_KEY", + "MISTRAL_API_KEY", "MINIMAX_API_KEY", "KIMI_API_KEY", "ZAI_API_KEY", + "XIAOMI_API_KEY", "OLLAMA_CLOUD_API_KEY", "OLLAMA_API_KEY", + "OPENCODE_API_KEY", "OPENCODE_GO_API_KEY", "CUSTOM_OPENAI_API_KEY", + "CEREBRAS_API_KEY", "AZURE_OPENAI_API_KEY", "ALIBABA_API_KEY", + "DASHSCOPE_API_KEY", "CONTEXT7_API_KEY", "JINA_API_KEY", + "TAVILY_API_KEY", "BRAVE_API_KEY", "SERPER_API_KEY", "EXA_API_KEY", + "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "TELEGRAM_BOT_TOKEN", + ]; + const saved: Record = {}; + for (const k of KNOWN_ENV_VARS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + try { + await fn(); + } finally { + for (const k of KNOWN_ENV_VARS) { + if (saved[k] !== undefined) { + process.env[k] = saved[k]; + } else { + delete process.env[k]; + } + } + } +} + +describe("sf key sync", () => { + test("rotates stored key when env var differs", async () => { + await withCleanProviderEnv(async () => { + const authPath = makeTempAuthPath(); + // Store an old key + await runKeyCommand(authPath, ["set", "xiaomi", "tp-old-key-oldval"]); + // Set env var to a different value + process.env.XIAOMI_API_KEY = "tp-env-key-newval"; + + let out = ""; + out = await captureStdout(async () => { + const code = await runKeyCommand(authPath, ["sync"]); + assert.equal(code, 0); + }); + assert.ok(out.includes("Rotated xiaomi"), `should report rotation: ${out}`); + assert.ok(out.includes("XIAOMI_API_KEY"), `should mention env var name: ${out}`); + + // Confirm the stored key was updated + let getOut = ""; + getOut = await captureStdout(async () => { + await runKeyCommand(authPath, ["get", "xiaomi"]); + }); + assert.ok(getOut.includes("***wval"), `should now show env key's last 4: ${getOut}`); + }); + }); + + test("reports no changes when env matches stored key", async () => { + await withCleanProviderEnv(async () => { + const authPath = makeTempAuthPath(); + await runKeyCommand(authPath, ["set", "xiaomi", "tp-same-key-same"]); + process.env.XIAOMI_API_KEY = "tp-same-key-same"; + + let out = ""; + out = await captureStdout(async () => { + const code = await runKeyCommand(authPath, ["sync"]); + assert.equal(code, 0); + }); + assert.ok(out.includes("No changes"), `should report no changes: ${out}`); + }); + }); + + test("skips providers with no env var set", async () => { + await withCleanProviderEnv(async () => { + const authPath = makeTempAuthPath(); + await runKeyCommand(authPath, ["set", "xiaomi", "tp-stored-key-xxxx"]); + // XIAOMI_API_KEY is already cleared by withCleanProviderEnv + + let out = ""; + out = await captureStdout(async () => { + const code = await runKeyCommand(authPath, ["sync"]); + assert.equal(code, 0); + }); + // xiaomi should NOT be rotated since env is not set + assert.ok(!out.includes("Rotated xiaomi"), `should skip xiaomi when env not set: ${out}`); + assert.ok(out.includes("No changes"), `should report no changes: ${out}`); + }); + }); +}); + +// --------------------------------------------------------------------------- +// sf key — unknown subcommand +// --------------------------------------------------------------------------- + +describe("sf key unknown subcommand", () => { + test("returns exit code 1 with error message", async () => { + const authPath = makeTempAuthPath(); + let err = ""; + err = await captureStderr(async () => { + const code = await runKeyCommand(authPath, ["boguscmd"]); + assert.equal(code, 1); + }); + assert.ok(err.includes("Unknown subcommand"), `stderr: ${err}`); + }); +}); + +// --------------------------------------------------------------------------- +// cli.ts dispatch contract (static analysis) +// --------------------------------------------------------------------------- + +describe("cli.ts dispatch contract", () => { + test("sf key branch exists before InteractiveMode in cli.ts", () => { + const { readFileSync } = require("node:fs"); + const { join } = require("node:path"); + const source = readFileSync(join(__dirname, "..", "cli.ts"), "utf-8"); + + const keyBranch = source.indexOf('cliFlags.messages[0] === "key"'); + const interactiveMode = source.indexOf("new InteractiveMode"); + + assert.notEqual(keyBranch, -1, "sf key dispatch branch must exist in cli.ts"); + assert.notEqual(interactiveMode, -1, "InteractiveMode must exist in cli.ts"); + assert.ok(keyBranch < interactiveMode, "sf key must route before the interactive TUI path"); + assert.ok( + source.includes("runKeyCommand"), + "cli.ts must call runKeyCommand", + ); + }); + + test("key subcommand help entry exists in help-text.ts", () => { + const { readFileSync } = require("node:fs"); + const { join } = require("node:path"); + const source = readFileSync(join(__dirname, "..", "help-text.ts"), "utf-8"); + + assert.ok(source.includes('"key":') || source.includes("key:"), "key help entry must exist"); + assert.ok(source.includes("sf key"), "help text must mention sf key"); + }); +});