From 77b2fe9fbfff6f57f03b0e241d9f44a56de05e87 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Sat, 7 Feb 2026 13:30:53 -0500 Subject: [PATCH] feat: add ANSI auth menu and codex-auth command --- index.ts | 209 +++++++++++++++++++++++++------- lib/ui/auth-menu-flow.ts | 61 ++++++++++ lib/ui/auth-menu-runner.ts | 97 +++++++++++++++ lib/ui/auth-menu.ts | 122 +++++++++++++++++++ lib/ui/codex-quota-report.ts | 106 ++++++++++++++++ lib/ui/tty/confirm.ts | 27 +++++ lib/ui/tty/select.ts | 155 +++++++++++++++++++++++ test/auth-menu-flow.test.ts | 89 ++++++++++++++ test/auth-menu-runner.test.ts | 133 ++++++++++++++++++++ test/auth-menu.test.ts | 118 ++++++++++++++++++ test/codex-quota-report.test.ts | 31 +++++ test/plugin-config-hook.test.ts | 25 ++++ test/plugin-loader.test.ts | 19 +++ test/tty-confirm.test.ts | 27 +++++ test/tty-select.test.ts | 88 ++++++++++++++ 15 files changed, 1260 insertions(+), 47 deletions(-) create mode 100644 lib/ui/auth-menu-flow.ts create mode 100644 lib/ui/auth-menu-runner.ts create mode 100644 lib/ui/auth-menu.ts create mode 100644 lib/ui/codex-quota-report.ts create mode 100644 lib/ui/tty/confirm.ts create mode 100644 lib/ui/tty/select.ts create mode 100644 test/auth-menu-flow.test.ts create mode 100644 test/auth-menu-runner.test.ts create mode 100644 test/auth-menu.test.ts create mode 100644 test/codex-quota-report.test.ts create mode 100644 test/tty-confirm.test.ts create mode 100644 test/tty-select.test.ts diff --git a/index.ts b/index.ts index c3708cd..838742b 100644 --- a/index.ts +++ b/index.ts @@ -70,6 +70,9 @@ import { getHealthTracker, getTokenTracker } from "./lib/rotation.js"; import { RateLimitTracker } from "./lib/rate-limit.js"; import { codexStatus, type CodexRateLimitSnapshot } from "./lib/codex-status.js"; import { renderObsidianDashboard } from "./lib/codex-status-ui.js"; +import { renderQuotaReport } from "./lib/ui/codex-quota-report.js"; +import { runAuthMenuOnce } from "./lib/ui/auth-menu-runner.js"; +import type { AuthMenuAccount } from "./lib/ui/auth-menu.js"; import { ProactiveRefreshQueue, createRefreshScheduler, @@ -649,6 +652,103 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { enabled: account.enabled, })); + const buildAuthMenuAccounts = ( + accounts: ReturnType, + activeIndex: number, + ): AuthMenuAccount[] => + accounts.map((account) => ({ + index: account.index, + accountId: account.accountId, + email: account.email, + plan: account.plan, + enabled: account.enabled, + lastUsed: account.lastUsed, + rateLimitResetTimes: account.rateLimitResetTimes, + coolingDownUntil: account.coolingDownUntil, + cooldownReason: account.cooldownReason, + isActive: account.index === activeIndex, + })); + + const runInteractiveAuthMenu = async (options: { allowExit: boolean }): Promise<"add" | "exit"> => { + while (true) { + const accountManager = await AccountManager.loadFromDisk(); + const accounts = accountManager.getAccountsSnapshot(); + const activeIndex = accountManager.getActiveIndexForFamily(DEFAULT_MODEL_FAMILY); + const menuAccounts = buildAuthMenuAccounts(accounts, activeIndex); + const now = Date.now(); + + const result = await runAuthMenuOnce({ + accounts: menuAccounts, + now, + input: process.stdin, + output: process.stdout, + handlers: { + onCheckQuotas: async () => { + await Promise.all( + accounts.map(async (acc, index) => { + if (acc.enabled === false) return; + const live = accountManager.getAccountByIndex(index); + if (!live) return; + const auth = accountManager.toAuthDetails(live); + if (auth.access && auth.expires > Date.now()) { + await codexStatus.fetchFromBackend(live, auth.access); + } + }), + ); + const snapshots = await codexStatus.getAllSnapshots(); + const report = renderQuotaReport(menuAccounts, snapshots, Date.now()); + process.stdout.write(report.join("\n") + "\n"); + }, + onConfigureModels: async () => { + process.stdout.write( + "Edit your opencode.jsonc (or opencode.json) to configure models.\n", + ); + }, + onDeleteAll: async () => { + await updateStorageWithLock(() => createEmptyStorage()); + }, + onToggleAccount: async (account) => { + await updateStorageWithLock((current) => + toggleAccountFromStorage(current, account), + ); + }, + onRefreshAccount: async (account) => { + const live = accountManager.getAccountByIndex(account.index); + if (!live || live.enabled === false) return; + const refreshed = await accountManager.refreshAccountWithFallback(live); + if (refreshed.type === "success") { + if (refreshed.headers) { + await codexStatus.updateFromHeaders( + live, + Object.fromEntries(refreshed.headers.entries()), + ); + } + const refreshedAuth = { + type: "oauth" as const, + access: refreshed.access, + refresh: refreshed.refresh, + expires: refreshed.expires, + }; + accountManager.updateFromAuth(live, refreshedAuth); + await accountManager.saveToDisk(); + } + }, + onDeleteAccount: async (account) => { + await updateStorageWithLock((current) => + removeAccountFromStorage(current, account), + ); + }, + }, + }); + + if (result === "add") return "add"; + if (result === "exit") { + if (options.allowExit) return "exit"; + continue; + } + } + }; + const storedAccountsForMethods = await loadAccounts(); const hasStoredAccounts = (storedAccountsForMethods?.accounts.length ?? 0) > 0; @@ -661,35 +761,39 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (inputs) { let existingStorage = await loadAccounts(); if (existingStorage?.accounts?.length) { - while (true) { - const existingLabels = buildExistingAccountLabels(existingStorage); - const mode = await promptLoginMode(existingLabels); - - if (mode === "manage") { - const action = await promptManageAccounts(existingLabels); - if (!action) { + if (process.stdin.isTTY && process.stdout.isTTY) { + await runInteractiveAuthMenu({ allowExit: false }); + } else { + while (true) { + const existingLabels = buildExistingAccountLabels(existingStorage); + const mode = await promptLoginMode(existingLabels); + + if (mode === "manage") { + const action = await promptManageAccounts(existingLabels); + if (!action) { + continue; + } + + if (action.action === "toggle") { + existingStorage = await updateStorageWithLock((current) => + toggleAccountFromStorage(current, action.target), + ); + } else { + existingStorage = await updateStorageWithLock((current) => + removeAccountFromStorage(current, action.target), + ); + } + + if (existingStorage.accounts.length === 0) { + replaceExisting = true; + break; + } continue; } - if (action.action === "toggle") { - existingStorage = await updateStorageWithLock((current) => - toggleAccountFromStorage(current, action.target), - ); - } else { - existingStorage = await updateStorageWithLock((current) => - removeAccountFromStorage(current, action.target), - ); - } - - if (existingStorage.accounts.length === 0) { - replaceExisting = true; - break; - } - continue; + replaceExisting = mode === "fresh"; + break; } - - replaceExisting = mode === "fresh"; - break; } } } @@ -888,35 +992,46 @@ async fetch(input: Request | string | URL, init?: RequestInit): Promise + !new Set(["codex-status", "codex-switch-accounts", "codex-toggle-account", "codex-remove-account"]).has( + toolName, + ), + ); + if (!cfg.experimental.primary_tools.includes("codex-auth")) { + cfg.experimental.primary_tools.push("codex-auth"); } }, tool: { - "codex-status": tool({ - description: "List all configured OpenAI Codex accounts and their current rate limits.", - args: {}, + "codex-auth": tool({ + description: "Open the interactive Codex auth menu.", + args: {}, + async execute() { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return "Interactive auth menu requires a TTY. Run `opencode auth login`."; + } + const result = await runInteractiveAuthMenu({ allowExit: true }); + if (result === "add") { + return "Add accounts with `opencode auth login`."; + } + return "Done."; + }, + }), + "codex-status": tool({ + description: "List all configured OpenAI Codex accounts and their current rate limits.", + args: {}, async execute() { configureStorageForCurrentCwd(); const accountManager = await AccountManager.loadFromDisk(); diff --git a/lib/ui/auth-menu-flow.ts b/lib/ui/auth-menu-flow.ts new file mode 100644 index 0000000..e618475 --- /dev/null +++ b/lib/ui/auth-menu-flow.ts @@ -0,0 +1,61 @@ +import type { AuthMenuAction, AuthMenuAccount, AccountAction } from "./auth-menu.js"; +import { buildAccountActionItems, buildAuthMenuItems, buildAccountSelectItems } from "./auth-menu.js"; +import { runSelect } from "./tty/select.js"; + +type SelectContext = { + input?: NodeJS.ReadStream; + output?: NodeJS.WriteStream; +}; + +export async function chooseAuthMenuAction( + args: SelectContext & { + accounts: AuthMenuAccount[]; + now?: number; + }, +): Promise { + const items = buildAuthMenuItems(args.accounts, args.now); + const selected = await runSelect({ + title: "Manage accounts", + subtitle: "Select account", + items, + input: args.input, + output: args.output, + useColor: false, + }); + return selected?.value ?? null; +} + +export async function chooseAccountAction( + args: SelectContext & { + account: AuthMenuAccount; + }, +): Promise { + const items = buildAccountActionItems(args.account); + const selected = await runSelect({ + title: "Account options", + subtitle: "Select action", + items, + input: args.input, + output: args.output, + useColor: false, + }); + return selected?.value ?? null; +} + +export async function chooseAccountFromList( + args: SelectContext & { + accounts: AuthMenuAccount[]; + now?: number; + }, +): Promise { + const items = buildAccountSelectItems(args.accounts, args.now); + const selected = await runSelect({ + title: "Manage accounts", + subtitle: "Select account", + items, + input: args.input, + output: args.output, + useColor: false, + }); + return selected?.value ?? null; +} diff --git a/lib/ui/auth-menu-runner.ts b/lib/ui/auth-menu-runner.ts new file mode 100644 index 0000000..98539c0 --- /dev/null +++ b/lib/ui/auth-menu-runner.ts @@ -0,0 +1,97 @@ +import type { AuthMenuAccount } from "./auth-menu.js"; +import { chooseAccountAction, chooseAccountFromList, chooseAuthMenuAction } from "./auth-menu-flow.js"; +import { runConfirm } from "./tty/confirm.js"; + +export type AuthMenuHandlers = { + onCheckQuotas: () => Promise; + onConfigureModels: () => Promise; + onDeleteAll: () => Promise; + onToggleAccount: (account: AuthMenuAccount) => Promise; + onRefreshAccount: (account: AuthMenuAccount) => Promise; + onDeleteAccount: (account: AuthMenuAccount) => Promise; +}; + +export type AuthMenuResult = "add" | "continue" | "exit"; + +export async function runAuthMenuOnce(args: { + accounts: AuthMenuAccount[]; + handlers: AuthMenuHandlers; + input?: NodeJS.ReadStream; + output?: NodeJS.WriteStream; + now?: number; +}): Promise { + const action = await chooseAuthMenuAction({ + accounts: args.accounts, + input: args.input, + output: args.output, + now: args.now, + }); + + if (!action) return "exit"; + + if (action.type === "add") return "add"; + if (action.type === "check-quotas") { + await args.handlers.onCheckQuotas(); + return "continue"; + } + if (action.type === "configure-models") { + await args.handlers.onConfigureModels(); + return "continue"; + } + if (action.type === "delete-all") { + const confirm = await runConfirm({ + title: "Delete accounts", + message: "Delete all accounts?", + input: args.input, + output: args.output, + useColor: false, + }); + if (confirm) { + await args.handlers.onDeleteAll(); + } + return "continue"; + } + + const account = + action.type === "select-account" + ? action.account + : await chooseAccountFromList({ + accounts: args.accounts, + input: args.input, + output: args.output, + now: args.now, + }); + if (!account) return "continue"; + + const accountAction = await chooseAccountAction({ + account, + input: args.input, + output: args.output, + }); + if (!accountAction || accountAction === "back") return "continue"; + if (accountAction === "toggle") { + await args.handlers.onToggleAccount(account); + return "continue"; + } + if (accountAction === "refresh") { + if (account.enabled !== false) { + await args.handlers.onRefreshAccount(account); + } + return "continue"; + } + if (accountAction === "delete") { + const confirm = await runConfirm({ + title: "Delete account", + message: `Delete ${account.email ?? "this account"}?`, + input: args.input, + output: args.output, + useColor: false, + }); + if (confirm) { + await args.handlers.onDeleteAccount(account); + } + return "continue"; + } + + return "continue"; +} diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts new file mode 100644 index 0000000..27d27d0 --- /dev/null +++ b/lib/ui/auth-menu.ts @@ -0,0 +1,122 @@ +import { formatAccountLabel } from "../accounts.js"; +import type { RateLimitStateV3 } from "../types.js"; +import type { SelectItem } from "./tty/select.js"; + +export type AuthMenuAction = + | { type: "add" } + | { type: "check-quotas" } + | { type: "manage" } + | { type: "configure-models" } + | { type: "select-account"; account: AuthMenuAccount } + | { type: "delete-all" }; + +export type AccountAction = "back" | "toggle" | "refresh" | "delete"; + +export type AuthMenuAccount = { + index: number; + email?: string; + plan?: string; + accountId?: string; + enabled?: boolean; + lastUsed?: number; + rateLimitResetTimes?: RateLimitStateV3; + coolingDownUntil?: number; + cooldownReason?: "auth-failure"; + isActive?: boolean; +}; + +export function formatLastUsedHint(lastUsed: number | undefined, now = Date.now()): string { + if (!lastUsed || !Number.isFinite(lastUsed) || lastUsed <= 0) return ""; + const diff = Math.max(0, now - lastUsed); + const dayMs = 24 * 60 * 60 * 1000; + if (diff < dayMs) return "used today"; + if (diff < 2 * dayMs) return "used yesterday"; + const days = Math.floor(diff / dayMs); + return `used ${days}d ago`; +} + +function isRateLimited(rateLimitResetTimes: RateLimitStateV3 | undefined, now: number): boolean { + if (!rateLimitResetTimes) return false; + return Object.values(rateLimitResetTimes).some((resetAt) => + typeof resetAt === "number" && Number.isFinite(resetAt) && resetAt > now, + ); +} + +export function getAccountBadge(account: AuthMenuAccount, now = Date.now()): string { + if (account.enabled === false) return "[disabled]"; + if (isRateLimited(account.rateLimitResetTimes, now)) return "[rate-limited]"; + if (account.isActive) return "[active]"; + return ""; +} + +export function buildAuthMenuItems( + accounts: AuthMenuAccount[], + now = Date.now(), +): Array> { + const items: Array> = [ + { label: "Add new account", value: { type: "add" } }, + { label: "Check quotas", value: { type: "check-quotas" } }, + { label: "Manage accounts (enable/disable)", value: { type: "manage" } }, + { label: "Configure models in opencode.json", value: { type: "configure-models" } }, + ]; + + for (const account of accounts) { + const baseLabel = formatAccountLabel( + { email: account.email, plan: account.plan, accountId: account.accountId }, + account.index, + ); + const badge = getAccountBadge(account, now); + const label = badge ? `${baseLabel} ${badge}` : baseLabel; + const hint = formatLastUsedHint(account.lastUsed, now); + items.push({ + label, + hint: hint || undefined, + value: { type: "select-account", account }, + }); + } + + if (accounts.length > 0) { + items.push({ label: "Delete all accounts", value: { type: "delete-all" } }); + } + + return items; +} + +export function buildAccountActionItems( + account: AuthMenuAccount, +): Array> { + const items: Array> = [ + { label: "Back", value: "back" }, + { + label: account.enabled === false ? "Enable account" : "Disable account", + value: "toggle", + }, + ]; + + if (account.enabled !== false) { + items.push({ label: "Refresh token", value: "refresh" }); + } + + items.push({ label: "Delete this account", value: "delete" }); + return items; +} + +export function buildAccountSelectItems( + accounts: AuthMenuAccount[], + now = Date.now(), +): Array> { + return accounts.map((account) => { + const baseLabel = formatAccountLabel( + { email: account.email, plan: account.plan, accountId: account.accountId }, + account.index, + ); + const badge = getAccountBadge(account, now); + const label = badge ? `${baseLabel} ${badge}` : baseLabel; + const hint = formatLastUsedHint(account.lastUsed, now); + return { + label, + hint: hint || undefined, + value: account, + }; + }); +} diff --git a/lib/ui/codex-quota-report.ts b/lib/ui/codex-quota-report.ts new file mode 100644 index 0000000..193fa9d --- /dev/null +++ b/lib/ui/codex-quota-report.ts @@ -0,0 +1,106 @@ +import { createHash } from "node:crypto"; + +import type { CodexRateLimitSnapshot } from "../codex-status.js"; + +type AccountLike = { + accountId?: string; + email?: string; + plan?: string; + refreshToken?: string; +}; + +const LINE = "=".repeat(52); +const BAR_WIDTH = 20; + +function getSnapshotKey(account: AccountLike): string | null { + if (account.accountId && account.email && account.plan) { + return `${account.accountId}|${account.email.toLowerCase()}|${account.plan}`; + } + if (account.refreshToken) { + return createHash("sha256").update(account.refreshToken).digest("hex"); + } + return null; +} + +function findSnapshot( + account: AccountLike, + snapshots: CodexRateLimitSnapshot[], +): CodexRateLimitSnapshot | undefined { + const key = getSnapshotKey(account); + if (key) { + const direct = snapshots.find((snapshot) => (snapshot as any).key === key); + if (direct) return direct; + } + + return snapshots.find( + (snapshot) => + snapshot.accountId === account.accountId && + snapshot.email?.toLowerCase() === account.email?.toLowerCase() && + snapshot.plan === account.plan, + ); +} + +function formatReset(resetAt: number, now: number): string { + if (!resetAt || resetAt <= now) return ""; + const date = new Date(resetAt); + const timeStr = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; + if (resetAt - now <= 24 * 60 * 60 * 1000) { + return ` (resets ${timeStr})`; + } + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + return ` (resets ${timeStr} ${date.getDate()} ${months[date.getMonth()]})`; +} + +function renderBar(usedPercent: number | undefined | null): { bar: string; percent: string } { + if (usedPercent === undefined || usedPercent === null || Number.isNaN(usedPercent)) { + const empty = "░".repeat(BAR_WIDTH); + return { bar: empty, percent: "???" }; + } + const left = Math.max(0, Math.min(100, Math.round(100 - usedPercent))); + const filled = Math.round((left / 100) * BAR_WIDTH); + const bar = "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled); + const percent = `${String(left).padStart(3, " ")}%`; + return { bar, percent }; +} + +export function renderQuotaReport( + accounts: AccountLike[], + snapshots: CodexRateLimitSnapshot[], + now = Date.now(), +): string[] { + const lines: string[] = ["Checking quotas for all accounts..."]; + for (const account of accounts) { + const email = account.email || account.accountId || "Unknown account"; + const snapshot = findSnapshot(account, snapshots); + lines.push(LINE); + lines.push(` ${email}`); + lines.push(LINE); + lines.push(" +- Codex CLI Quota"); + + const primary = renderBar(snapshot?.primary?.usedPercent); + const primaryReset = snapshot?.primary?.resetAt + ? formatReset(snapshot.primary.resetAt, now) + : primary.percent === "???" + ? " ???" + : ""; + lines.push(` | |- GPT-5 ${primary.bar} ${primary.percent}${primaryReset}`); + + const secondary = renderBar(snapshot?.secondary?.usedPercent); + const secondaryReset = snapshot?.secondary?.resetAt + ? formatReset(snapshot.secondary.resetAt, now) + : secondary.percent === "???" + ? " ???" + : ""; + lines.push(` | |- Weekly ${secondary.bar} ${secondary.percent}${secondaryReset}`); + + const creditInfo = snapshot?.credits; + const creditStr = creditInfo + ? creditInfo.unlimited + ? "unlimited" + : `${creditInfo.balance} credits` + : "0 credits"; + lines.push(` |---- Credits ${creditStr}`); + } + + return lines; +} diff --git a/lib/ui/tty/confirm.ts b/lib/ui/tty/confirm.ts new file mode 100644 index 0000000..f6be25c --- /dev/null +++ b/lib/ui/tty/confirm.ts @@ -0,0 +1,27 @@ +import { runSelect } from "./select.js"; + +export type ConfirmArgs = { + title: string; + message: string; + input?: NodeJS.ReadStream; + output?: NodeJS.WriteStream; + useColor?: boolean; +}; + +export async function runConfirm(args: ConfirmArgs): Promise { + const result = await runSelect({ + title: args.title, + subtitle: args.message, + items: [ + { label: "Yes", value: true }, + { label: "No", value: false }, + ], + input: args.input, + output: args.output, + initialIndex: 0, + useColor: args.useColor, + }); + + if (!result) return null; + return Boolean(result.value); +} diff --git a/lib/ui/tty/select.ts b/lib/ui/tty/select.ts new file mode 100644 index 0000000..c801d67 --- /dev/null +++ b/lib/ui/tty/select.ts @@ -0,0 +1,155 @@ +export type SelectKeyAction = "up" | "down" | "enter" | "cancel" | "unknown"; + +export type SelectItem = { + label: string; + value?: T; + hint?: string; +}; + +export type RenderSelectFrameArgs = { + title: string; + subtitle?: string; + items: Array>; + selectedIndex: number; + useColor?: boolean; +}; + +export type RunSelectArgs = { + title: string; + subtitle?: string; + items: Array>; + input?: NodeJS.ReadStream; + output?: NodeJS.WriteStream; + initialIndex?: number; + useColor?: boolean; +}; + +const ANSI = { + reset: "\x1b[0m", + dim: "\x1b[2m", + cyan: "\x1b[36m", + green: "\x1b[32m", +}; + +function colorize(text: string, code: string, useColor?: boolean): string { + return useColor ? `${code}${text}${ANSI.reset}` : text; +} + +export function renderSelectFrame(args: RenderSelectFrameArgs): string[] { + const lines: string[] = []; + const top = colorize("+", ANSI.dim, args.useColor); + const pipe = colorize("|", ANSI.dim, args.useColor); + lines.push(`${top} ${args.title}`); + lines.push(pipe); + if (args.subtitle) { + lines.push(`${pipe} ${args.subtitle}`); + } + if (args.items.length > 0) { + lines.push(pipe); + } + + args.items.forEach((item, index) => { + const marker = index === args.selectedIndex ? colorize(">", ANSI.green, args.useColor) : " "; + const hint = item.hint ? ` ${item.hint}` : ""; + lines.push(`${pipe} ${marker} ${item.label}${hint}`); + }); + + lines.push(`${pipe} ^/v to select, Enter: confirm`); + lines.push(top); + return lines; +} + +export function parseSelectKey(input: string): SelectKeyAction { + if (input === "\r" || input === "\n") return "enter"; + if (input === "\u001b") return "cancel"; + if (input === "\u0003") return "cancel"; + if (input === "\u001b[A" || input === "\u001bOA") return "up"; + if (input === "\u001b[B" || input === "\u001bOB") return "down"; + + if (input === "k" || input === "K") return "up"; + if (input === "j" || input === "J") return "down"; + + return "unknown"; +} + +export function moveSelectIndex(current: number, delta: number, size: number): number { + if (size <= 0) return 0; + const next = (current + delta) % size; + return next < 0 ? next + size : next; +} + +export async function runSelect(args: RunSelectArgs): Promise | null> { + const input = args.input ?? process.stdin; + const output = args.output ?? process.stdout; + if (!input.isTTY || !output.isTTY || args.items.length === 0) return null; + + let selectedIndex = Math.min( + Math.max(args.initialIndex ?? 0, 0), + Math.max(args.items.length - 1, 0), + ); + let resolved = false; + + const render = () => { + const lines = renderSelectFrame({ + title: args.title, + subtitle: args.subtitle, + items: args.items, + selectedIndex, + useColor: args.useColor, + }); + output.write("\x1b[2J\x1b[H"); + output.write(lines.join("\n") + "\n"); + }; + + const cleanup = () => { + if (resolved) return; + resolved = true; + input.off("data", onData); + input.pause(); + if (typeof input.setRawMode === "function") { + input.setRawMode(false); + } + }; + + const onData = (chunk: Buffer | string) => { + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + const action = parseSelectKey(text); + if (action === "up") { + selectedIndex = moveSelectIndex(selectedIndex, -1, args.items.length); + render(); + return; + } + if (action === "down") { + selectedIndex = moveSelectIndex(selectedIndex, 1, args.items.length); + render(); + return; + } + if (action === "enter") { + const selected = args.items[selectedIndex] ?? null; + cleanup(); + resolvePromise(selected); + return; + } + if (action === "cancel") { + cleanup(); + resolvePromise(null); + } + }; + + let resolvePromise: (value: SelectItem | null) => void = () => undefined; + const promise = new Promise | null>((resolve) => { + resolvePromise = resolve; + }); + + if (typeof input.setRawMode === "function") { + input.setRawMode(true); + } + input.resume(); + input.on("data", onData); + input.setEncoding?.("utf8"); + output.write("\x1b[?25l"); + render(); + const result = await promise; + output.write("\x1b[?25h"); + return result; +} diff --git a/test/auth-menu-flow.test.ts b/test/auth-menu-flow.test.ts new file mode 100644 index 0000000..5fc3533 --- /dev/null +++ b/test/auth-menu-flow.test.ts @@ -0,0 +1,89 @@ +import { readFileSync } from "node:fs"; +import { PassThrough } from "node:stream"; + +import { describe, it, expect, vi } from "vitest"; + +import { + chooseAuthMenuAction, + chooseAccountAction, + chooseAccountFromList, +} from "../lib/ui/auth-menu-flow.js"; + +const fixture = JSON.parse( + readFileSync(new URL("./fixtures/openai-codex-accounts.json", import.meta.url), "utf-8"), +) as { accounts: Array<{ accountId: string; email: string; plan: string; lastUsed: number }> }; + +describe("auth menu flow", () => { + function makeTty() { + const input = new PassThrough(); + const output = new PassThrough(); + (input as unknown as { isTTY: boolean }).isTTY = true; + (output as unknown as { isTTY: boolean }).isTTY = true; + (input as unknown as { setRawMode: (val: boolean) => void }).setRawMode = vi.fn(); + return { input, output }; + } + + it("selects a top-level action", async () => { + const { input, output } = makeTty(); + const resultPromise = chooseAuthMenuAction({ + accounts: [ + { + index: 0, + email: fixture.accounts[0]!.email, + plan: fixture.accounts[0]!.plan, + accountId: fixture.accounts[0]!.accountId, + lastUsed: fixture.accounts[0]!.lastUsed, + }, + ], + input, + output, + }); + + input.write("\u001b[B"); + input.write("\r"); + + const result = await resultPromise; + expect(result?.type).toBe("check-quotas"); + }); + + it("selects an account action", async () => { + const { input, output } = makeTty(); + const resultPromise = chooseAccountAction({ + account: { + index: 0, + email: fixture.accounts[0]!.email, + plan: fixture.accounts[0]!.plan, + enabled: true, + }, + input, + output, + }); + + input.write("\u001b[B"); + input.write("\r"); + + const result = await resultPromise; + expect(result).toBe("toggle"); + }); + + it("selects an account from list", async () => { + const { input, output } = makeTty(); + const resultPromise = chooseAccountFromList({ + accounts: [ + { + index: 0, + email: fixture.accounts[0]!.email, + plan: fixture.accounts[0]!.plan, + accountId: fixture.accounts[0]!.accountId, + lastUsed: fixture.accounts[0]!.lastUsed, + }, + ], + input, + output, + }); + + input.write("\r"); + const result = await resultPromise; + expect(result?.email).toBe(fixture.accounts[0]!.email); + }); +}); diff --git a/test/auth-menu-runner.test.ts b/test/auth-menu-runner.test.ts new file mode 100644 index 0000000..58c771e --- /dev/null +++ b/test/auth-menu-runner.test.ts @@ -0,0 +1,133 @@ +import { readFileSync } from "node:fs"; +import { PassThrough } from "node:stream"; + +import { describe, it, expect, vi } from "vitest"; + +import { runAuthMenuOnce } from "../lib/ui/auth-menu-runner.js"; + +const fixture = JSON.parse( + readFileSync(new URL("./fixtures/openai-codex-accounts.json", import.meta.url), "utf-8"), +) as { accounts: Array<{ accountId: string; email: string; plan: string; lastUsed: number }> }; + +async function tick(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +function makeTty() { + const input = new PassThrough(); + const output = new PassThrough(); + (input as unknown as { isTTY: boolean }).isTTY = true; + (output as unknown as { isTTY: boolean }).isTTY = true; + (input as unknown as { setRawMode: (val: boolean) => void }).setRawMode = vi.fn(); + return { input, output }; +} + +describe("auth menu runner", () => { + it("returns add when selecting add new account", async () => { + const { input, output } = makeTty(); + const resultPromise = runAuthMenuOnce({ + accounts: [ + { + index: 0, + email: fixture.accounts[0]!.email, + plan: fixture.accounts[0]!.plan, + accountId: fixture.accounts[0]!.accountId, + lastUsed: fixture.accounts[0]!.lastUsed, + }, + ], + handlers: { + onCheckQuotas: vi.fn(), + onConfigureModels: vi.fn(), + onDeleteAll: vi.fn(), + onToggleAccount: vi.fn(), + onRefreshAccount: vi.fn(), + onDeleteAccount: vi.fn(), + }, + input, + output, + }); + + await tick(); + input.write("\r"); + + const result = await resultPromise; + expect(result).toBe("add"); + }); + + it("invokes quota handler and continues", async () => { + const { input, output } = makeTty(); + const onCheckQuotas = vi.fn(); + const resultPromise = runAuthMenuOnce({ + accounts: [ + { + index: 0, + email: fixture.accounts[0]!.email, + plan: fixture.accounts[0]!.plan, + accountId: fixture.accounts[0]!.accountId, + lastUsed: fixture.accounts[0]!.lastUsed, + }, + ], + handlers: { + onCheckQuotas, + onConfigureModels: vi.fn(), + onDeleteAll: vi.fn(), + onToggleAccount: vi.fn(), + onRefreshAccount: vi.fn(), + onDeleteAccount: vi.fn(), + }, + input, + output, + }); + + await tick(); + input.write("\u001b[B"); + input.write("\r"); + + const result = await resultPromise; + expect(result).toBe("continue"); + expect(onCheckQuotas).toHaveBeenCalledTimes(1); + }); + + it("routes account action to handler", async () => { + const { input, output } = makeTty(); + const onToggleAccount = vi.fn(); + const resultPromise = runAuthMenuOnce({ + accounts: [ + { + index: 0, + email: fixture.accounts[0]!.email, + plan: fixture.accounts[0]!.plan, + accountId: fixture.accounts[0]!.accountId, + lastUsed: fixture.accounts[0]!.lastUsed, + enabled: true, + }, + ], + handlers: { + onCheckQuotas: vi.fn(), + onConfigureModels: vi.fn(), + onDeleteAll: vi.fn(), + onToggleAccount, + onRefreshAccount: vi.fn(), + onDeleteAccount: vi.fn(), + }, + input, + output, + }); + + await tick(); + input.write("\u001b[B"); + input.write("\u001b[B"); + input.write("\r"); + + await tick(); + input.write("\r"); + + await tick(); + input.write("\u001b[B"); + input.write("\r"); + + const result = await resultPromise; + expect(result).toBe("continue"); + expect(onToggleAccount).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/auth-menu.test.ts b/test/auth-menu.test.ts new file mode 100644 index 0000000..7afe688 --- /dev/null +++ b/test/auth-menu.test.ts @@ -0,0 +1,118 @@ +import { readFileSync } from "node:fs"; + +import { describe, it, expect } from "vitest"; + +import { + buildAuthMenuItems, + buildAccountActionItems, + buildAccountSelectItems, + formatLastUsedHint, + getAccountBadge, +} from "../lib/ui/auth-menu.js"; + +const fixture = JSON.parse( + readFileSync(new URL("./fixtures/openai-codex-accounts.json", import.meta.url), "utf-8"), +) as { + accounts: Array<{ + accountId: string; + email: string; + plan: string; + refreshToken: string; + lastUsed: number; + enabled?: boolean; + rateLimitResetTimes?: Record; + }>; +}; + +describe("auth menu helpers", () => { + it("formats last-used hints", () => { + const now = Date.now(); + expect(formatLastUsedHint(now, now)).toBe("used today"); + expect(formatLastUsedHint(now - 24 * 60 * 60 * 1000, now)).toBe("used yesterday"); + expect(formatLastUsedHint(now - 3 * 24 * 60 * 60 * 1000, now)).toBe("used 3d ago"); + expect(formatLastUsedHint(0, now)).toBe(""); + }); + + it("builds badges for status", () => { + const now = fixture.accounts[0]!.lastUsed; + expect(getAccountBadge({ enabled: false }, now)).toBe("[disabled]"); + expect(getAccountBadge({ enabled: true, isActive: true }, now)).toBe("[active]"); + expect( + getAccountBadge( + { + enabled: true, + rateLimitResetTimes: { codex: now + 60_000 }, + }, + now, + ), + ).toBe("[rate-limited]"); + }); + + it("builds auth menu items with account labels", () => { + const account = fixture.accounts[0]!; + const now = account.lastUsed; + const items = buildAuthMenuItems( + [ + { + index: 0, + email: account.email, + plan: account.plan, + accountId: account.accountId, + lastUsed: account.lastUsed, + isActive: true, + }, + ], + now, + ); + + expect(items[0]?.label).toBe("Add new account"); + expect(items[1]?.label).toBe("Check quotas"); + const accountItem = items.find((item) => item.label.includes(account.email)); + expect(accountItem).toBeTruthy(); + expect(accountItem!.label).toContain("[active]"); + expect(accountItem!.hint).toBe("used today"); + }); + + it("builds account actions and hides refresh when disabled", () => { + const account = fixture.accounts[0]!; + const enabled = buildAccountActionItems({ + index: 0, + email: account.email, + plan: account.plan, + enabled: true, + }); + expect(enabled.map((item) => item.label)).toContain("Disable account"); + expect(enabled.map((item) => item.label)).toContain("Refresh token"); + + const disabled = buildAccountActionItems({ + index: 0, + email: account.email, + plan: account.plan, + enabled: false, + }); + expect(disabled.map((item) => item.label)).toContain("Enable account"); + expect(disabled.map((item) => item.label)).not.toContain("Refresh token"); + }); + + it("builds account-only select items", () => { + const account = fixture.accounts[0]!; + const now = account.lastUsed; + const items = buildAccountSelectItems( + [ + { + index: 0, + email: account.email, + plan: account.plan, + accountId: account.accountId, + lastUsed: account.lastUsed, + isActive: true, + }, + ], + now, + ); + expect(items).toHaveLength(1); + expect(items[0]?.label).toContain(account.email); + expect(items[0]?.label).toContain("[active]"); + expect(items[0]?.hint).toBe("used today"); + }); +}); diff --git a/test/codex-quota-report.test.ts b/test/codex-quota-report.test.ts new file mode 100644 index 0000000..a7368fa --- /dev/null +++ b/test/codex-quota-report.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; + +import { describe, it, expect } from "vitest"; + +import { renderQuotaReport } from "../lib/ui/codex-quota-report.js"; + +describe("codex quota report", () => { + it("renders account blocks with aligned percent", () => { + const accountsFixture = JSON.parse( + readFileSync(new URL("./fixtures/openai-codex-accounts.json", import.meta.url), "utf-8"), + ) as { accounts: Array<{ accountId: string; email: string; plan: string; refreshToken: string }> }; + const snapshotEntries = JSON.parse( + readFileSync(new URL("./fixtures/codex-status-snapshots.json", import.meta.url), "utf-8"), + ) as Array<[string, any]>; + const snapshots = snapshotEntries.map((entry) => entry[1]); + const account = accountsFixture.accounts[0]!; + const now = snapshots[0]?.updatedAt ?? Date.now(); + + const output = renderQuotaReport([account], snapshots, now).join("\n"); + expect(output).toContain("Checking quotas for all accounts..."); + expect(output).toContain(account.email); + expect(output).toContain("Codex CLI Quota"); + expect(output).toContain("GPT-5"); + expect(output).toContain("Weekly"); + + // Percents aligned to 3 characters. + expect(output).toMatch(/\s\d{1,3}%/); + // Bars use block characters. + expect(output).toMatch(/[█░]{20}/); + }); +}); diff --git a/test/plugin-config-hook.test.ts b/test/plugin-config-hook.test.ts index 4c2c2e9..2f157f5 100644 --- a/test/plugin-config-hook.test.ts +++ b/test/plugin-config-hook.test.ts @@ -422,4 +422,29 @@ describe("OpenAIAuthPlugin config hook", () => { rmSync(root, { recursive: true, force: true }); } }); + + it("registers codex-auth command and removes legacy codex commands", async () => { + const root = mkdtempSync(join(tmpdir(), "opencode-config-hook-codex-auth-")); + process.env.XDG_CONFIG_HOME = root; + + try { + const plugin = await OpenAIAuthPlugin({ + client: { + tui: { showToast: vi.fn() }, + auth: { set: vi.fn() }, + } as any, + } as any); + + const cfg: any = { provider: { openai: {} }, experimental: {} }; + await (plugin as any).config(cfg); + + expect(cfg.command["codex-auth"]).toBeDefined(); + expect(cfg.command["codex-status"]).toBeUndefined(); + expect(cfg.command["codex-switch-accounts"]).toBeUndefined(); + expect(cfg.command["codex-toggle-account"]).toBeUndefined(); + expect(cfg.command["codex-remove-account"]).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/test/plugin-loader.test.ts b/test/plugin-loader.test.ts index 71941eb..c0a68e5 100644 --- a/test/plugin-loader.test.ts +++ b/test/plugin-loader.test.ts @@ -165,6 +165,25 @@ describe("OpenAIAuthPlugin loader", () => { } }); + it("codex-auth tool reports non-tty requirement", async () => { + const root = mkdtempSync(join(tmpdir(), "opencode-codex-auth-")); + process.env.XDG_CONFIG_HOME = root; + const originalIsTTY = process.stdin.isTTY; + try { + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + const client = { + tui: { showToast: vi.fn() }, + auth: { set: vi.fn() }, + }; + const plugin = await OpenAIAuthPlugin({ client: client as any } as any); + const result = await (plugin as any).tool["codex-auth"].execute({}); + expect(result).toContain("TTY"); + } finally { + Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); + rmSync(root, { recursive: true, force: true }); + } + }); + it("codex-status highlights active account for default family", async () => { const root = mkdtempSync(join(tmpdir(), "opencode-status-active-")); process.env.XDG_CONFIG_HOME = root; diff --git a/test/tty-confirm.test.ts b/test/tty-confirm.test.ts new file mode 100644 index 0000000..eceb7e2 --- /dev/null +++ b/test/tty-confirm.test.ts @@ -0,0 +1,27 @@ +import { PassThrough } from "node:stream"; + +import { describe, it, expect, vi } from "vitest"; + +import { runConfirm } from "../lib/ui/tty/confirm.js"; + +describe("tty confirm", () => { + it("returns true when confirming", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + (input as unknown as { isTTY: boolean }).isTTY = true; + (output as unknown as { isTTY: boolean }).isTTY = true; + (input as unknown as { setRawMode: (val: boolean) => void }).setRawMode = vi.fn(); + + const resultPromise = runConfirm({ + title: "Delete account", + message: "Delete user?", + input, + output, + useColor: false, + }); + + input.write("\r"); + const result = await resultPromise; + expect(result).toBe(true); + }); +}); diff --git a/test/tty-select.test.ts b/test/tty-select.test.ts new file mode 100644 index 0000000..ddfa085 --- /dev/null +++ b/test/tty-select.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from "vitest"; + +import { PassThrough } from "node:stream"; + +import { renderSelectFrame, parseSelectKey, moveSelectIndex, runSelect } from "../lib/ui/tty/select.js"; + +describe("tty select", () => { + it("renders ASCII frame and selection marker", () => { + const lines = renderSelectFrame({ + title: "Manage accounts", + subtitle: "Select account", + items: [{ label: "Add new account" }, { label: "Check quotas", hint: "used today" }], + selectedIndex: 0, + useColor: false, + }); + + const output = lines.join("\n"); + expect(output).toContain("+ Manage accounts"); + expect(output).toContain("| Select account"); + expect(output).toContain("| > Add new account"); + expect(output).toContain("| Check quotas used today"); + expect(output).toContain("^/v to select"); + }); + + it("parses arrow and vim keys", () => { + expect(parseSelectKey("\u001b[A")).toBe("up"); + expect(parseSelectKey("\u001b[B")).toBe("down"); + expect(parseSelectKey("k")).toBe("up"); + expect(parseSelectKey("j")).toBe("down"); + expect(parseSelectKey("\r")).toBe("enter"); + expect(parseSelectKey("\u001b")).toBe("cancel"); + }); + + it("wraps selection index", () => { + expect(moveSelectIndex(0, -1, 3)).toBe(2); + expect(moveSelectIndex(2, 1, 3)).toBe(0); + // No movement when list is empty. + expect(moveSelectIndex(0, 1, 0)).toBe(0); + }); + + it("adds ANSI colors when enabled", () => { + const lines = renderSelectFrame({ + title: "Manage accounts", + items: [{ label: "Add new account" }], + selectedIndex: 0, + useColor: true, + }); + expect(lines.join("\n")).toContain("\u001b["); + }); + + it("uses ASCII hint line", () => { + const lines = renderSelectFrame({ + title: "Manage accounts", + items: [{ label: "Add new account" }], + selectedIndex: 0, + useColor: false, + }); + expect(lines.join("\n")).toContain("^/v to select, Enter: confirm"); + }); + + it("selects item using key input", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + (input as unknown as { isTTY: boolean }).isTTY = true; + (output as unknown as { isTTY: boolean }).isTTY = true; + const setRawMode = vi.fn(); + (input as unknown as { setRawMode: (val: boolean) => void }).setRawMode = setRawMode; + + const resultPromise = runSelect({ + title: "Select", + items: [ + { label: "One", value: "one" }, + { label: "Two", value: "two" }, + ], + input, + output, + useColor: false, + }); + + input.write("\u001b[B"); + input.write("\r"); + + const result = await resultPromise; + expect(result?.value).toBe("two"); + expect(setRawMode).toHaveBeenCalledWith(true); + expect(setRawMode).toHaveBeenCalledWith(false); + }); +});