Skip to content

Instantly share code, notes, and snippets.

@JavaGT
Last active June 20, 2026 11:56
Show Gist options
  • Select an option

  • Save JavaGT/8833308eb12647b75bbdfda0debf81f3 to your computer and use it in GitHub Desktop.

Select an option

Save JavaGT/8833308eb12647b75bbdfda0debf81f3 to your computer and use it in GitHub Desktop.
Multi-provider API key manager for opencode

switch-key

Multi-provider API key manager for opencode. Named profiles, GitHub sync, interactive arrow-key picker.

Install

curl -fsSL https://gist.githubusercontent.com/JavaGT/8833308eb12647b75bbdfda0debf81f3/raw/switch-key.mjs -o ~/.config/opencode/switch-key.mjs
chmod +x ~/.config/opencode/switch-key.mjs
ln -sf ~/.config/opencode/switch-key.mjs /opt/homebrew/bin/switch-key

Usage

switch-key                          # status overview
switch-key providers                # list all providers
switch-key list <provider>          # list profiles
switch-key add <provider> <name> <key> [desc]
switch-key set <provider> <name>    # switch active key
switch-key delete <provider> <name>
switch-key show <provider> <name>
switch-key current [provider]
switch-key switch-all pioneer:personal-email openrouter:work
switch-key pick                     # interactive arrow-key picker
switch-key sync --repo <owner/repo> # configure GitHub sync
switch-key sync                     # pull from GitHub
switch-key sync --push              # push to GitHub
switch-key -v                       # version
#!/usr/bin/env node
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, renameSync } from "fs";
import * as readline from "readline";
import { execSync } from "child_process";
const HOME = process.env.HOME;
const CONFIG_PATH = `${HOME}/.config/opencode/opencode.json`;
const KEYS_DIR = `${HOME}/.config/opencode/keys`;
const SECRETS_DIR = `${HOME}/.secrets/llm-api-keys`;
const PROFILES_PATH = `${KEYS_DIR}/profiles.json`;
const UPDATE_CACHE = `${KEYS_DIR}/.last-update-check`;
const VERSION = "1.3.0";
const UPDATE_URL = "https://gist.githubusercontent.com/JavaGT/8833308eb12647b75bbdfda0debf81f3/raw/switch-opencode-keys.mjs";
// ── Built-in providers that use env vars ────────────────────────────
const ENV_PROVIDERS = {
openrouter: { env: "OPENROUTER_API_KEY", label: "OpenRouter" },
anthropic: { env: "ANTHROPIC_API_KEY", label: "Anthropic" },
openai: { env: "OPENAI_API_KEY", label: "OpenAI" },
google: { env: "GOOGLE_GENERATIVEAI_API_KEY", label: "Google AI" },
deepseek: { env: "DEEPSEEK_API_KEY", label: "DeepSeek" },
xai: { env: "XAI_API_KEY", label: "xAI" },
groq: { env: "GROQ_API_KEY", label: "Groq" },
mistral: { env: "MISTRAL_API_KEY", label: "Mistral" },
together: { env: "TOGETHER_API_KEY", label: "Together AI" },
fireworks: { env: "FIREWORKS_API_KEY", label: "Fireworks AI" },
novita: { env: "NOVITA_API_KEY", label: "Novita AI" },
cohere: { env: "COHERE_API_KEY", label: "Cohere" },
};
// ── Data layer ──────────────────────────────────────────────────────
function ensureDataDir() {
if (!existsSync(KEYS_DIR)) mkdirSync(KEYS_DIR, { recursive: true });
if (!existsSync(SECRETS_DIR)) mkdirSync(SECRETS_DIR, { recursive: true });
}
function secretFilePath(provider) {
return `${SECRETS_DIR}/${provider}.key`;
}
function readSecretFile(path) {
try { return readFileSync(path, "utf-8").trim(); } catch { return null; }
}
function writeSecretFile(path, key) {
writeFileSync(path, key + "\n");
}
function deleteSecretFile(path) {
try { rmSync(path); } catch {}
}
function resolveFileRef(value) {
if (typeof value === "string" && value.startsWith("{file:")) {
const m = value.match(/^\{file:(.+)\}$/);
if (m) return readSecretFile(m[1].replace(/^~/, HOME));
}
return value || null;
}
function loadProfiles() {
ensureDataDir();
if (!existsSync(PROFILES_PATH)) return {};
return JSON.parse(readFileSync(PROFILES_PATH, "utf-8"));
}
function saveProfiles(p) {
ensureDataDir();
writeFileSync(PROFILES_PATH, JSON.stringify(p, null, 2) + "\n");
}
function loadConfig() {
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
}
function saveConfig(c) {
const tmp = CONFIG_PATH + ".tmp";
writeFileSync(tmp, JSON.stringify(c, null, 2) + "\n");
renameSync(tmp, CONFIG_PATH);
}
// ── Profile key helpers (secrets file per profile) ──────────────────
function profileSecretFile(provider, name) {
const safe = `${provider}-${name}`.replace(/[^a-zA-Z0-9._-]/g, "_");
return `${SECRETS_DIR}/${safe}.key`;
}
function resolveProfileKey(provider, name, entry) {
if (entry.key) return entry.key;
if (entry.file) return readSecretFile(`${SECRETS_DIR}/${entry.file}`);
return null;
}
function saveProfileKey(provider, name, key) {
const safe = `${provider}-${name}`.replace(/[^a-zA-Z0-9._-]/g, "_");
const file = `${safe}.key`;
writeSecretFile(`${SECRETS_DIR}/${file}`, key);
return file;
}
function deleteProfileKey(provider, name) {
const safe = `${provider}-${name}`.replace(/[^a-zA-Z0-9._-]/g, "_");
deleteSecretFile(`${SECRETS_DIR}/${safe}.key`);
}
// ── Config read/write for a provider ────────────────────────────────
function getConfigKey(provider) {
const config = loadConfig();
const cfgKey = config?.provider?.[provider]?.options?.apiKey;
const envInfo = ENV_PROVIDERS[provider];
const envKey = envInfo ? process.env[envInfo.env] : undefined;
const resolved = resolveFileRef(cfgKey);
return resolved || envKey || null;
}
function setConfigKey(provider, key) {
const config = loadConfig();
const envInfo = ENV_PROVIDERS[provider];
ensureDataDir();
const fileArg = `{file:~/.secrets/llm-api-keys/${provider}.key}`;
writeSecretFile(secretFilePath(provider), key);
if (envInfo) {
if (!config.provider) config.provider = {};
if (!config.provider[provider]) config.provider[provider] = { name: envInfo.label, models: {} };
if (!config.provider[provider].options) config.provider[provider].options = {};
config.provider[provider].options.apiKey = fileArg;
saveConfig(config);
} else if (config?.provider?.[provider]) {
if (!config.provider[provider].options) config.provider[provider].options = {};
config.provider[provider].options.apiKey = fileArg;
saveConfig(config);
} else {
console.log(` Unknown provider "${provider}". Add it to opencode.json first.`);
process.exit(1);
}
}
function removeConfigKey(provider) {
const config = loadConfig();
deleteSecretFile(secretFilePath(provider));
if (config?.provider?.[provider]?.options?.apiKey) {
delete config.provider[provider].options.apiKey;
saveConfig(config);
}
}
// ── Providers list ──────────────────────────────────────────────────
function getAllProviders() {
const config = loadConfig();
const providers = {};
for (const [name, cfg] of Object.entries(config?.provider || {})) {
providers[name] = {
label: cfg.name || name,
type: cfg.options?.baseURL ? "custom (config)" : (ENV_PROVIDERS[name] ? "env" : "config"),
hasKey: !!resolveFileRef(cfg.options?.apiKey),
};
}
for (const [name, info] of Object.entries(ENV_PROVIDERS)) {
if (!providers[name]) {
providers[name] = {
label: info.label,
type: "env",
hasKey: !!process.env[info.env],
};
}
}
return providers;
}
// ── Sync helpers ────────────────────────────────────────────────────
function ghExec(args, opts = {}) {
return execSync(`gh ${args}`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], ...opts }).trim();
}
// ── Credits ─────────────────────────────────────────────────────────
function fetchCredits(apiKey) {
const auth = `Bearer ${apiKey}`;
let teamsBody;
try {
const teamsOut = execSync(
`curl -sf -H 'Authorization: ${auth}' https://api.pioneer.ai/teams`,
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 10000 }
);
teamsBody = JSON.parse(teamsOut);
} catch (e) {
return { error: `Failed to fetch teams: ${e.stderr || e.message}` };
}
if (!teamsBody.teams || !Array.isArray(teamsBody.teams)) {
return { error: `Unexpected teams response: ${JSON.stringify(teamsBody).slice(0, 200)}` };
}
const results = [];
for (const team of teamsBody.teams) {
let credit_limit = null;
try {
const statusOut = execSync(
`curl -sf -H 'Authorization: ${auth}' https://api.pioneer.ai/billing/billing-status`,
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 10000 }
);
const status = JSON.parse(statusOut);
credit_limit = status.credit_limit ?? null;
} catch {}
try {
const usageOut = execSync(
`curl -sf -H 'Authorization: ${auth}' https://api.pioneer.ai/billing/team/${team.id}/usage`,
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 10000 }
);
const usage = JSON.parse(usageOut);
results.push({ team_name: team.name, team_id: team.id, credit_limit, ...usage });
} catch (e) {
results.push({ team_name: team.name, team_id: team.id, credit_limit, error: e.stderr || e.message });
}
}
return { teams: results };
}
function displayCredits(result) {
if (result.error) {
console.log(` ✗ ${result.error}`);
return;
}
for (const t of result.teams) {
console.log(` ${t.team_name}`);
if (t.error) {
console.log(` Billing: ${t.error.trim()}`);
continue;
}
console.log(` Total usage: ${t.total_usage?.toFixed(2) ?? "?"} credits`);
if (t.members) {
for (const m of t.members) {
const spent = m.total_credits ?? 0;
const limit = t.credit_limit ?? 5000;
const remaining = limit - spent;
const usd = `$${(remaining / 100).toFixed(2)}`;
console.log(` ${m.email || m.user_id}: ${spent.toFixed(2)} spent / ${remaining.toFixed(0)}cr remaining (${usd}) — ${m.request_count} requests`);
}
}
}
}
// ── Interactive picker ──────────────────────────────────────────────
function render(screen) {
process.stdout.write("\x1b[2J\x1b[H");
process.stdout.write(screen);
}
function printStatus() {
const providers = getAllProviders();
const profiles = loadProfiles();
const names = Object.keys(providers);
if (names.length === 0) { console.log(" (no providers)"); return; }
const col = names.reduce((m, n) => Math.max(m, n.length), 0);
for (const name of names) {
const p = providers[name];
const active = p.hasKey ? "●" : "○";
const profilesForProvider = profiles[name] || {};
const profileNames = Object.keys(profilesForProvider);
const profileCount = profileNames.length > 0 ? ` (${profileNames.length} profiles)` : "";
const key = getConfigKey(name);
const mask = key ? key.slice(0, 10) + "…" + key.slice(-4) : "—";
console.log(` ${active} ${name.padEnd(col + 2)} ${p.type.padEnd(8)} ${mask}${profileCount}`);
}
}
function listProfiles(provider) {
const profiles = loadProfiles();
const current = getConfigKey(provider);
const p = profiles[provider] || {};
const names = Object.keys(p);
if (names.length === 0) {
console.log(` No profiles for "${provider}".`);
return;
}
const col = names.reduce((m, n) => Math.max(m, n.length), 0);
for (const name of names) {
const k = p[name];
const key = resolveProfileKey(provider, name, k);
const mask = key ? key.slice(0, 12) + "…" + key.slice(-4) : "—";
const desc = k.description ? ` — ${k.description}` : "";
const active = key === current ? " ← ACTIVE" : "";
console.log(` ${name.padEnd(col + 2)} ${mask}${desc}${active}`);
}
}
// ── Auto-update ─────────────────────────────────────────────────────
function shouldCheckUpdate() {
if (!existsSync(UPDATE_CACHE)) return true;
try {
const last = parseInt(readFileSync(UPDATE_CACHE, "utf-8"), 10);
return Date.now() - last > 24 * 60 * 60 * 1000; // once per day
} catch { return true; }
}
function markChecked() {
ensureDataDir();
writeFileSync(UPDATE_CACHE, String(Date.now()));
}
async function checkForUpdate() {
if (!shouldCheckUpdate()) return;
markChecked();
try {
const res = await fetch(UPDATE_URL, { signal: AbortSignal.timeout(5000) });
if (!res.ok) return;
const remote = await res.text();
// extract VERSION from remote script
const remoteVerMatch = remote.match(/^const VERSION = "(.+?)"/m);
if (!remoteVerMatch) return;
const remoteVer = remoteVerMatch[1];
if (remoteVer === VERSION) return;
// different version — prompt
const localPath = new URL(import.meta.url).pathname;
console.log(`\n \x1b[33mUpdate available: ${VERSION}${remoteVer}\x1b[0m`);
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((r) => rl.question(" Update now? (Y/n): ", r));
rl.close();
if (answer.trim().toLowerCase() === "n") return;
writeFileSync(localPath, remote);
console.log(` Updated to ${remoteVer}. Restart switch-opencode-keys.`);
process.exit(0);
} catch {
// network error — skip silently
}
}
// ── Migration: inline keys → secret files ──────────────────────────
function migrateInlineKeys() {
const config = loadConfig();
const profiles = loadProfiles();
let changed = false;
// migrate provider apiKey inline → {file:...}
for (const [provider, cfg] of Object.entries(config?.provider || {})) {
const val = cfg?.options?.apiKey;
if (val && typeof val === "string" && !val.startsWith("{file:") && !val.startsWith("{env:")) {
ensureDataDir();
writeSecretFile(secretFilePath(provider), val);
config.provider[provider].options.apiKey = `{file:~/.secrets/llm-api-keys/${provider}.key}`;
changed = true;
}
}
if (changed) saveConfig(config);
// migrate profiles key inline → file reference
let profilesChanged = false;
for (const [provider, entries] of Object.entries(profiles)) {
for (const [name, entry] of Object.entries(entries || {})) {
if (entry.key && !entry.file) {
ensureDataDir();
const file = saveProfileKey(provider, name, entry.key);
delete entry.key;
entry.file = file;
profilesChanged = true;
}
}
}
if (profilesChanged) saveProfiles(profiles);
}
// ── Init ────────────────────────────────────────────────────────────
function cmdInit() {
ensureDataDir();
migrateInlineKeys();
console.log("\n Secrets setup complete:\n");
console.log(` Secrets dir: ${SECRETS_DIR}`);
console.log(` Keys dir: ${KEYS_DIR}`);
console.log(` Profiles: ${PROFILES_PATH}`);
console.log(" Keys are local-only and never synced to git or any remote.");
const config = loadConfig();
let count = 0;
for (const [provider, cfg] of Object.entries(config?.provider || {})) {
const val = cfg?.options?.apiKey;
if (val && typeof val === "string" && val.startsWith("{file:")) {
const key = resolveFileRef(val);
console.log(` ${provider}: ${key ? "✓ secret file" : "✗ file not found"}`);
count++;
}
}
if (count === 0) console.log(" No provider keys configured.");
console.log();
}
function showHelp() {
console.log(`\n switch-opencode-keys v${VERSION}`);
console.log("\n Commands:");
console.log(" switch-opencode-keys (no args) Interactive arrow-key picker");
console.log(" switch-opencode-keys status Show API key status");
console.log(" switch-opencode-keys providers List all providers");
console.log(" switch-opencode-keys list <provider> List profiles for a provider");
console.log(" switch-opencode-keys add <provider> <name> <key> [desc]");
console.log(" switch-opencode-keys set <provider> <name> Switch active key");
console.log(" switch-opencode-keys delete <provider> <name> Remove a profile");
console.log(" switch-opencode-keys show <provider> <name> Show full key");
console.log(" switch-opencode-keys current [provider] Show active key(s)");
console.log(" switch-opencode-keys switch-all <p>:<n> [...] Switch multiple providers at once");
console.log(" switch-opencode-keys pick Interactive arrow-key picker");
console.log(" switch-opencode-keys credits <provider> Show Pioneer billing credits");
console.log(" switch-opencode-keys init Setup/verify secrets directory and migrate keys");
console.log();
console.log(" Options:");
console.log(" -h, --help, help Show this help");
console.log(" -v, --version Show version");
console.log();
}
// ── CLI ─────────────────────────────────────────────────────────────
async function main() {
const args = process.argv.slice(2);
const cmd = args[0];
// skip update check for version/help flags
if (cmd !== "--version" && cmd !== "-v" && cmd !== "--help" && cmd !== "-h" && cmd !== "help") {
await checkForUpdate();
}
if (cmd === "--version" || cmd === "-v") {
console.log(`switch-opencode-keys v${VERSION}`);
process.exit(0);
}
if (cmd === "--help" || cmd === "-h" || cmd === "help") {
showHelp();
process.exit(0);
}
// auto-migrate inline keys to secret files on any non-help command
if (cmd !== "init") {
try { migrateInlineKeys(); } catch {}
}
if (cmd === "init") {
cmdInit();
process.exit(0);
}
// ── default: open interactive picker ──
if (!cmd || cmd === "status") {
if (cmd === "status") {
// explicit status command
console.log("\n API key status:\n");
printStatus();
console.log(`\n Run \x1b[32mswitch-opencode-keys --help\x1b[0m for all commands.\n`);
process.exit(0);
}
// no args — open interactive picker
const providers = getAllProviders();
const names = Object.keys(providers);
let cursor = 0;
const draw = () => {
const lines = [];
lines.push("");
lines.push(" Select provider:\n");
for (let i = 0; i < names.length; i++) {
const n = names[i];
const p = providers[n];
const ptr = i === cursor ? " \x1b[36m▸\x1b[0m" : " ";
const marker = p.hasKey ? "●" : "○";
lines.push(`${ptr} ${marker} ${n} \x1b[2m${p.label}\x1b[0m`);
}
lines.push("");
lines.push(" \x1b[2m↑/↓ navigate ↵ select q quit\x1b[0m");
render(lines.join("\n"));
};
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding("utf-8");
await new Promise((resolve) => {
const onKey = async (d) => {
if (d === "\x1b[A") { cursor = (cursor - 1 + names.length) % names.length; draw(); }
else if (d === "\x1b[B") { cursor = (cursor + 1) % names.length; draw(); }
else if (d === "\r") {
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.removeListener("data", onKey);
await interactivePick(names[cursor]);
resolve();
}
else if (d === "q" || d === "\x03") {
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.removeListener("data", onKey);
console.log("\n Bye.");
resolve();
}
};
process.stdin.on("data", onKey);
draw();
});
process.exit(0);
}
// ── providers ──
if (cmd === "providers") {
const providers = getAllProviders();
console.log("\n Available providers:\n");
const col = Object.keys(providers).reduce((m, n) => Math.max(m, n.length), 0);
for (const [name, p] of Object.entries(providers)) {
const marker = p.hasKey ? "●" : "○";
console.log(` ${marker} ${name.padEnd(col + 2)} ${p.type.padEnd(10)} ${p.label}`);
}
console.log("\n ● = key configured ○ = no key");
process.exit(0);
}
// ── list ──
if (cmd === "list") {
const provider = args[1];
if (!provider) { console.log("Usage: switch-opencode-keys list <provider>"); process.exit(1); }
console.log(`\n Profiles for ${provider}:\n`);
listProfiles(provider);
process.exit(0);
}
// ── add ──
if (cmd === "add") {
const provider = args[1], name = args[2], key = args[3], desc = args.slice(4).join(" ") || undefined;
if (!provider || !name || !key) {
console.log("Usage: switch-opencode-keys add <provider> <name> <key> [description]");
process.exit(1);
}
ensureDataDir();
const profiles = loadProfiles();
if (!profiles[provider]) profiles[provider] = {};
if (profiles[provider][name]) {
console.log(` Profile "${name}" already exists for ${provider}.`);
process.exit(1);
}
const file = saveProfileKey(provider, name, key);
profiles[provider][name] = { file, description: desc };
saveProfiles(profiles);
console.log(` Added "${name}" for ${provider}.`);
process.exit(0);
}
// ── set ──
if (cmd === "set") {
const provider = args[1], name = args[2];
if (!provider || !name) { console.log("Usage: switch-opencode-keys set <provider> <name>"); process.exit(1); }
const profiles = loadProfiles();
if (!profiles[provider]?.[name]) {
console.log(` Profile "${name}" not found for ${provider}.`);
process.exit(1);
}
const key = resolveProfileKey(provider, name, profiles[provider][name]);
if (!key) {
console.log(` Key file missing for "${name}" (${provider}). Re-add the profile.`);
process.exit(1);
}
setConfigKey(provider, key);
console.log(` Active key for ${provider} → \x1b[32m${name}\x1b[0m. Restart opencode.`);
process.exit(0);
}
// ── delete ──
if (cmd === "delete" || cmd === "rm") {
const provider = args[1], name = args[2];
if (!provider || !name) { console.log("Usage: switch-opencode-keys delete <provider> <name>"); process.exit(1); }
const profiles = loadProfiles();
if (!profiles[provider]?.[name]) {
console.log(` Profile "${name}" not found for ${provider}.`);
process.exit(1);
}
const current = getConfigKey(provider);
const key = resolveProfileKey(provider, name, profiles[provider][name]);
const wasActive = key === current;
deleteProfileKey(provider, name);
delete profiles[provider][name];
saveProfiles(profiles);
if (wasActive) removeConfigKey(provider);
console.log(` Deleted "${name}" from ${provider}.${wasActive ? " (was active — key removed from config)" : ""}`);
process.exit(0);
}
// ── show ──
if (cmd === "show") {
const provider = args[1], name = args[2];
if (!provider || !name) { console.log("Usage: switch-opencode-keys show <provider> <name>"); process.exit(1); }
const profiles = loadProfiles();
if (!profiles[provider]?.[name]) {
console.log(` Profile "${name}" not found for ${provider}.`);
process.exit(1);
}
const p = profiles[provider][name];
const key = resolveProfileKey(provider, name, p);
console.log(`\n Provider: ${provider}`);
console.log(` Name: ${name}`);
console.log(` Description: ${p.description || "(none)"}`);
console.log(` Key: ${key || "(file missing)"}\n`);
process.exit(0);
}
// ── current ──
if (cmd === "current") {
const provider = args[1];
if (provider) {
const key = getConfigKey(provider);
if (!key) { console.log(` No active key for ${provider}.`); }
else {
const profiles = loadProfiles();
let match = null;
for (const [n, v] of Object.entries(profiles[provider] || {})) {
if (resolveProfileKey(provider, n, v) === key) { match = n; break; }
}
console.log(` ${provider}: ${match ? match : key.slice(0, 12) + "…"}`);
}
} else {
console.log("\n Active keys:\n");
const providers = getAllProviders();
for (const [name] of Object.entries(providers)) {
const key = getConfigKey(name);
if (key) {
const profiles = loadProfiles();
let match = null;
for (const [n, v] of Object.entries(profiles[name] || {})) {
if (resolveProfileKey(name, n, v) === key) { match = n; break; }
}
console.log(` ${name.padEnd(14)} ${match ? match : key.slice(0, 12) + "…"}`);
}
}
}
process.exit(0);
}
// ── switch-all ──
if (cmd === "switch-all") {
const pairs = args.slice(1);
if (pairs.length === 0) {
console.log("Usage: switch-opencode-keys switch-all <provider>:<profile> [...]");
console.log(" e.g. switch-opencode-keys switch-all pioneer:student-email openrouter:work");
process.exit(1);
}
const profiles = loadProfiles();
for (const pair of pairs) {
const [provider, name] = pair.split(":");
if (!provider || !name) { console.log(` Invalid format: "${pair}" (expected provider:name)`); continue; }
if (!profiles[provider]?.[name]) { console.log(` Profile "${name}" not found for ${provider}.`); continue; }
const key = resolveProfileKey(provider, name, profiles[provider][name]);
if (!key) { console.log(` Key file missing for "${name}" (${provider}).`); continue; }
setConfigKey(provider, key);
console.log(` ${provider} → \x1b[32m${name}\x1b[0m`);
}
console.log("\n Done. Restart opencode.");
process.exit(0);
}
// ── credits ──
if (cmd === "credits") {
const provider = args[1];
if (!provider) { console.log("Usage: switch-opencode-keys credits <provider>"); process.exit(1); }
const key = getConfigKey(provider);
if (!key) { console.log(` No active key for ${provider}.`); process.exit(1); }
console.log(`\n Fetching credits for ${provider}…\n`);
const result = fetchCredits(key);
displayCredits(result);
console.log();
process.exit(0);
}
// ── interactive mode ──
if (cmd === "pick" || cmd === "i") {
const provider = args[1];
if (!provider) {
const providers = getAllProviders();
const names = Object.keys(providers);
let cursor = 0;
const draw = () => {
const lines = [];
lines.push("");
lines.push(" Select provider:\n");
for (let i = 0; i < names.length; i++) {
const n = names[i];
const p = providers[n];
const ptr = i === cursor ? " \x1b[36m▸\x1b[0m" : " ";
const marker = p.hasKey ? "●" : "○";
lines.push(`${ptr} ${marker} ${n} \x1b[2m${p.label}\x1b[0m`);
}
lines.push("");
lines.push(" \x1b[2m↑/↓ navigate ↵ select q quit\x1b[0m");
render(lines.join("\n"));
};
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding("utf-8");
await new Promise((resolve) => {
const onKey = async (d) => {
if (d === "\x1b[A") { cursor = (cursor - 1 + names.length) % names.length; draw(); }
else if (d === "\x1b[B") { cursor = (cursor + 1) % names.length; draw(); }
else if (d === "\r") {
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.removeListener("data", onKey);
await interactivePick(names[cursor]);
resolve();
}
else if (d === "q" || d === "\x03") {
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.removeListener("data", onKey);
console.log("\n Bye.");
resolve();
}
};
process.stdin.on("data", onKey);
draw();
});
process.exit(0);
}
await interactivePick(provider);
process.exit(0);
}
console.log(` Unknown command: ${cmd}`);
console.log(" Run switch-opencode-keys with no args for help.");
process.exit(1);
}
async function interactivePick(provider) {
const profiles = loadProfiles();
const p = profiles[provider] || {};
const names = Object.keys(p);
if (names.length === 0) {
console.log(`\n No profiles for "${provider}". Add one with:`);
console.log(` switch-opencode-keys add ${provider} <name> <key>\n`);
return;
}
const current = getConfigKey(provider);
let cursor = Math.max(0, names.findIndex((n) => resolveProfileKey(provider, n, p[n]) === current));
let balances = null; // { name: { credits, usd } } once fetched
const creditsToUSD = (c) => c != null ? `$${(c / 100).toFixed(2)}` : null;
const draw = () => {
const lines = [];
lines.push("");
lines.push(` ${provider} profiles:\n`);
for (let i = 0; i < names.length; i++) {
const n = names[i];
const k = p[n];
const key = resolveProfileKey(provider, n, k);
const mask = key ? key.slice(0, 12) + "…" + key.slice(-4) : "—";
const desc = k.description ? ` — ${k.description}` : "";
const active = key === current ? " \x1b[32m● active\x1b[0m" : "";
const ptr = i === cursor ? " \x1b[36m▸\x1b[0m" : " ";
let balance = "";
if (balances && balances[n]) {
const b = balances[n];
const limit = b.credit_limit ?? 5000;
const remaining = limit - b.total_credits;
const usd = creditsToUSD(remaining);
balance = usd ? ` \x1b[2m${remaining.toFixed(0)}cr / ${usd}\x1b[0m` : ` \x1b[2m${remaining.toFixed(0)}cr\x1b[0m`;
}
lines.push(`${ptr} ${n.padEnd(20)} ${mask}${desc}${active}${balance}`);
}
lines.push("");
lines.push(" \x1b[2m↑/↓ navigate ↵ select s show key c credits q back\x1b[0m");
render(lines.join("\n"));
};
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding("utf-8");
return new Promise((resolve) => {
const onKey = (d) => {
if (d === "\x1b[A") { cursor = (cursor - 1 + names.length) % names.length; draw(); }
else if (d === "\x1b[B") { cursor = (cursor + 1) % names.length; draw(); }
else if (d === "\r") {
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.removeListener("data", onKey);
const name = names[cursor];
const key = resolveProfileKey(provider, name, p[name]);
if (!key) { console.log(` Key file missing for "${name}".\n`); resolve(); return; }
setConfigKey(provider, key);
console.log(`\n ${provider} → \x1b[32m${name}\x1b[0m. Restart opencode.\n`);
resolve();
}
else if (d === "q" || d === "\x03") {
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.removeListener("data", onKey);
resolve();
}
else if (d === "s") {
process.stdin.setRawMode(false);
const name = names[cursor];
const key = resolveProfileKey(provider, name, p[name]);
console.log(`\n ${name}: ${key || "(file missing)"}`);
console.log(" Press any key to continue…");
process.stdin.once("data", () => {
process.stdin.setRawMode(true);
draw();
});
}
else if (d === "c") {
if (balances) {
balances = null;
draw();
} else {
process.stdin.setRawMode(false);
const fetchLine = `\r Fetching balances…`;
process.stdout.write(fetchLine);
const results = {};
for (const n of names) {
const key = resolveProfileKey(provider, n, p[n]);
if (!key) { results[n] = { error: "no key" }; continue; }
const r = fetchCredits(key);
if (r.error) { results[n] = { error: r.error }; continue; }
const t = (r.teams || [])[0];
if (t && !t.error && t.members && t.members.length > 0) {
results[n] = { total_credits: t.members[0].total_credits, credit_limit: t.credit_limit, team_name: t.team_name };
} else if (t?.error) {
results[n] = { error: t.error };
} else {
results[n] = { error: "no data" };
}
}
process.stdout.write("\r\x1b[K");
balances = results;
process.stdin.setRawMode(true);
draw();
}
}
};
process.stdin.on("data", onKey);
draw();
});
}
main().catch((e) => { console.error(e); process.exit(1); });
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment