|
#!/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); }); |