import { t } from "../i18n.mjs"; import { emit } from "../output.mjs"; import { loadContexts, saveContexts, resolveActiveContext } from "../contexts.mjs"; /** Auth label for a context: prefers the scoped accessToken over the legacy apiKey. */ function authLabel(c) { if (c?.accessToken) return "token"; if (c?.apiKey) return "key"; return "✗"; } export async function confirm(msg) { // Non-interactive stdin (pipe, CI, EOF) cannot answer a [y/N] prompt. Asking // anyway leaves the readline question pending forever — Node then warns about an // "unsettled top-level await" at exit. Decline cleanly instead and point at the // non-interactive escape hatch so scripted callers fail safe rather than hang. if (!process.stdin.isTTY) { process.stderr.write(`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`); return false; } const readline = await import("node:readline"); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const answer = await new Promise((r) => rl.question(`${msg} [y/N] `, r)); rl.close(); return /^y(es)?$/i.test(answer); } function maskKey(k) { if (!k) return null; if (k.length <= 8) return "***"; return `${k.slice(0, 6)}***${k.slice(-4)}`; } export function registerContexts(program) { const ctx = program .command("contexts") .alias("context") // singular alias — docs/connect output historically said `context current` .description(t("config.contexts.description") || "Manage server contexts/profiles"); ctx .command("list") .description("List all contexts") .action(async (opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); const cfg = loadContexts(); const rows = Object.entries(cfg.contexts || {}).map(([name, c]) => ({ active: name === (cfg.currentContext || "default") ? "●" : "", name, baseUrl: c.baseUrl || "", auth: authLabel(c), scope: c.scope || "", description: c.description || "", })); emit(rows, globalOpts, [ { key: "active", header: "" }, { key: "name", header: "Name" }, { key: "baseUrl", header: "Base URL" }, { key: "auth", header: "Auth" }, { key: "scope", header: "Scope" }, { key: "description", header: "Description" }, ]); }); ctx .command("add ") .description("Add a new context") .requiredOption("--url ", "Base URL") .option("--api-key ", "Legacy inference API key") .option("--api-key-stdin", "Read API key from stdin") .option("--access-token ", "Scoped CLI access token (preferred over --api-key)") .option("--access-token-stdin", "Read access token from stdin") .option("--scope ", "Token scope hint for display (read|write|admin)") .option("--description ", "Context description") .action(async (name, opts) => { const cfg = loadContexts(); if (cfg.contexts?.[name]) { process.stderr.write(`Context '${name}' already exists. Remove or rename first.\n`); process.exit(2); } let apiKey = opts.apiKey || null; let accessToken = opts.accessToken || null; if (opts.apiKeyStdin || opts.accessTokenStdin) { const chunks = []; for await (const c of process.stdin) chunks.push(c); const value = chunks.join("").trim() || null; if (opts.accessTokenStdin) accessToken = value; else apiKey = value; } cfg.contexts = cfg.contexts || {}; cfg.contexts[name] = { baseUrl: opts.url, accessToken: accessToken || undefined, apiKey, scope: opts.scope || undefined, description: opts.description || undefined, }; saveContexts(cfg); process.stdout.write(`Added context '${name}'\n`); }); ctx .command("use ") .description("Switch active context") .action((name) => { const cfg = loadContexts(); if (!cfg.contexts?.[name]) { process.stderr.write(`No such context: ${name}\n`); process.exit(2); } cfg.currentContext = name; saveContexts(cfg); process.stdout.write(`Active context: ${name}\n`); }); ctx .command("current") .description("Show the active context (server, auth, scope)") .option("--name-only", "Print just the context name (legacy behavior)") .action((opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); const cfg = loadContexts(); const name = cfg.currentContext || cfg.activeProfile || "default"; if (opts.nameOnly) { process.stdout.write(`${name}\n`); return; } const c = resolveActiveContext(name); emit( { name, baseUrl: c.baseUrl || "", auth: authLabel(c), scope: c.scope || "", description: c.description || "", }, globalOpts ); }); ctx .command("show ") .description("Show context details") .action((name, opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); const cfg = loadContexts(); const c = cfg.contexts?.[name]; if (!c) { process.stderr.write(`No such context: ${name}\n`); process.exit(2); } const display = { name, baseUrl: c.baseUrl, accessToken: maskKey(c.accessToken), apiKey: maskKey(c.apiKey), scope: c.scope, description: c.description, }; emit(display, globalOpts); }); ctx .command("remove ") .description("Remove a context") .option("--yes", "Skip confirmation") .action(async (name, opts) => { if (!opts.yes) { const ok = await confirm(`Remove context '${name}'?`); if (!ok) { process.stdout.write("Cancelled.\n"); return; } } const cfg = loadContexts(); if (!cfg.contexts?.[name]) { process.stderr.write(`No such context: ${name}\n`); process.exit(2); } if (name === "default") { process.stderr.write("Cannot remove default context.\n"); process.exit(2); } delete cfg.contexts[name]; if (cfg.currentContext === name) cfg.currentContext = "default"; saveContexts(cfg); process.stdout.write(`Removed context '${name}'\n`); }); ctx .command("rename ") .description("Rename a context") .action((oldName, newName) => { const cfg = loadContexts(); if (!cfg.contexts?.[oldName]) { process.stderr.write(`No such context: ${oldName}\n`); process.exit(2); } if (cfg.contexts[newName]) { process.stderr.write(`Context '${newName}' already exists.\n`); process.exit(2); } cfg.contexts[newName] = cfg.contexts[oldName]; delete cfg.contexts[oldName]; if (cfg.currentContext === oldName) cfg.currentContext = newName; saveContexts(cfg); process.stdout.write(`Renamed '${oldName}' → '${newName}'\n`); }); ctx .command("export") .description("Export contexts to JSON") .option("--out ", "Output file path (default: stdout)") .option("--no-secrets", "Omit API keys from export") .action(async (opts, cmd) => { const cfg = loadContexts(); const out = JSON.parse(JSON.stringify(cfg)); if (opts.noSecrets) { for (const c of Object.values(out.contexts || {})) { c.apiKey = null; delete c.accessToken; } } const json = JSON.stringify(out, null, 2); if (opts.out) { const { writeFileSync } = await import("node:fs"); writeFileSync(opts.out, json); process.stdout.write(`Exported to ${opts.out}\n`); } else { process.stdout.write(json + "\n"); } }); ctx .command("import ") .description("Import contexts from a JSON file") .option("--merge", "Merge with existing contexts (default: overwrite)") .action(async (file, opts) => { const { readFileSync } = await import("node:fs"); let imported; try { imported = JSON.parse(readFileSync(file, "utf8")); } catch (e) { process.stderr.write( `Cannot read ${file}: ${e instanceof Error ? e.message : String(e)}\n` ); process.exit(1); } const cfg = opts.merge ? loadContexts() : { version: 1, currentContext: "default", contexts: {} }; const incoming = imported.contexts || {}; let count = 0; for (const [name, raw] of Object.entries(incoming)) { if (typeof name !== "string" || !name) continue; const c = raw && typeof raw === "object" ? /** @type {Record} */ (raw) : {}; cfg.contexts[name] = { baseUrl: typeof c.baseUrl === "string" ? c.baseUrl : "http://localhost:20128", accessToken: typeof c.accessToken === "string" ? c.accessToken : undefined, apiKey: typeof c.apiKey === "string" ? c.apiKey : null, scope: typeof c.scope === "string" ? c.scope : undefined, description: typeof c.description === "string" ? c.description : undefined, }; count++; } if (!opts.merge && typeof imported.currentContext === "string") { cfg.currentContext = imported.currentContext; } saveContexts(cfg); process.stdout.write(`Imported ${count} context(s)\n`); }); }