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
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>
393 lines
13 KiB
TypeScript
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",
|
|
);
|
|
}
|