/** * 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", ); }