singularity-forge/src/cli-key.ts
Mikael Hugo 365c6bbc3b
Some checks are pending
CI / detect-changes (push) Waiting to run
CI / docs-check (push) Blocked by required conditions
CI / lint (push) Blocked by required conditions
CI / build (push) Blocked by required conditions
CI / integration-tests (push) Blocked by required conditions
CI / windows-portability (push) Blocked by required conditions
CI / rtk-portability (linux, blacksmith-4vcpu-ubuntu-2404) (push) Blocked by required conditions
CI / rtk-portability (macos, macos-15) (push) Blocked by required conditions
CI / rtk-portability (windows, blacksmith-4vcpu-windows-2025) (push) Blocked by required conditions
chore: formatter / linter touch-up (230 files)
Pure formatting / lint-fix pass that ran during `npm run build:core`
in the session that landed the agent-runner / quota / coverage /
phase-2 routing work. No logic changes — indentation, trailing
commas, import sort, etc. Captured separately so the actual feature
commits stay scoped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:19:53 +02:00

393 lines
13 KiB
TypeScript

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