feat(cli): add sf key subcommand for auth.json management

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    <provider> <api-key>   add/rotate stored key
  sf key get    <provider>             show masked key (last 4 chars)
  sf key remove <provider> [--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) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-15 16:37:04 +02:00
parent 351bfad41d
commit d8f56e6704
4 changed files with 871 additions and 0 deletions

393
src/cli-key.ts Normal file
View file

@ -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 <provider> <new-key>`.
* 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 <provider> <api-key> add/replace the stored key (authoritative write)
* sf key get <provider> show masked key (last 4 chars only)
* sf key remove <provider> [--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<boolean> {
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 <provider> <api-key> */
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 <provider> */
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 <provider> [--yes] */
async function handleKeyRemove(
auth: AuthStorage,
provider: string,
yes: boolean,
): Promise<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);
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<number> {
// 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 <provider> <api-key>\n");
return 1;
}
return handleKeySet(auth, provider, key);
}
case "get": {
const provider = argv[1];
if (!provider) {
process.stderr.write("Usage: sf key get <provider>\n");
return 1;
}
return handleKeyGet(auth, provider);
}
case "remove":
case "rm": {
const provider = argv[1];
if (!provider) {
process.stderr.write("Usage: sf key remove <provider> [--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 <provider> <key>, get <provider>, remove <provider>, list, sync\n",
);
return 1;
}
}
}
function printKeyHelp(): void {
process.stdout.write(
[
"Usage: sf key <subcommand> [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 <provider> <api-key> Set or rotate the API key for a provider",
" get <provider> Show key status (last 4 chars only, never full key)",
" remove <provider> [--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",
);
}

View file

@ -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.

View file

@ -200,6 +200,36 @@ const SUBCOMMAND_HELP: Record<string, string> = {
" sf schedule done 01ARZ3ND",
].join("\n"),
key: [
"Usage: sf key <subcommand> [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 <provider> <api-key> Set or rotate the API key for a provider",
" get <provider> Show key status (last 4 chars only, never full key)",
" remove <provider> [--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 <cmd> Manage time-bound reminders (add, list, done, cancel, snooze, run)\n",
);
process.stdout.write(
" key <set|get|remove|list> Manage provider API keys in auth.json\n",
);
process.stdout.write(
"\nRun sf <subcommand> --help for subcommand-specific help.\n",
);

View file

@ -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<unknown>): Promise<string> {
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<unknown>): Promise<string> {
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<void>): Promise<void> {
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<string, string | undefined> = {};
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");
});
});