From b85ac454047644cfa00e94abce8a170588bd168c Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:28:36 +0800 Subject: [PATCH 1/4] feat: add forecast explain output --- lib/codex-manager.ts | 1355 ++++++++++++++++++++++---------- lib/forecast.ts | 135 +++- test/codex-manager-cli.test.ts | 627 ++++++++++----- 3 files changed, 1468 insertions(+), 649 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index b80f1204..bf00c254 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1,16 +1,7 @@ -import { createInterface } from "node:readline/promises"; -import { stdin as input, stdout as output } from "node:process"; -import { promises as fs, existsSync } from "node:fs"; +import { existsSync, promises as fs } from "node:fs"; import { dirname, resolve } from "node:path"; -import { - createAuthorizationFlow, - exchangeAuthorizationCode, - parseAuthorizationInput, - REDIRECT_URI, -} from "./auth/auth.js"; -import { startLocalOAuthServer } from "./auth/server.js"; -import { copyTextToClipboard, isBrowserLaunchSuppressed, openBrowserUrl } from "./auth/browser.js"; -import { promptAddAnotherAccount, promptLoginMode, type ExistingAccountInfo } from "./cli.js"; +import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; import { extractAccountEmail, extractAccountId, @@ -23,68 +14,90 @@ import { selectBestAccountCandidate, shouldUpdateAccountIdFromToken, } from "./accounts.js"; +import { + createAuthorizationFlow, + exchangeAuthorizationCode, + parseAuthorizationInput, + REDIRECT_URI, +} from "./auth/auth.js"; +import { + copyTextToClipboard, + isBrowserLaunchSuppressed, + openBrowserUrl, +} from "./auth/browser.js"; +import { startLocalOAuthServer } from "./auth/server.js"; +import { + type ExistingAccountInfo, + promptAddAnotherAccount, + promptLoginMode, +} from "./cli.js"; +import { + getCodexCliAuthPath, + getCodexCliConfigPath, + loadCodexCliState, +} from "./codex-cli/state.js"; +import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; +import { + applyUiThemeFromDashboardSettings, + configureUnifiedSettings, + resolveMenuLayoutMode, +} from "./codex-manager/settings-hub.js"; import { ACCOUNT_LIMITS } from "./constants.js"; import { - loadDashboardDisplaySettings, - DEFAULT_DASHBOARD_DISPLAY_SETTINGS, - type DashboardDisplaySettings, type DashboardAccountSortMode, + type DashboardDisplaySettings, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + loadDashboardDisplaySettings, } from "./dashboard-settings.js"; import { + buildForecastExplanation, evaluateForecastAccounts, + type ForecastAccountResult, isHardRefreshFailure, recommendForecastAccount, summarizeForecast, - type ForecastAccountResult, } from "./forecast.js"; import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; -import { - fetchCodexQuotaSnapshot, - formatQuotaSnapshotLine, - type CodexQuotaSnapshot, -} from "./quota-probe.js"; -import { queuedRefresh } from "./refresh-queue.js"; import { loadQuotaCache, - saveQuotaCache, type QuotaCacheData, type QuotaCacheEntry, + saveQuotaCache, } from "./quota-cache.js"; import { + type CodexQuotaSnapshot, + fetchCodexQuotaSnapshot, + formatQuotaSnapshotLine, +} from "./quota-probe.js"; +import { queuedRefresh } from "./refresh-queue.js"; +import { + type AccountMetadataV3, + type AccountStorageV3, clearAccounts, + type FlaggedAccountMetadataV1, findMatchingAccountIndex, formatStorageErrorHint, getNamedBackups, getStoragePath, - loadFlaggedAccounts, loadAccounts, - StorageError, + loadFlaggedAccounts, type NamedBackupSummary, restoreAccountsFromBackup, - saveFlaggedAccounts, + StorageError, saveAccounts, + saveFlaggedAccounts, setStoragePath, withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, - type AccountMetadataV3, - type AccountStorageV3, - type FlaggedAccountMetadataV1, } from "./storage.js"; import type { AccountIdSource, TokenFailure, TokenResult } from "./types.js"; -import { - getCodexCliAuthPath, - getCodexCliConfigPath, - loadCodexCliState, -} from "./codex-cli/state.js"; -import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; -import { UI_COPY } from "./ui/copy.js"; import { confirm } from "./ui/confirm.js"; +import { UI_COPY } from "./ui/copy.js"; import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; import { getUiRuntimeOptions } from "./ui/runtime.js"; -import { select, type MenuItem } from "./ui/select.js"; -import { applyUiThemeFromDashboardSettings, configureUnifiedSettings, resolveMenuLayoutMode } from "./codex-manager/settings-hub.js"; +import { type MenuItem, select } from "./ui/select.js"; type TokenSuccess = Extract; type TokenSuccessWithAccount = TokenSuccess & { @@ -105,15 +118,16 @@ function stylePromptText(text: string, tone: PromptTone): string { const mapped = tone === "accent" ? "primary" : tone; return paintUiText(ui, text, mapped); } - const legacyCode = tone === "accent" - ? ANSI.green - : tone === "success" + const legacyCode = + tone === "accent" ? ANSI.green - : tone === "warning" - ? ANSI.yellow - : tone === "danger" - ? ANSI.red - : ANSI.dim; + : tone === "success" + ? ANSI.green + : tone === "warning" + ? ANSI.yellow + : tone === "danger" + ? ANSI.red + : ANSI.dim; return `${legacyCode}${text}${ANSI.reset}`; } @@ -131,14 +145,17 @@ function extractErrorMessageFromPayload(payload: unknown): string | undefined { if (!payload || typeof payload !== "object") return undefined; const record = payload as Record; - const directMessage = typeof record.message === "string" - ? collapseWhitespace(record.message) - : ""; - const directCode = typeof record.code === "string" - ? collapseWhitespace(record.code) - : ""; + const directMessage = + typeof record.message === "string" + ? collapseWhitespace(record.message) + : ""; + const directCode = + typeof record.code === "string" ? collapseWhitespace(record.code) : ""; if (directMessage) { - if (directCode && !directMessage.toLowerCase().includes(directCode.toLowerCase())) { + if ( + directCode && + !directMessage.toLowerCase().includes(directCode.toLowerCase()) + ) { return `${directMessage} [${directCode}]`; } return directMessage; @@ -181,7 +198,8 @@ function normalizeFailureDetail( const raw = message?.trim() || reasonLabel || "refresh failed"; const structured = parseStructuredErrorMessage(raw); const normalized = collapseWhitespace(structured ?? raw); - const bounded = normalized.length > 260 ? `${normalized.slice(0, 257)}...` : normalized; + const bounded = + normalized.length > 260 ? `${normalized.slice(0, 257)}...` : normalized; return bounded.length > 0 ? bounded : "refresh failed"; } @@ -194,14 +212,19 @@ function joinStyledSegments(parts: string[]): string { function formatResultSummary( segments: ReadonlyArray<{ text: string; tone: PromptTone }>, ): string { - const rendered = segments.map((segment) => stylePromptText(segment.text, segment.tone)); + const rendered = segments.map((segment) => + stylePromptText(segment.text, segment.tone), + ); return `${stylePromptText("Result:", "accent")} ${joinStyledSegments(rendered)}`; } function styleQuotaSummary(summary: string): string { const normalized = collapseWhitespace(summary); if (!normalized) return stylePromptText(summary, "muted"); - const segments = normalized.split("|").map((segment) => segment.trim()).filter(Boolean); + const segments = normalized + .split("|") + .map((segment) => segment.trim()) + .filter(Boolean); if (segments.length === 0) return stylePromptText(normalized, "muted"); const rendered = segments.map((segment) => { @@ -224,7 +247,10 @@ function styleQuotaSummary(summary: string): string { return joinStyledSegments(rendered); } -function styleAccountDetailText(detail: string, fallbackTone: PromptTone = "muted"): string { +function styleAccountDetailText( + detail: string, + fallbackTone: PromptTone = "muted", +): string { const compact = collapseWhitespace(detail); if (!compact) return stylePromptText("", fallbackTone); @@ -239,11 +265,12 @@ function styleAccountDetailText(detail: string, fallbackTone: PromptTone = "mute : /ok|working|succeeded|valid/i.test(prefix) ? "success" : fallbackTone; - const suffixTone: PromptTone = /re-login|stale|warning|retry|fallback/i.test(suffix) - ? "warning" - : /failed|error/i.test(suffix) - ? "danger" - : "muted"; + const suffixTone: PromptTone = + /re-login|stale|warning|retry|fallback/i.test(suffix) + ? "warning" + : /failed|error/i.test(suffix) + ? "danger" + : "muted"; const chunks: string[] = []; if (prefix) chunks.push(stylePromptText(prefix, prefixTone)); @@ -253,13 +280,17 @@ function styleAccountDetailText(detail: string, fallbackTone: PromptTone = "mute } if (/rate-limited/i.test(compact)) return stylePromptText(compact, "danger"); - if (/re-login|stale|warning|fallback/i.test(compact)) return stylePromptText(compact, "warning"); + if (/re-login|stale|warning|fallback/i.test(compact)) + return stylePromptText(compact, "warning"); if (/failed|error/i.test(compact)) return stylePromptText(compact, "danger"); - if (/ok|working|succeeded|valid/i.test(compact)) return stylePromptText(compact, "success"); + if (/ok|working|succeeded|valid/i.test(compact)) + return stylePromptText(compact, "success"); return stylePromptText(compact, fallbackTone); } -function riskTone(level: ForecastAccountResult["riskLevel"]): "success" | "warning" | "danger" { +function riskTone( + level: ForecastAccountResult["riskLevel"], +): "success" | "warning" | "danger" { if (level === "low") return "success"; if (level === "medium") return "warning"; return "danger"; @@ -414,7 +445,8 @@ function resolveActiveIndex( ): number { const total = storage.accounts.length; if (total === 0) return 0; - const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex; + const rawCandidate = + storage.activeIndexByFamily?.[family] ?? storage.activeIndex; const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; return Math.max(0, Math.min(raw, total - 1)); } @@ -545,7 +577,9 @@ function quotaCacheEntryToSnapshot(entry: QuotaCacheEntry): CodexQuotaSnapshot { }; } -function formatCompactQuotaWindowLabel(windowMinutes: number | undefined): string { +function formatCompactQuotaWindowLabel( + windowMinutes: number | undefined, +): string { if (!windowMinutes || !Number.isFinite(windowMinutes) || windowMinutes <= 0) { return "quota"; } @@ -554,7 +588,10 @@ function formatCompactQuotaWindowLabel(windowMinutes: number | undefined): strin return `${windowMinutes}m`; } -function formatCompactQuotaPart(windowMinutes: number | undefined, usedPercent: number | undefined): string | null { +function formatCompactQuotaPart( + windowMinutes: number | undefined, + usedPercent: number | undefined, +): string | null { const label = formatCompactQuotaWindowLabel(windowMinutes); if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { return null; @@ -563,7 +600,9 @@ function formatCompactQuotaPart(windowMinutes: number | undefined, usedPercent: return `${label} ${left}%`; } -function quotaLeftPercentFromUsed(usedPercent: number | undefined): number | undefined { +function quotaLeftPercentFromUsed( + usedPercent: number | undefined, +): number | undefined { if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { return undefined; } @@ -572,9 +611,17 @@ function quotaLeftPercentFromUsed(usedPercent: number | undefined): number | und function formatCompactQuotaSnapshot(snapshot: CodexQuotaSnapshot): string { const parts = [ - formatCompactQuotaPart(snapshot.primary.windowMinutes, snapshot.primary.usedPercent), - formatCompactQuotaPart(snapshot.secondary.windowMinutes, snapshot.secondary.usedPercent), - ].filter((value): value is string => typeof value === "string" && value.length > 0); + formatCompactQuotaPart( + snapshot.primary.windowMinutes, + snapshot.primary.usedPercent, + ), + formatCompactQuotaPart( + snapshot.secondary.windowMinutes, + snapshot.secondary.usedPercent, + ), + ].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); if (snapshot.status === 429) { parts.push("rate-limited"); } @@ -586,9 +633,17 @@ function formatCompactQuotaSnapshot(snapshot: CodexQuotaSnapshot): string { function formatAccountQuotaSummary(entry: QuotaCacheEntry): string { const parts = [ - formatCompactQuotaPart(entry.primary.windowMinutes, entry.primary.usedPercent), - formatCompactQuotaPart(entry.secondary.windowMinutes, entry.secondary.usedPercent), - ].filter((value): value is string => typeof value === "string" && value.length > 0); + formatCompactQuotaPart( + entry.primary.windowMinutes, + entry.primary.usedPercent, + ), + formatCompactQuotaPart( + entry.secondary.windowMinutes, + entry.secondary.usedPercent, + ), + ].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); if (entry.status === 429) { parts.push("rate-limited"); } @@ -865,11 +920,17 @@ function hasUsableAccessToken( now: number, ): boolean { if (!account.accessToken) return false; - if (typeof account.expiresAt !== "number" || !Number.isFinite(account.expiresAt)) return false; + if ( + typeof account.expiresAt !== "number" || + !Number.isFinite(account.expiresAt) + ) + return false; return account.expiresAt - now > ACCESS_TOKEN_FRESH_WINDOW_MS; } -function hasLikelyInvalidRefreshToken(refreshToken: string | undefined): boolean { +function hasLikelyInvalidRefreshToken( + refreshToken: string | undefined, +): boolean { if (!refreshToken) return true; const trimmed = refreshToken.trim(); if (trimmed.length < 20) return true; @@ -883,7 +944,10 @@ function mapAccountStatus( now: number, ): ExistingAccountInfo["status"] { if (account.enabled === false) return "disabled"; - if (typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now) { + if ( + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now + ) { return "cooldown"; } const rateLimit = formatRateLimitEntry(account, now, "codex"); @@ -897,7 +961,9 @@ function parseLeftPercentFromQuotaSummary( windowLabel: "5h" | "7d", ): number { if (!summary) return -1; - const match = summary.match(new RegExp(`(?:^|\\|)\\s*${windowLabel}\\s+(\\d{1,3})%`, "i")); + const match = summary.match( + new RegExp(`(?:^|\\|)\\s*${windowLabel}\\s+(\\d{1,3})%`, "i"), + ); const value = Number.parseInt(match?.[1] ?? "", 10); if (!Number.isFinite(value)) return -1; return Math.max(0, Math.min(100, value)); @@ -907,14 +973,19 @@ function readQuotaLeftPercent( account: ExistingAccountInfo, windowLabel: "5h" | "7d", ): number { - const direct = windowLabel === "5h" ? account.quota5hLeftPercent : account.quota7dLeftPercent; + const direct = + windowLabel === "5h" + ? account.quota5hLeftPercent + : account.quota7dLeftPercent; if (typeof direct === "number" && Number.isFinite(direct)) { return Math.max(0, Math.min(100, Math.round(direct))); } return parseLeftPercentFromQuotaSummary(account.quotaSummary, windowLabel); } -function accountStatusSortBucket(status: ExistingAccountInfo["status"]): number { +function accountStatusSortBucket( + status: ExistingAccountInfo["status"], +): number { switch (status) { case "active": case "ok": @@ -945,7 +1016,9 @@ function compareReadyFirstAccounts( const right7d = readQuotaLeftPercent(right, "7d"); if (left7d !== right7d) return right7d - left7d; - const bucketDelta = accountStatusSortBucket(left.status) - accountStatusSortBucket(right.status); + const bucketDelta = + accountStatusSortBucket(left.status) - + accountStatusSortBucket(right.status); if (bucketDelta !== 0) return bucketDelta; const leftLastUsed = left.lastUsed ?? 0; @@ -962,18 +1035,26 @@ function applyAccountMenuOrdering( displaySettings: DashboardDisplaySettings, ): ExistingAccountInfo[] { const sortEnabled = - displaySettings.menuSortEnabled ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true); + displaySettings.menuSortEnabled ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? + true; const sortMode: DashboardAccountSortMode = - displaySettings.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first"); + displaySettings.menuSortMode ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? + "ready-first"; if (!sortEnabled || sortMode !== "ready-first") { return [...accounts]; } const sorted = [...accounts].sort(compareReadyFirstAccounts); - const pinCurrent = displaySettings.menuSortPinCurrent ?? - (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? false); + const pinCurrent = + displaySettings.menuSortPinCurrent ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? + false; if (pinCurrent) { - const currentIndex = sorted.findIndex((account) => account.isCurrentAccount); + const currentIndex = sorted.findIndex( + (account) => account.isCurrentAccount, + ); if (currentIndex > 0) { const current = sorted.splice(currentIndex, 1)[0]; const first = sorted[0]; @@ -1014,12 +1095,15 @@ function toExistingAccountInfo( addedAt: account.addedAt, lastUsed: account.lastUsed, status: mapAccountStatus(account, index, activeIndex, now), - quotaSummary: (displaySettings.menuShowQuotaSummary ?? true) && entry - ? formatAccountQuotaSummary(entry) - : undefined, + quotaSummary: + (displaySettings.menuShowQuotaSummary ?? true) && entry + ? formatAccountQuotaSummary(entry) + : undefined, quota5hLeftPercent: quotaLeftPercentFromUsed(entry?.primary.usedPercent), quota5hResetAtMs: entry?.primary.resetAtMs, - quota7dLeftPercent: quotaLeftPercentFromUsed(entry?.secondary.usedPercent), + quota7dLeftPercent: quotaLeftPercentFromUsed( + entry?.secondary.usedPercent, + ), quota7dResetAtMs: entry?.secondary.resetAtMs, quotaRateLimited: entry?.status === 429, isCurrentAccount: index === activeIndex, @@ -1031,11 +1115,19 @@ function toExistingAccountInfo( showHintsForUnselectedRows: layoutMode === "expanded-rows", highlightCurrentRow: displaySettings.menuHighlightCurrentRow ?? true, focusStyle: displaySettings.menuFocusStyle ?? "row-invert", - statuslineFields: displaySettings.menuStatuslineFields ?? ["last-used", "limits", "status"], + statuslineFields: displaySettings.menuStatuslineFields ?? [ + "last-used", + "limits", + "status", + ], }; }); - const orderedAccounts = applyAccountMenuOrdering(baseAccounts, displaySettings); - const quickSwitchUsesVisibleRows = displaySettings.menuSortQuickSwitchVisibleRow ?? true; + const orderedAccounts = applyAccountMenuOrdering( + baseAccounts, + displaySettings, + ); + const quickSwitchUsesVisibleRows = + displaySettings.menuSortQuickSwitchVisibleRow ?? true; return orderedAccounts.map((account, displayIndex) => ({ ...account, index: displayIndex, @@ -1045,7 +1137,9 @@ function toExistingAccountInfo( })); } -function resolveAccountSelection(tokens: TokenSuccess): TokenSuccessWithAccount { +function resolveAccountSelection( + tokens: TokenSuccess, +): TokenSuccessWithAccount { const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); if (override) { return { @@ -1108,7 +1202,8 @@ function resolveStoredAccountIdentity( return { accountId, - accountIdSource: accountId === tokenAccountId ? "token" : storedAccountIdSource, + accountIdSource: + accountId === tokenAccountId ? "token" : storedAccountIdSource, }; } @@ -1124,8 +1219,10 @@ function applyTokenAccountIdentity( if (!nextIdentity.accountId) { return false; } - if (nextIdentity.accountId === account.accountId - && nextIdentity.accountIdSource === account.accountIdSource) { + if ( + nextIdentity.accountId === account.accountId && + nextIdentity.accountIdSource === account.accountIdSource + ) { return false; } @@ -1228,7 +1325,10 @@ function isReadlineClosedError(error: unknown): boolean { typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : ""; - return errorCode === "ERR_USE_AFTER_CLOSE" || /readline was closed/i.test(error.message); + return ( + errorCode === "ERR_USE_AFTER_CLOSE" || + /readline was closed/i.test(error.message) + ); } type OAuthSignInMode = "browser" | "manual" | "restore-backup" | "cancel"; @@ -1254,24 +1354,32 @@ async function promptOAuthSignInMode( const ui = getUiRuntimeOptions(); const items: MenuItem[] = [ - { label: UI_COPY.oauth.signInHeading, value: "cancel" as const, kind: "heading" }, + { + label: UI_COPY.oauth.signInHeading, + value: "cancel" as const, + kind: "heading", + }, { label: UI_COPY.oauth.openBrowser, value: "browser", color: "green" }, { label: UI_COPY.oauth.manualMode, value: "manual", color: "yellow" }, ...(backupOption ? [ - { separator: true, label: "", value: "cancel" as const }, - { label: UI_COPY.oauth.restoreHeading, value: "cancel" as const, kind: "heading" as const }, - { - label: UI_COPY.oauth.restoreSavedBackup, - value: "restore-backup" as const, - hint: UI_COPY.oauth.loadLastBackupHint( - backupOption.fileName, - backupOption.accountCount, - formatBackupSavedAt(backupOption.mtimeMs), - ), - color: "cyan" as const, - }, - ] + { separator: true, label: "", value: "cancel" as const }, + { + label: UI_COPY.oauth.restoreHeading, + value: "cancel" as const, + kind: "heading" as const, + }, + { + label: UI_COPY.oauth.restoreSavedBackup, + value: "restore-backup" as const, + hint: UI_COPY.oauth.loadLastBackupHint( + backupOption.fileName, + backupOption.accountCount, + formatBackupSavedAt(backupOption.mtimeMs), + ), + color: "cyan" as const, + }, + ] : []), { separator: true, label: "", value: "cancel" as const }, { label: UI_COPY.oauth.back, value: "cancel", color: "red" }, @@ -1356,15 +1464,17 @@ async function promptManualBackupSelection( } const ui = getUiRuntimeOptions(); - const items: MenuItem[] = backups.map((backup) => ({ - label: backup.fileName, - value: backup, - hint: UI_COPY.oauth.manualBackupHint( - backup.accountCount, - formatBackupSavedAt(backup.mtimeMs), - ), - color: "cyan", - })); + const items: MenuItem[] = backups.map( + (backup) => ({ + label: backup.fileName, + value: backup, + hint: UI_COPY.oauth.manualBackupHint( + backup.accountCount, + formatBackupSavedAt(backup.mtimeMs), + ), + color: "cyan", + }), + ); items.push({ label: UI_COPY.oauth.back, value: null, color: "red" }); const selected = await select(items, { @@ -1390,7 +1500,9 @@ interface WaitForReturnOptions { pauseOnAnyKey?: boolean; } -async function waitForMenuReturn(options: WaitForReturnOptions = {}): Promise { +async function waitForMenuReturn( + options: WaitForReturnOptions = {}, +): Promise { if (!input.isTTY || !output.isTTY) { return; } @@ -1429,9 +1541,7 @@ async function waitForMenuReturn(options: WaitForReturnOptions = {}): Promise((resolve) => { @@ -1506,7 +1616,8 @@ async function waitForMenuReturn(options: WaitForReturnOptions = {}): Promise 0 ? `${stylePromptText(promptText, "muted")} ` : ""; + const question = + promptText.length > 0 ? `${stylePromptText(promptText, "muted")} ` : ""; output.write(`\r${ANSI.clearLine}`); await rl.question(question); } catch (error) { @@ -1575,7 +1686,12 @@ async function runActionPanel( ? UI_COPY.returnFlow.failed : UI_COPY.returnFlow.done; previousLog(stylePromptText(title, "accent")); - previousLog(stylePromptText(stageText, failed ? "danger" : running ? "accent" : "success")); + previousLog( + stylePromptText( + stageText, + failed ? "danger" : running ? "accent" : "success", + ), + ); previousLog(""); const lines = captured.slice(-maxVisibleLines); @@ -1588,7 +1704,8 @@ async function runActionPanel( previousLog(""); } previousLog(""); - if (running) previousLog(stylePromptText(UI_COPY.returnFlow.working, "muted")); + if (running) + previousLog(stylePromptText(UI_COPY.returnFlow.working, "muted")); frame += 1; }; @@ -1637,7 +1754,9 @@ async function runActionPanel( pauseOnAnyKey: settings?.actionPauseOnKey ?? true, }); } - output.write(ANSI.altScreenOff + ANSI.show + ANSI.clearScreen + ANSI.moveTo(1, 1)); + output.write( + ANSI.altScreenOff + ANSI.show + ANSI.clearScreen + ANSI.moveTo(1, 1), + ); if (failed) { throw failed; } @@ -1649,7 +1768,8 @@ async function runOAuthFlow( ): Promise { const { pkce, state, url } = await createAuthorizationFlow({ forceNewLogin }); let code: string | null = null; - let oauthServer: Awaited> | null = null; + let oauthServer: Awaited> | null = + null; try { if (signInMode === "browser") { try { @@ -1659,15 +1779,15 @@ async function runOAuthFlow( "Local OAuth callback server unavailable; falling back to manual callback entry.", serverError instanceof Error ? { - message: serverError.message, - stack: serverError.stack, - code: - typeof serverError === "object" && - serverError !== null && - "code" in serverError - ? String(serverError.code) - : undefined, - } + message: serverError.message, + stack: serverError.stack, + code: + typeof serverError === "object" && + serverError !== null && + "code" in serverError + ? String(serverError.code) + : undefined, + } : { error: String(serverError) }, ); oauthServer = null; @@ -1700,7 +1820,8 @@ async function runOAuthFlow( ); } - const waitingForCallback = signInMode === "browser" && oauthServer?.ready === true; + const waitingForCallback = + signInMode === "browser" && oauthServer?.ready === true; if (waitingForCallback && oauthServer) { console.log(stylePromptText(UI_COPY.oauth.waitingCallback, "muted")); const callbackResult = await oauthServer.waitForCode(state); @@ -1718,7 +1839,9 @@ async function runOAuthFlow( "warning", ), ); - code = await promptManualCallback(state, { allowNonTty: signInMode === "manual" }); + code = await promptManualCallback(state, { + allowNonTty: signInMode === "manual", + }); } } finally { oauthServer?.close(); @@ -1754,19 +1877,24 @@ async function persistAccountPool( tokenAccountId, ); const accountIdSource = accountId - ? (result.accountIdSource ?? (result.accountIdOverride ? "manual" : "token")) + ? (result.accountIdSource ?? + (result.accountIdOverride ? "manual" : "token")) : undefined; const accountLabel = result.accountLabel; const accountEmail = sanitizeEmail( extractAccountEmail(result.access, result.idToken), ); - const existingIndex = findMatchingAccountIndex(accounts, { - accountId, - email: accountEmail, - refreshToken: result.refresh, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); + const existingIndex = findMatchingAccountIndex( + accounts, + { + accountId, + email: accountEmail, + refreshToken: result.refresh, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); if (existingIndex === undefined) { const newIndex = accounts.length; @@ -1810,17 +1938,16 @@ async function persistAccountPool( selectedAccountIndex = existingIndex; } - const fallbackActiveIndex = accounts.length === 0 - ? 0 - : Math.max( - 0, - Math.min(stored?.activeIndex ?? 0, accounts.length - 1), - ); - const nextActiveIndex = accounts.length === 0 - ? 0 - : selectedAccountIndex === null - ? fallbackActiveIndex - : Math.max(0, Math.min(selectedAccountIndex, accounts.length - 1)); + const fallbackActiveIndex = + accounts.length === 0 + ? 0 + : Math.max(0, Math.min(stored?.activeIndex ?? 0, accounts.length - 1)); + const nextActiveIndex = + accounts.length === 0 + ? 0 + : selectedAccountIndex === null + ? fallbackActiveIndex + : Math.max(0, Math.min(selectedAccountIndex, accounts.length - 1)); const activeIndexByFamily: Partial> = {}; for (const family of MODEL_FAMILIES) { activeIndexByFamily[family] = nextActiveIndex; @@ -1835,14 +1962,18 @@ async function persistAccountPool( }); } -async function syncSelectionToCodex(tokens: TokenSuccessWithAccount): Promise { +async function syncSelectionToCodex( + tokens: TokenSuccessWithAccount, +): Promise { const tokenAccountId = extractAccountId(tokens.access); const accountId = resolveRequestAccountId( tokens.accountIdOverride, tokens.accountIdSource, tokenAccountId, ); - const email = sanitizeEmail(extractAccountEmail(tokens.access, tokens.idToken)); + const email = sanitizeEmail( + extractAccountEmail(tokens.access, tokens.idToken), + ); await setCodexCliActiveSelection({ accountId, email, @@ -1880,9 +2011,10 @@ async function showAccountStatus(): Promise { const cooldown = formatCooldown(account, now); if (cooldown) markers.push(`cooldown:${cooldown}`); const markerLabel = markers.length > 0 ? ` [${markers.join(", ")}]` : ""; - const lastUsed = typeof account.lastUsed === "number" && account.lastUsed > 0 - ? `used ${formatWaitTime(now - account.lastUsed)} ago` - : "never used"; + const lastUsed = + typeof account.lastUsed === "number" && account.lastUsed > 0 + ? `used ${formatWaitTime(now - account.lastUsed)} ago` + : "never used"; console.log(`${i + 1}. ${label}${markerLabel} ${lastUsed}`); } } @@ -1920,12 +2052,14 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const activeIndex = resolveActiveIndex(storage, "codex"); let activeAccountRefreshed = false; const now = Date.now(); - console.log(stylePromptText( - forceRefresh - ? `Checking ${storage.accounts.length} account(s) with full refresh test...` - : `Checking ${storage.accounts.length} account(s) with quick check${liveProbe ? " + live check" : ""}...`, - "accent", - )); + console.log( + stylePromptText( + forceRefresh + ? `Checking ${storage.accounts.length} account(s) with full refresh test...` + : `Checking ${storage.accounts.length} account(s) with quick check${liveProbe ? " + live check" : ""}...`, + "accent", + ), + ); for (let i = 0; i < storage.accounts.length; i += 1) { const account = storage.accounts[i]; if (!account) continue; @@ -1948,7 +2082,8 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { : undefined; if (!probeAccountId || !currentAccessToken) { warnings += 1; - healthDetail = "signed in and working (live check skipped: missing account ID)"; + healthDetail = + "signed in and working (live check skipped: missing account ID)"; } else { try { const snapshot = await fetchCodexQuotaSnapshot({ @@ -1991,7 +2126,9 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const result = await queuedRefresh(account.refreshToken); if (result.type === "success") { const tokenAccountId = extractAccountId(result.access); - const nextEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(result.access, result.idToken), + ); const previousEmail = account.email; let accountIdentityChanged = false; if (account.refreshToken !== result.refresh) { @@ -2020,7 +2157,9 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { changed = true; } if (accountIdentityChanged && liveProbe && workingQuotaCache) { - quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); + quotaEmailFallbackState = buildQuotaEmailFallbackState( + storage.accounts, + ); quotaCacheChanged = pruneUnsafeQuotaEmailCacheEntry( workingQuotaCache, @@ -2039,7 +2178,8 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const probeAccountId = account.accountId ?? tokenAccountId; if (!probeAccountId) { warnings += 1; - healthyMessage = "working now (live check skipped: missing account ID)"; + healthyMessage = + "working now (live check skipped: missing account ID)"; } else { try { const snapshot = await fetchCodexQuotaSnapshot({ @@ -2094,7 +2234,12 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { } if (!display.showPerAccountRows) { - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); + console.log( + stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); } if (workingQuotaCache && quotaCacheChanged) { await saveQuotaCache(workingQuotaCache); @@ -2104,7 +2249,11 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { await saveAccounts(storage); } - if (activeAccountRefreshed && activeIndex >= 0 && activeIndex < storage.accounts.length) { + if ( + activeAccountRefreshed && + activeIndex >= 0 && + activeIndex < storage.accounts.length + ) { const activeAccount = storage.accounts[activeIndex]; if (activeAccount) { await setCodexCliActiveSelection({ @@ -2118,16 +2267,25 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { } console.log(""); - console.log(formatResultSummary([ - { text: `${ok} working`, tone: "success" }, - { text: `${failed} need re-login`, tone: failed > 0 ? "danger" : "muted" }, - { text: `${warnings} warning${warnings === 1 ? "" : "s"}`, tone: warnings > 0 ? "warning" : "muted" }, - ])); + console.log( + formatResultSummary([ + { text: `${ok} working`, tone: "success" }, + { + text: `${failed} need re-login`, + tone: failed > 0 ? "danger" : "muted", + }, + { + text: `${warnings} warning${warnings === 1 ? "" : "s"}`, + tone: warnings > 0 ? "warning" : "muted", + }, + ]), + ); } interface ForecastCliOptions { live: boolean; json: boolean; + explain: boolean; model: string; } @@ -2158,17 +2316,20 @@ interface VerifyFlaggedCliOptions { restore: boolean; } -type ParsedArgsResult = { ok: true; options: T } | { ok: false; message: string }; +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; function printForecastUsage(): void { console.log( [ "Usage:", - " codex auth forecast [--live] [--json] [--model ]", + " codex auth forecast [--live] [--json] [--explain] [--model ]", "", "Options:", " --live, -l Probe live quota headers via Codex backend", " --json, -j Print machine-readable JSON output", + " --explain Include structured recommendation reasoning", " --model, -m Probe model for live mode (default: gpt-5-codex)", ].join("\n"), ); @@ -2230,10 +2391,13 @@ function printVerifyFlaggedUsage(): void { ); } -function parseForecastArgs(args: string[]): ParsedArgsResult { +function parseForecastArgs( + args: string[], +): ParsedArgsResult { const options: ForecastCliOptions = { live: false, json: false, + explain: false, model: "gpt-5-codex", }; @@ -2249,6 +2413,10 @@ function parseForecastArgs(args: string[]): ParsedArgsResult options.json = true; continue; } + if (arg === "--explain") { + options.explain = true; + continue; + } if (arg === "--model" || arg === "-m") { const value = args[i + 1]; if (!value) { @@ -2362,7 +2530,9 @@ function parseFixArgs(args: string[]): ParsedArgsResult { return { ok: true, options }; } -function parseVerifyFlaggedArgs(args: string[]): ParsedArgsResult { +function parseVerifyFlaggedArgs( + args: string[], +): ParsedArgsResult { const options: VerifyFlaggedCliOptions = { dryRun: false, json: false, @@ -2511,7 +2681,10 @@ function parseReportArgs(args: string[]): ParsedArgsResult { function serializeForecastResults( results: ForecastAccountResult[], - liveQuotaByIndex: Map>>, + liveQuotaByIndex: Map< + number, + Awaited> + >, refreshFailures: Map, ): Array<{ index: number; @@ -2544,12 +2717,12 @@ function serializeForecastResults( reasons: result.reasons, liveQuota: liveQuota ? { - status: liveQuota.status, - planType: liveQuota.planType, - activeLimit: liveQuota.activeLimit, - model: liveQuota.model, - summary: formatQuotaSnapshotLine(liveQuota), - } + status: liveQuota.status, + planType: liveQuota.planType, + activeLimit: liveQuota.activeLimit, + model: liveQuota.model, + summary: formatQuotaSnapshotLine(liveQuota), + } : undefined, refreshFailure: refreshFailures.get(result.index), }; @@ -2588,7 +2761,10 @@ async function runForecast(args: string[]): Promise { const now = Date.now(); const activeIndex = resolveActiveIndex(storage, "codex"); const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); + const liveQuotaByIndex = new Map< + number, + Awaited> + >(); const probeErrors: string[] = []; for (let i = 0; i < storage.accounts.length; i += 1) { @@ -2597,22 +2773,29 @@ async function runForecast(args: string[]): Promise { if (account.enabled === false) continue; let probeAccessToken = account.accessToken; - let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); + let probeAccountId = + account.accountId ?? extractAccountId(account.accessToken); if (!hasUsableAccessToken(account, now)) { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type !== "success") { refreshFailures.set(i, { ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + message: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); continue; } probeAccessToken = refreshResult.access; - probeAccountId = account.accountId ?? extractAccountId(refreshResult.access); + probeAccountId = + account.accountId ?? extractAccountId(refreshResult.access); } if (!probeAccessToken || !probeAccountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + probeErrors.push( + `${formatAccountLabel(account, i)}: missing accountId for live probe`, + ); continue; } @@ -2656,6 +2839,7 @@ async function runForecast(args: string[]): Promise { const forecastResults = evaluateForecastAccounts(forecastInputs); const summary = summarizeForecast(forecastResults); const recommendation = recommendForecastAccount(forecastResults); + const explanation = buildForecastExplanation(forecastResults, recommendation); if (options.json) { if (workingQuotaCache && quotaCacheChanged) { @@ -2669,8 +2853,13 @@ async function runForecast(args: string[]): Promise { liveProbe: options.live, summary, recommendation, + explanation: options.explain ? explanation : undefined, probeErrors, - accounts: serializeForecastResults(forecastResults, liveQuotaByIndex, refreshFailures), + accounts: serializeForecastResults( + forecastResults, + liveQuotaByIndex, + refreshFailures, + ), }, null, 2, @@ -2689,8 +2878,14 @@ async function runForecast(args: string[]): Promise { formatResultSummary([ { text: `${summary.ready} ready now`, tone: "success" }, { text: `${summary.delayed} waiting`, tone: "warning" }, - { text: `${summary.unavailable} unavailable`, tone: summary.unavailable > 0 ? "danger" : "muted" }, - { text: `${summary.highRisk} high risk`, tone: summary.highRisk > 0 ? "danger" : "muted" }, + { + text: `${summary.unavailable} unavailable`, + tone: summary.unavailable > 0 ? "danger" : "muted", + }, + { + text: `${summary.highRisk} high risk`, + tone: summary.highRisk > 0 ? "danger" : "muted", + }, ]), ); console.log(""); @@ -2700,25 +2895,48 @@ async function runForecast(args: string[]): Promise { continue; } const currentTag = result.isCurrent ? " [current]" : ""; - const waitLabel = result.waitMs > 0 ? stylePromptText(`wait ${formatWaitTime(result.waitMs)}`, "muted") : ""; + const waitLabel = + result.waitMs > 0 + ? stylePromptText(`wait ${formatWaitTime(result.waitMs)}`, "muted") + : ""; const indexLabel = stylePromptText(`${result.index + 1}.`, "accent"); - const accountLabel = stylePromptText(`${result.label}${currentTag}`, "accent"); - const riskLabel = stylePromptText(`${result.riskLevel} risk (${result.riskScore})`, riskTone(result.riskLevel)); - const availabilityLabel = stylePromptText(result.availability, availabilityTone(result.availability)); + const accountLabel = stylePromptText( + `${result.label}${currentTag}`, + "accent", + ); + const riskLabel = stylePromptText( + `${result.riskLevel} risk (${result.riskScore})`, + riskTone(result.riskLevel), + ); + const availabilityLabel = stylePromptText( + result.availability, + availabilityTone(result.availability), + ); const rowParts = [availabilityLabel, riskLabel]; if (waitLabel) rowParts.push(waitLabel); - console.log(`${indexLabel} ${accountLabel} ${stylePromptText("|", "muted")} ${joinStyledSegments(rowParts)}`); + console.log( + `${indexLabel} ${accountLabel} ${stylePromptText("|", "muted")} ${joinStyledSegments(rowParts)}`, + ); if (display.showForecastReasons && result.reasons.length > 0) { - console.log(` ${stylePromptText(result.reasons.slice(0, 3).join("; "), "muted")}`); + console.log( + ` ${stylePromptText(result.reasons.slice(0, 3).join("; "), "muted")}`, + ); } const liveQuota = liveQuotaByIndex.get(result.index); if (display.showQuotaDetails && liveQuota) { - console.log(` ${stylePromptText("quota:", "accent")} ${styleQuotaSummary(formatCompactQuotaSnapshot(liveQuota))}`); + console.log( + ` ${stylePromptText("quota:", "accent")} ${styleQuotaSummary(formatCompactQuotaSnapshot(liveQuota))}`, + ); } } if (!display.showPerAccountRows) { - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); + console.log( + stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); } if (display.showRecommendations) { @@ -2730,21 +2948,42 @@ async function runForecast(args: string[]): Promise { console.log( `${stylePromptText("Best next account:", "accent")} ${stylePromptText(`${index + 1} (${account.label})`, "success")}`, ); - console.log(`${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); if (index !== activeIndex) { - console.log(`${stylePromptText("Switch now with:", "accent")} codex auth switch ${index + 1}`); + console.log( + `${stylePromptText("Switch now with:", "accent")} codex auth switch ${index + 1}`, + ); } } } else { - console.log(`${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); + } + if (options.explain) { + console.log(""); + console.log(stylePromptText("Explain:", "accent")); + for (const item of explanation.considered) { + const prefix = item.selected ? "*" : "-"; + const reasons = item.reasons.slice(0, 3).join("; "); + console.log( + `${stylePromptText(prefix, item.selected ? "success" : "muted")} ${stylePromptText(`${item.index + 1}. ${item.label}`, item.selected ? "success" : "accent")} ${stylePromptText("|", "muted")} ${stylePromptText(`${item.availability}, ${item.riskLevel} risk (${item.riskScore})`, item.selected ? "success" : "muted")}${reasons ? ` ${stylePromptText("|", "muted")} ${stylePromptText(reasons, "muted")}` : ""}`, + ); + } } } if (display.showLiveProbeNotes && probeErrors.length > 0) { console.log(""); - console.log(stylePromptText(`Live check notes (${probeErrors.length}):`, "warning")); + console.log( + stylePromptText(`Live check notes (${probeErrors.length}):`, "warning"), + ); for (const error of probeErrors) { - console.log(` ${stylePromptText("-", "warning")} ${stylePromptText(error, "muted")}`); + console.log( + ` ${stylePromptText("-", "warning")} ${stylePromptText(error, "muted")}`, + ); } } if (workingQuotaCache && quotaCacheChanged) { @@ -2775,7 +3014,10 @@ async function runReport(args: string[]): Promise { const accountCount = storage?.accounts.length ?? 0; const activeIndex = storage ? resolveActiveIndex(storage, "codex") : 0; const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); + const liveQuotaByIndex = new Map< + number, + Awaited> + >(); const probeErrors: string[] = []; if (storage && options.live) { @@ -2787,14 +3029,20 @@ async function runReport(args: string[]): Promise { if (refreshResult.type !== "success") { refreshFailures.set(i, { ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + message: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); continue; } - const accountId = account.accountId ?? extractAccountId(refreshResult.access); + const accountId = + account.accountId ?? extractAccountId(refreshResult.access); if (!accountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + probeErrors.push( + `${formatAccountLabel(account, i)}: missing accountId for live probe`, + ); continue; } @@ -2836,11 +3084,14 @@ async function runReport(args: string[]): Promise { const coolingCount = storage ? storage.accounts.filter( (account) => - typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now, + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now, ).length : 0; const rateLimitedCount = storage - ? storage.accounts.filter((account) => !!formatRateLimitEntry(account, now, "codex")).length + ? storage.accounts.filter( + (account) => !!formatRateLimitEntry(account, now, "codex"), + ).length : 0; const report = { @@ -2861,14 +3112,22 @@ async function runReport(args: string[]): Promise { summary: forecastSummary, recommendation, probeErrors, - accounts: serializeForecastResults(forecastResults, liveQuotaByIndex, refreshFailures), + accounts: serializeForecastResults( + forecastResults, + liveQuotaByIndex, + refreshFailures, + ), }, }; if (options.outPath) { const outputPath = resolve(process.cwd(), options.outPath); await fs.mkdir(dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf-8"); + await fs.writeFile( + outputPath, + `${JSON.stringify(report, null, 2)}\n`, + "utf-8", + ); } if (options.json) { @@ -2916,9 +3175,7 @@ interface FixAccountReport { message: string; } -function summarizeFixReports( - reports: FixAccountReport[], -): { +function summarizeFixReports(reports: FixAccountReport[]): { healthy: number; disabled: number; warnings: number; @@ -2967,24 +3224,32 @@ function findExistingAccountIndexForFlagged( const flaggedEmail = sanitizeEmail(flagged.email); const candidateAccountId = nextAccountId ?? flagged.accountId; const candidateEmail = sanitizeEmail(nextEmail) ?? flaggedEmail; - const nextMatchIndex = findMatchingAccountIndex(storage.accounts, { - accountId: candidateAccountId, - email: candidateEmail, - refreshToken: nextRefreshToken, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); + const nextMatchIndex = findMatchingAccountIndex( + storage.accounts, + { + accountId: candidateAccountId, + email: candidateEmail, + refreshToken: nextRefreshToken, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); if (nextMatchIndex !== undefined) { return nextMatchIndex; } - const flaggedMatchIndex = findMatchingAccountIndex(storage.accounts, { - accountId: candidateAccountId, - email: candidateEmail, - refreshToken: flagged.refreshToken, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); + const flaggedMatchIndex = findMatchingAccountIndex( + storage.accounts, + { + accountId: candidateAccountId, + email: candidateEmail, + refreshToken: flagged.refreshToken, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); return flaggedMatchIndex ?? -1; } @@ -2994,10 +3259,17 @@ function upsertRecoveredFlaggedAccount( refreshResult: TokenSuccess, now: number, ): { restored: boolean; changed: boolean; message: string } { - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)) ?? flagged.email; + const nextEmail = + sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ) ?? flagged.email; const tokenAccountId = extractAccountId(refreshResult.access); const { accountId: nextAccountId, accountIdSource: nextAccountIdSource } = - resolveStoredAccountIdentity(flagged.accountId, flagged.accountIdSource, tokenAccountId); + resolveStoredAccountIdentity( + flagged.accountId, + flagged.accountIdSource, + tokenAccountId, + ); const existingIndex = findExistingAccountIndexForFlagged( storage, flagged, @@ -3009,7 +3281,11 @@ function upsertRecoveredFlaggedAccount( if (existingIndex >= 0) { const existing = storage.accounts[existingIndex]; if (!existing) { - return { restored: false, changed: false, message: "existing account entry is missing" }; + return { + restored: false, + changed: false, + message: "existing account entry is missing", + }; } let changed = false; if (existing.refreshToken !== refreshResult.refresh) { @@ -3030,10 +3306,8 @@ function upsertRecoveredFlaggedAccount( } if ( nextAccountId !== undefined && - ( - (nextAccountId !== existing.accountId) - || (nextAccountIdSource !== existing.accountIdSource) - ) + (nextAccountId !== existing.accountId || + nextAccountIdSource !== existing.accountIdSource) ) { existing.accountId = nextAccountId; existing.accountIdSource = nextAccountIdSource; @@ -3043,7 +3317,10 @@ function upsertRecoveredFlaggedAccount( existing.enabled = true; changed = true; } - if (existing.accountLabel !== flagged.accountLabel && flagged.accountLabel) { + if ( + existing.accountLabel !== flagged.accountLabel && + flagged.accountLabel + ) { existing.accountLabel = flagged.accountLabel; changed = true; } @@ -3147,9 +3424,7 @@ async function runVerifyFlagged(args: string[]): Promise { }); } - const applyRefreshChecks = ( - storage: AccountStorageV3, - ): void => { + const applyRefreshChecks = (storage: AccountStorageV3): void => { for (const check of refreshChecks) { const { index: i, flagged, label, result } = check; if (result.type === "success") { @@ -3167,7 +3442,10 @@ async function runVerifyFlagged(args: string[]): Promise { expiresAt: result.expires, accountId: nextIdentity.accountId, accountIdSource: nextIdentity.accountIdSource, - email: sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? flagged.email, + email: + sanitizeEmail( + extractAccountEmail(result.access, result.idToken), + ) ?? flagged.email, lastUsed: now, lastError: undefined, }; @@ -3179,12 +3457,18 @@ async function runVerifyFlagged(args: string[]): Promise { index: i, label, outcome: "healthy-flagged", - message: "session is healthy (left in flagged list due to --no-restore)", + message: + "session is healthy (left in flagged list due to --no-restore)", }); continue; } - const upsertResult = upsertRecoveredFlaggedAccount(storage, flagged, result, now); + const upsertResult = upsertRecoveredFlaggedAccount( + storage, + flagged, + result, + now, + ); if (upsertResult.restored) { storageChanged = storageChanged || upsertResult.changed; flaggedChanged = true; @@ -3210,7 +3494,9 @@ async function runVerifyFlagged(args: string[]): Promise { expiresAt: result.expires, accountId: nextIdentity.accountId, accountIdSource: nextIdentity.accountIdSource, - email: sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? flagged.email, + email: + sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? + flagged.email, lastUsed: now, lastError: upsertResult.message, }; @@ -3247,9 +3533,7 @@ async function runVerifyFlagged(args: string[]): Promise { if (options.restore) { if (options.dryRun) { - applyRefreshChecks( - (await loadAccounts()) ?? createEmptyAccountStorage(), - ); + applyRefreshChecks((await loadAccounts()) ?? createEmptyAccountStorage()); } else { await withAccountAndFlaggedStorageTransaction( async (loadedStorage, persist) => { @@ -3273,12 +3557,22 @@ async function runVerifyFlagged(args: string[]): Promise { } const remainingFlagged = nextFlaggedAccounts.length; - const restored = reports.filter((report) => report.outcome === "restored").length; - const healthyFlagged = reports.filter((report) => report.outcome === "healthy-flagged").length; - const stillFlagged = reports.filter((report) => report.outcome === "still-flagged").length; + const restored = reports.filter( + (report) => report.outcome === "restored", + ).length; + const healthyFlagged = reports.filter( + (report) => report.outcome === "healthy-flagged", + ).length; + const stillFlagged = reports.filter( + (report) => report.outcome === "still-flagged", + ).length; const changed = storageChanged || flaggedChanged; - if (!options.dryRun && flaggedChanged && (!options.restore || !storageChanged)) { + if ( + !options.dryRun && + flaggedChanged && + (!options.restore || !storageChanged) + ) { await saveFlaggedAccounts({ version: 1, accounts: nextFlaggedAccounts, @@ -3314,32 +3608,47 @@ async function runVerifyFlagged(args: string[]): Promise { ), ); for (const report of reports) { - const tone = report.outcome === "restored" - ? "success" - : report.outcome === "healthy-flagged" - ? "warning" - : report.outcome === "restore-skipped" + const tone = + report.outcome === "restored" + ? "success" + : report.outcome === "healthy-flagged" ? "warning" - : "danger"; - const marker = report.outcome === "restored" - ? "✓" - : report.outcome === "healthy-flagged" - ? "!" - : report.outcome === "restore-skipped" + : report.outcome === "restore-skipped" + ? "warning" + : "danger"; + const marker = + report.outcome === "restored" + ? "✓" + : report.outcome === "healthy-flagged" ? "!" - : "✗"; + : report.outcome === "restore-skipped" + ? "!" + : "✗"; console.log( `${stylePromptText(marker, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone)}`, ); } console.log(""); - console.log(formatResultSummary([ - { text: `${restored} restored`, tone: restored > 0 ? "success" : "muted" }, - { text: `${healthyFlagged} healthy (kept flagged)`, tone: healthyFlagged > 0 ? "warning" : "muted" }, - { text: `${stillFlagged} still flagged`, tone: stillFlagged > 0 ? "danger" : "muted" }, - ])); + console.log( + formatResultSummary([ + { + text: `${restored} restored`, + tone: restored > 0 ? "success" : "muted", + }, + { + text: `${healthyFlagged} healthy (kept flagged)`, + tone: healthyFlagged > 0 ? "warning" : "muted", + }, + { + text: `${stillFlagged} still flagged`, + tone: stillFlagged > 0 ? "danger" : "muted", + }, + ]), + ); if (options.dryRun) { - console.log(stylePromptText("Preview only: no changes were saved.", "warning")); + console.log( + stylePromptText("Preview only: no changes were saved.", "warning"), + ); } else if (!changed) { console.log(stylePromptText("No storage changes were needed.", "muted")); } @@ -3459,7 +3768,9 @@ async function runFix(args: string[]): Promise { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); const nextAccountId = extractAccountId(refreshResult.access); const previousEmail = account.email; let accountChanged = false; @@ -3489,7 +3800,9 @@ async function runFix(args: string[]): Promise { if (accountChanged) changed = true; if (accountIdentityChanged && options.live && workingQuotaCache) { - quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); + quotaEmailFallbackState = buildQuotaEmailFallbackState( + storage.accounts, + ); quotaCacheChanged = pruneUnsafeQuotaEmailCacheEntry( workingQuotaCache, @@ -3550,7 +3863,10 @@ async function runFix(args: string[]): Promise { continue; } - const detail = normalizeFailureDetail(refreshResult.message, refreshResult.reason); + const detail = normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ); refreshFailures.set(i, { ...refreshResult, message: detail, @@ -3576,13 +3892,17 @@ async function runFix(args: string[]): Promise { } if (hardDisabledIndexes.length > 0) { - const enabledCount = storage.accounts.filter((account) => account.enabled !== false).length; + const enabledCount = storage.accounts.filter( + (account) => account.enabled !== false, + ).length; if (enabledCount === 0) { - const fallbackIndex = - hardDisabledIndexes.includes(activeIndex) ? activeIndex : hardDisabledIndexes[0]; - const fallback = typeof fallbackIndex === "number" - ? storage.accounts[fallbackIndex] - : undefined; + const fallbackIndex = hardDisabledIndexes.includes(activeIndex) + ? activeIndex + : hardDisabledIndexes[0]; + const fallback = + typeof fallbackIndex === "number" + ? storage.accounts[fallbackIndex] + : undefined; if (fallback && fallback.enabled === false) { fallback.enabled = true; changed = true; @@ -3631,7 +3951,7 @@ async function runFix(args: string[]): Promise { recommendation, recommendedSwitchCommand: recommendation.recommendedIndex !== null && - recommendation.recommendedIndex !== activeIndex + recommendation.recommendedIndex !== activeIndex ? `codex auth switch ${recommendation.recommendedIndex + 1}` : null, reports, @@ -3643,16 +3963,26 @@ async function runFix(args: string[]): Promise { return 0; } - console.log(stylePromptText(`Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, "accent")); - console.log(formatResultSummary([ - { text: `${reportSummary.healthy} working`, tone: "success" }, - { text: `${reportSummary.disabled} disabled`, tone: reportSummary.disabled > 0 ? "danger" : "muted" }, - { - text: `${reportSummary.warnings} warning${reportSummary.warnings === 1 ? "" : "s"}`, - tone: reportSummary.warnings > 0 ? "warning" : "muted", - }, - { text: `${reportSummary.skipped} already disabled`, tone: "muted" }, - ])); + console.log( + stylePromptText( + `Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, + "accent", + ), + ); + console.log( + formatResultSummary([ + { text: `${reportSummary.healthy} working`, tone: "success" }, + { + text: `${reportSummary.disabled} disabled`, + tone: reportSummary.disabled > 0 ? "danger" : "muted", + }, + { + text: `${reportSummary.warnings} warning${reportSummary.warnings === 1 ? "" : "s"}`, + tone: reportSummary.warnings > 0 ? "warning" : "muted", + }, + { text: `${reportSummary.skipped} already disabled`, tone: "muted" }, + ]), + ); if (display.showPerAccountRows) { console.log(""); for (const report of reports) { @@ -3664,33 +3994,47 @@ async function runFix(args: string[]): Promise { : report.outcome === "warning-soft-failure" ? "!" : "-"; - const tone = report.outcome === "healthy" - ? "success" - : report.outcome === "disabled-hard-failure" - ? "danger" - : report.outcome === "warning-soft-failure" - ? "warning" - : "muted"; + const tone = + report.outcome === "healthy" + ? "success" + : report.outcome === "disabled-hard-failure" + ? "danger" + : report.outcome === "warning-soft-failure" + ? "warning" + : "muted"; console.log( `${stylePromptText(prefix, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone === "success" ? "muted" : tone)}`, ); } } else { console.log(""); - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); + console.log( + stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); } if (display.showRecommendations) { console.log(""); if (recommendation.recommendedIndex !== null) { const target = recommendation.recommendedIndex + 1; - console.log(`${stylePromptText("Best next account:", "accent")} ${stylePromptText(String(target), "success")}`); - console.log(`${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Best next account:", "accent")} ${stylePromptText(String(target), "success")}`, + ); + console.log( + `${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); if (recommendation.recommendedIndex !== activeIndex) { - console.log(`${stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`); + console.log( + `${stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`, + ); } } else { - console.log(`${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); } } if (workingQuotaCache && quotaCacheChanged) { @@ -3698,7 +4042,9 @@ async function runFix(args: string[]): Promise { } if (changed && options.dryRun) { - console.log(`\n${stylePromptText("Preview only: no changes were saved.", "warning")}`); + console.log( + `\n${stylePromptText("Preview only: no changes were saved.", "warning")}`, + ); } else if (changed) { console.log(`\n${stylePromptText("Saved updates.", "success")}`); } else { @@ -3736,7 +4082,8 @@ function hasPlaceholderEmail(value: string | undefined): boolean { function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { const total = storage.accounts.length; - const nextActive = total === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, total - 1)); + const nextActive = + total === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, total - 1)); let changed = false; if (storage.activeIndex !== nextActive) { storage.activeIndex = nextActive; @@ -3746,8 +4093,10 @@ function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { for (const family of MODEL_FAMILIES) { const raw = storage.activeIndexByFamily[family]; const fallback = storage.activeIndex; - const candidate = typeof raw === "number" && Number.isFinite(raw) ? raw : fallback; - const clamped = total === 0 ? 0 : Math.max(0, Math.min(candidate, total - 1)); + const candidate = + typeof raw === "number" && Number.isFinite(raw) ? raw : fallback; + const clamped = + total === 0 ? 0 : Math.max(0, Math.min(candidate, total - 1)); if (storage.activeIndexByFamily[family] !== clamped) { storage.activeIndexByFamily[family] = clamped; changed = true; @@ -3756,15 +4105,16 @@ function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { return changed; } -function getDoctorRefreshTokenKey( - refreshToken: unknown, -): string | undefined { +function getDoctorRefreshTokenKey(refreshToken: unknown): string | undefined { if (typeof refreshToken !== "string") return undefined; const trimmed = refreshToken.trim(); return trimmed || undefined; } -function applyDoctorFixes(storage: AccountStorageV3): { changed: boolean; actions: DoctorFixAction[] } { +function applyDoctorFixes(storage: AccountStorageV3): { + changed: boolean; + actions: DoctorFixAction[]; +} { let changed = false; const actions: DoctorFixAction[] = []; @@ -3822,7 +4172,9 @@ function applyDoctorFixes(storage: AccountStorageV3): { changed: boolean; action } } - const enabledCount = storage.accounts.filter((account) => account.enabled !== false).length; + const enabledCount = storage.accounts.filter( + (account) => account.enabled !== false, + ).length; if (storage.accounts.length > 0 && enabledCount === 0) { const index = resolveActiveIndex(storage, "codex"); const candidate = storage.accounts[index] ?? storage.accounts[0]; @@ -3879,7 +4231,8 @@ async function runDoctor(args: string[]): Promise { addCheck({ key: "storage-readable", severity: stat.size > 0 ? "ok" : "warn", - message: stat.size > 0 ? "Storage file is readable" : "Storage file is empty", + message: + stat.size > 0 ? "Storage file is readable" : "Storage file is empty", details: `${stat.size} bytes`, }); } catch (error) { @@ -3912,20 +4265,27 @@ async function runDoctor(args: string[]): Promise { const parsed = JSON.parse(raw) as unknown; if (parsed && typeof parsed === "object") { const payload = parsed as Record; - const tokens = payload.tokens && typeof payload.tokens === "object" - ? (payload.tokens as Record) - : null; - const accessToken = tokens && typeof tokens.access_token === "string" - ? tokens.access_token - : undefined; - const idToken = tokens && typeof tokens.id_token === "string" - ? tokens.id_token - : undefined; - const accountIdFromFile = tokens && typeof tokens.account_id === "string" - ? tokens.account_id - : undefined; - const emailFromFile = typeof payload.email === "string" ? payload.email : undefined; - codexAuthEmail = sanitizeEmail(emailFromFile ?? extractAccountEmail(accessToken, idToken)); + const tokens = + payload.tokens && typeof payload.tokens === "object" + ? (payload.tokens as Record) + : null; + const accessToken = + tokens && typeof tokens.access_token === "string" + ? tokens.access_token + : undefined; + const idToken = + tokens && typeof tokens.id_token === "string" + ? tokens.id_token + : undefined; + const accountIdFromFile = + tokens && typeof tokens.account_id === "string" + ? tokens.account_id + : undefined; + const emailFromFile = + typeof payload.email === "string" ? payload.email : undefined; + codexAuthEmail = sanitizeEmail( + emailFromFile ?? extractAccountEmail(accessToken, idToken), + ); codexAuthAccountId = accountIdFromFile ?? extractAccountId(accessToken); } addCheck({ @@ -3960,7 +4320,9 @@ async function runDoctor(args: string[]): Promise { if (existsSync(codexConfigPath)) { try { const configRaw = await fs.readFile(codexConfigPath, "utf-8"); - const match = configRaw.match(/^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m); + const match = configRaw.match( + /^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m, + ); if (match?.[1]) { codexAuthStoreMode = match[1].trim(); } @@ -4029,7 +4391,8 @@ async function runDoctor(args: string[]): Promise { }); const activeIndex = resolveActiveIndex(storage, "codex"); - const activeExists = activeIndex >= 0 && activeIndex < storage.accounts.length; + const activeExists = + activeIndex >= 0 && activeIndex < storage.accounts.length; addCheck({ key: "active-index", severity: activeExists ? "ok" : "error", @@ -4038,7 +4401,9 @@ async function runDoctor(args: string[]): Promise { : "Active index is out of range", }); - const disabledCount = storage.accounts.filter((a) => a.enabled === false).length; + const disabledCount = storage.accounts.filter( + (a) => a.enabled === false, + ).length; addCheck({ key: "enabled-accounts", severity: disabledCount >= storage.accounts.length ? "error" : "ok", @@ -4117,7 +4482,10 @@ async function runDoctor(args: string[]): Promise { })), ); const recommendation = recommendForecastAccount(forecastResults); - if (recommendation.recommendedIndex !== null && recommendation.recommendedIndex !== activeIndex) { + if ( + recommendation.recommendedIndex !== null && + recommendation.recommendedIndex !== activeIndex + ) { addCheck({ key: "recommended-switch", severity: "warn", @@ -4136,8 +4504,10 @@ async function runDoctor(args: string[]): Promise { const activeAccount = storage.accounts[activeIndex]; const managerActiveEmail = sanitizeEmail(activeAccount?.email); const managerActiveAccountId = activeAccount?.accountId; - const codexActiveEmail = sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; - const codexActiveAccountId = codexCliState?.activeAccountId ?? codexAuthAccountId; + const codexActiveEmail = + sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; + const codexActiveAccountId = + codexCliState?.activeAccountId ?? codexAuthAccountId; const isEmailMismatch = !!managerActiveEmail && !!codexActiveEmail && @@ -4171,10 +4541,15 @@ async function runDoctor(args: string[]): Promise { message: `Prepared active-account token refresh for account ${activeIndex + 1} (dry-run)`, }); } else { - const refreshResult = await queuedRefresh(activeAccount.refreshToken); + const refreshResult = await queuedRefresh( + activeAccount.refreshToken, + ); if (refreshResult.type === "success") { const refreshedEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), + extractAccountEmail( + refreshResult.access, + refreshResult.idToken, + ), ); const refreshedAccountId = extractAccountId(refreshResult.access); activeAccount.accessToken = refreshResult.access; @@ -4196,7 +4571,10 @@ async function runDoctor(args: string[]): Promise { key: "doctor-refresh", severity: "warn", message: "Unable to refresh active account before Codex sync", - details: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + details: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); } } @@ -4228,7 +4606,8 @@ async function runDoctor(args: string[]): Promise { addCheck({ key: "codex-active-sync", severity: "warn", - message: "Failed to sync manager active account into Codex auth state", + message: + "Failed to sync manager active account into Codex auth state", }); } } else { @@ -4273,10 +4652,13 @@ async function runDoctor(args: string[]): Promise { console.log("Doctor diagnostics"); console.log(`Storage: ${storagePath}`); - console.log(`Summary: ${summary.ok} ok, ${summary.warn} warnings, ${summary.error} errors`); + console.log( + `Summary: ${summary.ok} ok, ${summary.warn} warnings, ${summary.error} errors`, + ); console.log(""); for (const check of checks) { - const marker = check.severity === "ok" ? "✓" : check.severity === "warn" ? "!" : "✗"; + const marker = + check.severity === "ok" ? "✓" : check.severity === "warn" ? "!" : "✗"; console.log(`${marker} ${check.key}: ${check.message}`); if (check.details) { console.log(` ${check.details}`); @@ -4285,7 +4667,9 @@ async function runDoctor(args: string[]): Promise { if (options.fix) { console.log(""); if (fixActions.length > 0) { - console.log(`Auto-fix actions (${options.dryRun ? "dry-run" : "applied"}):`); + console.log( + `Auto-fix actions (${options.dryRun ? "dry-run" : "applied"}):`, + ); for (const action of fixActions) { console.log(` - ${action.message}`); } @@ -4355,7 +4739,9 @@ async function handleManageAction( const tokenResult = await runOAuthFlow(true, signInMode); if (tokenResult.type !== "success") { - console.error(`Refresh failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); + console.error( + `Refresh failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`, + ); return; } @@ -4381,8 +4767,7 @@ async function runAuthLogin(args: string[]): Promise { setStoragePath(null); let pendingMenuQuotaRefresh: Promise | null = null; let menuQuotaRefreshStatus: string | undefined; - loginFlow: - while (true) { + loginFlow: while (true) { let existingStorage = await loadAccounts(); if (existingStorage && existingStorage.accounts.length > 0) { while (true) { @@ -4394,11 +4779,17 @@ async function runAuthLogin(args: string[]): Promise { const displaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(displaySettings); const quotaCache = await loadQuotaCache(); - const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; + const shouldAutoFetchLimits = + displaySettings.menuAutoFetchLimits ?? true; const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; - const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; + const quotaTtlMs = + displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { - const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); + const staleCount = countMenuQuotaRefreshTargets( + currentStorage, + quotaCache, + quotaTtlMs, + ); if (staleCount > 0) { if (showFetchStatus) { menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; @@ -4426,7 +4817,9 @@ async function runAuthLogin(args: string[]): Promise { toExistingAccountInfo(currentStorage, quotaCache, displaySettings), { flaggedCount: flaggedStorage.accounts.length, - statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, + statusMessage: showFetchStatus + ? () => menuQuotaRefreshStatus + : undefined, }, ); @@ -4435,27 +4828,47 @@ async function runAuthLogin(args: string[]): Promise { return 0; } if (menuResult.mode === "check") { - await runActionPanel("Quick Check", "Checking local session + live status", async () => { - await runHealthCheck({ forceRefresh: false, liveProbe: true }); - }, displaySettings); + await runActionPanel( + "Quick Check", + "Checking local session + live status", + async () => { + await runHealthCheck({ forceRefresh: false, liveProbe: true }); + }, + displaySettings, + ); continue; } if (menuResult.mode === "deep-check") { - await runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { - await runHealthCheck({ forceRefresh: true, liveProbe: true }); - }, displaySettings); + await runActionPanel( + "Deep Check", + "Refreshing and testing all accounts", + async () => { + await runHealthCheck({ forceRefresh: true, liveProbe: true }); + }, + displaySettings, + ); continue; } if (menuResult.mode === "forecast") { - await runActionPanel("Best Account", "Comparing accounts", async () => { - await runForecast(["--live"]); - }, displaySettings); + await runActionPanel( + "Best Account", + "Comparing accounts", + async () => { + await runForecast(["--live"]); + }, + displaySettings, + ); continue; } if (menuResult.mode === "fix") { - await runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { - await runFix(["--live"]); - }, displaySettings); + await runActionPanel( + "Auto-Fix", + "Checking and fixing common issues", + async () => { + await runFix(["--live"]); + }, + displaySettings, + ); continue; } if (menuResult.mode === "settings") { @@ -4463,27 +4876,45 @@ async function runAuthLogin(args: string[]): Promise { continue; } if (menuResult.mode === "verify-flagged") { - await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { - await runVerifyFlagged([]); - }, displaySettings); + await runActionPanel( + "Problem Account Check", + "Checking problem accounts", + async () => { + await runVerifyFlagged([]); + }, + displaySettings, + ); continue; } if (menuResult.mode === "fresh" && menuResult.deleteAll) { - await runActionPanel("Reset Accounts", "Deleting all saved accounts", async () => { - await clearAccountsAndReset(); - console.log("Cleared saved accounts from active storage. Recovery snapshots remain available."); - }, displaySettings); + await runActionPanel( + "Reset Accounts", + "Deleting all saved accounts", + async () => { + await clearAccountsAndReset(); + console.log( + "Cleared saved accounts from active storage. Recovery snapshots remain available.", + ); + }, + displaySettings, + ); continue; } if (menuResult.mode === "manage") { - const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; + const requiresInteractiveOAuth = + typeof menuResult.refreshAccountIndex === "number"; if (requiresInteractiveOAuth) { await handleManageAction(currentStorage, menuResult); continue; } - await runActionPanel("Applying Change", "Updating selected account", async () => { - await handleManageAction(currentStorage, menuResult); - }, displaySettings); + await runActionPanel( + "Applying Change", + "Updating selected account", + async () => { + await handleManageAction(currentStorage, menuResult); + }, + displaySettings, + ); continue; } if (menuResult.mode === "add") { @@ -4496,7 +4927,9 @@ async function runAuthLogin(args: string[]): Promise { let existingCount = refreshedStorage?.accounts.length ?? 0; let forceNewLogin = existingCount > 0; let onboardingBackupDiscoveryWarning: string | null = null; - const loadNamedBackupsForOnboarding = async (): Promise => { + const loadNamedBackupsForOnboarding = async (): Promise< + NamedBackupSummary[] + > => { if (existingCount > 0) { onboardingBackupDiscoveryWarning = null; return []; @@ -4513,9 +4946,7 @@ async function runAuthLogin(args: string[]): Promise { if (code && code !== "ENOENT") { onboardingBackupDiscoveryWarning = "Named backup discovery failed. Continuing with browser or manual sign-in only."; - console.warn( - onboardingBackupDiscoveryWarning, - ); + console.warn(onboardingBackupDiscoveryWarning); } else { onboardingBackupDiscoveryWarning = null; } @@ -4525,16 +4956,19 @@ async function runAuthLogin(args: string[]): Promise { let namedBackups = await loadNamedBackupsForOnboarding(); while (true) { const latestNamedBackup = namedBackups[0] ?? null; - const preferManualMode = loginOptions.manual || isBrowserLaunchSuppressed(); + const preferManualMode = + loginOptions.manual || isBrowserLaunchSuppressed(); const signInMode = preferManualMode ? "manual" : await promptOAuthSignInMode( - latestNamedBackup, - onboardingBackupDiscoveryWarning, - ); + latestNamedBackup, + onboardingBackupDiscoveryWarning, + ); if (signInMode === "cancel") { if (existingCount > 0) { - console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); + console.log( + stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted"), + ); continue loginFlow; } console.log("Cancelled."); @@ -4546,15 +4980,18 @@ async function runAuthLogin(args: string[]): Promise { namedBackups = await loadNamedBackupsForOnboarding(); continue; } - const restoreMode = await promptBackupRestoreMode(latestAvailableBackup); + const restoreMode = await promptBackupRestoreMode( + latestAvailableBackup, + ); if (restoreMode === "back") { namedBackups = await loadNamedBackupsForOnboarding(); continue; } - const selectedBackup = restoreMode === "manual" - ? await promptManualBackupSelection(namedBackups) - : latestAvailableBackup; + const selectedBackup = + restoreMode === "manual" + ? await promptManualBackupSelection(namedBackups) + : latestAvailableBackup; if (!selectedBackup) { namedBackups = await loadNamedBackupsForOnboarding(); continue; @@ -4603,13 +5040,16 @@ async function runAuthLogin(args: string[]): Promise { displaySettings, ); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = + error instanceof Error ? error.message : String(error); if (error instanceof StorageError) { console.error(formatStorageErrorHint(error, selectedBackup.path)); } else { console.error(`Backup restore failed: ${message}`); } - const storageAfterRestoreAttempt = await loadAccounts().catch(() => null); + const storageAfterRestoreAttempt = await loadAccounts().catch( + () => null, + ); if ((storageAfterRestoreAttempt?.accounts.length ?? 0) > 0) { continue loginFlow; } @@ -4627,13 +5067,17 @@ async function runAuthLogin(args: string[]): Promise { if (tokenResult.type !== "success") { if (isUserCancelledOAuth(tokenResult)) { if (existingCount > 0) { - console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); + console.log( + stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted"), + ); continue loginFlow; } console.log("Cancelled."); return 0; } - console.error(`Login failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); + console.error( + `Login failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`, + ); return 1; } @@ -4648,7 +5092,9 @@ async function runAuthLogin(args: string[]): Promise { onboardingBackupDiscoveryWarning = null; console.log(`Added account. Total: ${count}`); if (count >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { - console.log(`Reached maximum account limit (${ACCOUNT_LIMITS.MAX_ACCOUNTS}).`); + console.log( + `Reached maximum account limit (${ACCOUNT_LIMITS.MAX_ACCOUNTS}).`, + ); break; } @@ -4656,7 +5102,6 @@ async function runAuthLogin(args: string[]): Promise { if (!addAnother) break; forceNewLogin = true; } - continue loginFlow; } } @@ -4680,7 +5125,9 @@ async function runSwitch(args: string[]): Promise { return 1; } if (targetIndex < 0 || targetIndex >= storage.accounts.length) { - console.error(`Index out of range. Valid range: 1-${storage.accounts.length}`); + console.error( + `Index out of range. Valid range: 1-${storage.accounts.length}`, + ); return 1; } @@ -4765,7 +5212,9 @@ async function persistAndSyncSelectedAccount({ const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { const tokenAccountId = extractAccountId(refreshResult.access); - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); if (account.refreshToken !== refreshResult.refresh) { account.refreshToken = refreshResult.refresh; } @@ -4828,7 +5277,9 @@ async function runBest(args: string[]): Promise { const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (options.json) { - console.log(JSON.stringify({ error: "No accounts configured." }, null, 2)); + console.log( + JSON.stringify({ error: "No accounts configured." }, null, 2), + ); } else { console.log("No accounts configured."); } @@ -4837,7 +5288,10 @@ async function runBest(args: string[]): Promise { const now = Date.now(); const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); + const liveQuotaByIndex = new Map< + number, + Awaited> + >(); const probeIdTokenByIndex = new Map(); const probeRefreshedIndices = new Set(); const probeErrors: string[] = []; @@ -4863,13 +5317,17 @@ async function runBest(args: string[]): Promise { if (account.enabled === false) continue; let probeAccessToken = account.accessToken; - let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); + let probeAccountId = + account.accountId ?? extractAccountId(account.accessToken); if (!hasUsableAccessToken(account, now)) { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type !== "success") { refreshFailures.set(i, { ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + message: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); continue; } @@ -4910,7 +5368,9 @@ async function runBest(args: string[]): Promise { } if (!probeAccessToken || !probeAccountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + probeErrors.push( + `${formatAccountLabel(account, i)}: missing accountId for live probe`, + ); continue; } @@ -4945,10 +5405,16 @@ async function runBest(args: string[]): Promise { if (recommendation.recommendedIndex === null) { await persistProbeChangesIfNeeded(); if (options.json) { - console.log(JSON.stringify({ - error: recommendation.reason, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); + console.log( + JSON.stringify( + { + error: recommendation.reason, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); } else { console.log(`No best account available: ${recommendation.reason}`); printProbeNotes(); @@ -4961,7 +5427,9 @@ async function runBest(args: string[]): Promise { if (!bestAccount) { await persistProbeChangesIfNeeded(); if (options.json) { - console.log(JSON.stringify({ error: "Best account not found." }, null, 2)); + console.log( + JSON.stringify({ error: "Best account not found." }, null, 2), + ); } else { console.log("Best account not found."); } @@ -4972,7 +5440,8 @@ async function runBest(args: string[]): Promise { const currentIndex = resolveActiveIndex(storage, "codex"); if (currentIndex === bestIndex) { const shouldSyncCurrentBest = - probeRefreshedIndices.has(bestIndex) || probeIdTokenByIndex.has(bestIndex); + probeRefreshedIndices.has(bestIndex) || + probeIdTokenByIndex.has(bestIndex); let alreadyBestSynced: boolean | undefined; if (changed) { bestAccount.lastUsed = now; @@ -4990,19 +5459,31 @@ async function runBest(args: string[]): Promise { : {}), }); if (!alreadyBestSynced && !options.json) { - console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + console.warn( + "Codex auth sync did not complete. Multi-auth routing will still use this account.", + ); } } if (options.json) { - console.log(JSON.stringify({ - message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, - accountIndex: bestIndex + 1, - reason: recommendation.reason, - ...(alreadyBestSynced !== undefined ? { synced: alreadyBestSynced } : {}), - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); + console.log( + JSON.stringify( + { + message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: bestIndex + 1, + reason: recommendation.reason, + ...(alreadyBestSynced !== undefined + ? { synced: alreadyBestSynced } + : {}), + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); } else { - console.log(`Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`); + console.log( + `Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`, + ); console.log(`Reason: ${recommendation.reason}`); printProbeNotes(); } @@ -5020,20 +5501,30 @@ async function runBest(args: string[]): Promise { }); if (options.json) { - console.log(JSON.stringify({ - message: `Switched to best account: ${formatAccountLabel(bestAccount, targetIndex)}`, - accountIndex: parsed, - reason: recommendation.reason, - synced, - wasDisabled, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); + console.log( + JSON.stringify( + { + message: `Switched to best account: ${formatAccountLabel(bestAccount, targetIndex)}`, + accountIndex: parsed, + reason: recommendation.reason, + synced, + wasDisabled, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); } else { - console.log(`Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`); + console.log( + `Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`, + ); console.log(`Reason: ${recommendation.reason}`); printProbeNotes(); if (!synced) { - console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + console.warn( + "Codex auth sync did not complete. Multi-auth routing will still use this account.", + ); } } return 0; @@ -5067,7 +5558,9 @@ export async function autoSyncActiveAccountToCodex(): Promise { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { const tokenAccountId = extractAccountId(refreshResult.access); - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); if (account.refreshToken !== refreshResult.refresh) { account.refreshToken = refreshResult.refresh; changed = true; diff --git a/lib/forecast.ts b/lib/forecast.ts index 30455746..146defde 100644 --- a/lib/forecast.ts +++ b/lib/forecast.ts @@ -33,6 +33,24 @@ export interface ForecastRecommendation { reason: string; } +export interface ForecastExplanationAccount { + index: number; + label: string; + isCurrent: boolean; + availability: ForecastAvailability; + riskScore: number; + riskLevel: ForecastRiskLevel; + waitMs: number; + reasons: string[]; + selected: boolean; +} + +export interface ForecastExplanation { + recommendedIndex: number | null; + recommendationReason: string; + considered: ForecastExplanationAccount[]; +} + export interface ForecastSummary { total: number; ready: number; @@ -75,7 +93,8 @@ function redactSensitiveReason(value: string): string { function summarizeRefreshFailure(failure: TokenFailure): string { const reasonCode = failure.reason?.trim(); if (reasonCode && reasonCode.length > 0) { - const statusCode = typeof failure.statusCode === "number" ? ` (${failure.statusCode})` : ""; + const statusCode = + typeof failure.statusCode === "number" ? ` (${failure.statusCode})` : ""; return `${reasonCode}${statusCode}`; } const fallback = failure.message?.trim() || "refresh failed"; @@ -111,7 +130,10 @@ function getRateLimitResetTimeForFamily( function getLiveQuotaWaitMs(snapshot: CodexQuotaSnapshot, now: number): number { const waits: number[] = []; - for (const resetAt of [snapshot.primary.resetAtMs, snapshot.secondary.resetAtMs]) { + for (const resetAt of [ + snapshot.primary.resetAtMs, + snapshot.secondary.resetAtMs, + ]) { if (typeof resetAt !== "number") continue; if (!Number.isFinite(resetAt)) continue; const remaining = resetAt - now; @@ -120,8 +142,12 @@ function getLiveQuotaWaitMs(snapshot: CodexQuotaSnapshot, now: number): number { return waits.length > 0 ? Math.max(...waits) : 0; } -function describeQuotaUsage(label: string, usedPercent: number | undefined): string | null { - if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) return null; +function describeQuotaUsage( + label: string, + usedPercent: number | undefined, +): string | null { + if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) + return null; const bounded = Math.max(0, Math.min(100, Math.round(usedPercent))); return `${label} quota ${bounded}% used`; } @@ -138,12 +164,18 @@ export function isHardRefreshFailure(failure: TokenFailure): boolean { ); } -function appendWaitReason(reasons: string[], prefix: string, waitMs: number): void { +function appendWaitReason( + reasons: string[], + prefix: string, + waitMs: number, +): void { if (waitMs <= 0) return; reasons.push(`${prefix} ${formatWaitTime(waitMs)}`); } -export function evaluateForecastAccount(input: ForecastAccountInput): ForecastAccountResult { +export function evaluateForecastAccount( + input: ForecastAccountInput, +): ForecastAccountResult { const { account, index, isCurrent, now } = input; const reasons: string[] = []; let availability: ForecastAvailability = "ready"; @@ -172,7 +204,10 @@ export function evaluateForecastAccount(input: ForecastAccountInput): ForecastAc } } - if (typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now) { + if ( + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now + ) { const remaining = account.coolingDownUntil - now; waitMs = Math.max(waitMs, remaining); if (availability === "ready") availability = "delayed"; @@ -180,7 +215,11 @@ export function evaluateForecastAccount(input: ForecastAccountInput): ForecastAc appendWaitReason(reasons, "cooldown remaining", remaining); } - const rateLimitResetAt = getRateLimitResetTimeForFamily(account, now, "codex"); + const rateLimitResetAt = getRateLimitResetTimeForFamily( + account, + now, + "codex", + ); if (typeof rateLimitResetAt === "number") { const remaining = Math.max(0, rateLimitResetAt - now); waitMs = Math.max(waitMs, remaining); @@ -193,7 +232,8 @@ export function evaluateForecastAccount(input: ForecastAccountInput): ForecastAc if (quota) { const primaryUsed = quota.primary.usedPercent ?? 0; const secondaryUsed = quota.secondary.usedPercent ?? 0; - const quotaPressure = quota.status === 429 || primaryUsed >= 90 || secondaryUsed >= 90; + const quotaPressure = + quota.status === 429 || primaryUsed >= 90 || secondaryUsed >= 90; if (quota.status === 429) { availability = availability === "unavailable" ? "unavailable" : "delayed"; @@ -206,9 +246,15 @@ export function evaluateForecastAccount(input: ForecastAccountInput): ForecastAc availability = "delayed"; } - const primaryUsage = describeQuotaUsage("primary", quota.primary.usedPercent); + const primaryUsage = describeQuotaUsage( + "primary", + quota.primary.usedPercent, + ); if (primaryUsage) reasons.push(primaryUsage); - const secondaryUsage = describeQuotaUsage("secondary", quota.secondary.usedPercent); + const secondaryUsage = describeQuotaUsage( + "secondary", + quota.secondary.usedPercent, + ); if (secondaryUsage) reasons.push(secondaryUsage); if (primaryUsed >= 98 || secondaryUsed >= 98) { @@ -222,9 +268,15 @@ export function evaluateForecastAccount(input: ForecastAccountInput): ForecastAc } } - const hasLastUsed = typeof account.lastUsed === "number" && Number.isFinite(account.lastUsed) && account.lastUsed > 0; + const hasLastUsed = + typeof account.lastUsed === "number" && + Number.isFinite(account.lastUsed) && + account.lastUsed > 0; const lastUsedAge = hasLastUsed ? now - account.lastUsed : null; - if (lastUsedAge !== null && (!Number.isFinite(lastUsedAge) || lastUsedAge < 0)) { + if ( + lastUsedAge !== null && + (!Number.isFinite(lastUsedAge) || lastUsedAge < 0) + ) { riskScore += 5; } else if (lastUsedAge !== null && lastUsedAge > 7 * 24 * 60 * 60 * 1000) { riskScore += 10; @@ -245,11 +297,16 @@ export function evaluateForecastAccount(input: ForecastAccountInput): ForecastAc }; } -export function evaluateForecastAccounts(inputs: ForecastAccountInput[]): ForecastAccountResult[] { +export function evaluateForecastAccounts( + inputs: ForecastAccountInput[], +): ForecastAccountResult[] { return inputs.map((input) => evaluateForecastAccount(input)); } -function compareForecastResults(a: ForecastAccountResult, b: ForecastAccountResult): number { +function compareForecastResults( + a: ForecastAccountResult, + b: ForecastAccountResult, +): number { if (a.availability !== b.availability) { const rank: Record = { ready: 0, @@ -259,7 +316,11 @@ function compareForecastResults(a: ForecastAccountResult, b: ForecastAccountResu return rank[a.availability] - rank[b.availability]; } - if (a.availability === "delayed" && b.availability === "delayed" && a.waitMs !== b.waitMs) { + if ( + a.availability === "delayed" && + b.availability === "delayed" && + a.waitMs !== b.waitMs + ) { return a.waitMs - b.waitMs; } @@ -274,12 +335,17 @@ function compareForecastResults(a: ForecastAccountResult, b: ForecastAccountResu return a.index - b.index; } -export function recommendForecastAccount(results: ForecastAccountResult[]): ForecastRecommendation { - const candidates = results.filter((result) => !result.disabled && !result.hardFailure); +export function recommendForecastAccount( + results: ForecastAccountResult[], +): ForecastRecommendation { + const candidates = results.filter( + (result) => !result.disabled && !result.hardFailure, + ); if (candidates.length === 0) { return { recommendedIndex: null, - reason: "No healthy accounts are available. Run `codex auth login` to add a fresh account.", + reason: + "No healthy accounts are available. Run `codex auth login` to add a fresh account.", }; } @@ -305,13 +371,38 @@ export function recommendForecastAccount(results: ForecastAccountResult[]): Fore }; } -export function summarizeForecast(results: ForecastAccountResult[]): ForecastSummary { +export function summarizeForecast( + results: ForecastAccountResult[], +): ForecastSummary { return { total: results.length, ready: results.filter((result) => result.availability === "ready").length, - delayed: results.filter((result) => result.availability === "delayed").length, - unavailable: results.filter((result) => result.availability === "unavailable").length, + delayed: results.filter((result) => result.availability === "delayed") + .length, + unavailable: results.filter( + (result) => result.availability === "unavailable", + ).length, highRisk: results.filter((result) => result.riskLevel === "high").length, }; } +export function buildForecastExplanation( + results: ForecastAccountResult[], + recommendation: ForecastRecommendation, +): ForecastExplanation { + return { + recommendedIndex: recommendation.recommendedIndex, + recommendationReason: recommendation.reason, + considered: results.map((result) => ({ + index: result.index, + label: result.label, + isCurrent: result.isCurrent, + availability: result.availability, + riskScore: result.riskScore, + riskLevel: result.riskLevel, + waitMs: result.waitMs, + reasons: result.reasons, + selected: recommendation.recommendedIndex === result.index, + })), + }; +} diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 930cf8fb..9bd16887 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -96,7 +96,10 @@ vi.mock("../lib/accounts.js", () => ({ tokenId: string | undefined, ) => { if (!storedAccountId) return tokenId; - if (currentAccountIdSource === "org" || currentAccountIdSource === "manual") { + if ( + currentAccountIdSource === "org" || + currentAccountIdSource === "manual" + ) { return storedAccountId; } return tokenId ?? storedAccountId; @@ -107,10 +110,16 @@ vi.mock("../lib/accounts.js", () => ({ ), selectBestAccountCandidate: vi.fn(() => null), shouldUpdateAccountIdFromToken: vi.fn( - (currentAccountIdSource: string | undefined, currentAccountId: string | undefined) => { + ( + currentAccountIdSource: string | undefined, + currentAccountId: string | undefined, + ) => { if (!currentAccountId) return true; if (!currentAccountIdSource) return true; - return currentAccountIdSource === "token" || currentAccountIdSource === "id_token"; + return ( + currentAccountIdSource === "token" || + currentAccountIdSource === "id_token" + ); }, ), })); @@ -499,33 +508,31 @@ describe("codex manager cli commands", () => { version: 1, accounts: [], }); - withAccountStorageTransactionMock.mockImplementation( - async (handler) => { - const current = await loadAccountsMock(); - return handler( - current == null - ? { + withAccountStorageTransactionMock.mockImplementation(async (handler) => { + const current = await loadAccountsMock(); + return handler( + current == null + ? { version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {}, } - : structuredClone(current), - async (storage: unknown) => saveAccountsMock(storage), - ); - }, - ); + : structuredClone(current), + async (storage: unknown) => saveAccountsMock(storage), + ); + }); withAccountAndFlaggedStorageTransactionMock.mockImplementation( async (handler) => { const current = await loadAccountsMock(); let snapshot = current == null ? { - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - } + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } : structuredClone(current); return handler( structuredClone(snapshot), @@ -576,7 +583,9 @@ describe("codex manager cli commands", () => { const { formatBackupSavedAt } = await import("../lib/codex-manager.js"); try { - expect(formatBackupSavedAt(1_710_000_000_000)).toBe("Localized Saved Time"); + expect(formatBackupSavedAt(1_710_000_000_000)).toBe( + "Localized Saved Time", + ); expect(localeSpy).toHaveBeenCalledWith(undefined, { month: "short", day: "numeric", @@ -631,6 +640,57 @@ describe("codex manager cli commands", () => { expect(payload.recommendation.recommendedIndex).toBe(0); }); + it("runs forecast in json explain mode", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + { + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "forecast", + "--json", + "--explain", + ]); + expect(exitCode).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + explanation: { + recommendedIndex: number | null; + considered: Array<{ + index: number; + selected: boolean; + reasons: string[]; + }>; + }; + }; + expect(payload.explanation.recommendedIndex).toBe(0); + expect(payload.explanation.considered).toHaveLength(2); + expect(payload.explanation.considered[0]?.selected).toBe(true); + }); + it("does not mutate loaded quota cache when live forecast save fails", async () => { const now = Date.now(); const originalQuotaCache = { @@ -859,9 +919,7 @@ describe("codex manager cli commands", () => { expect(logSpy.mock.calls[0]?.[0]).toBe("Implemented features (41)"); expect( logSpy.mock.calls.some((call) => - String(call[0]).includes( - "41. Auto-switch to best account command", - ), + String(call[0]).includes("41. Auto-switch to best account command"), ), ).toBe(true); }); @@ -930,10 +988,17 @@ describe("codex manager cli commands", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "best", "--model", "gpt-5.1"]); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "best", + "--model", + "gpt-5.1", + ]); expect(exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith("--model requires --live for codex auth best"); + expect(errorSpy).toHaveBeenCalledWith( + "--model requires --live for codex auth best", + ); expect(loadAccountsMock).not.toHaveBeenCalled(); expect(saveAccountsMock).not.toHaveBeenCalled(); expect(fetchCodexQuotaSnapshotMock).not.toHaveBeenCalled(); @@ -1074,7 +1139,9 @@ describe("codex manager cli commands", () => { ]); expect(exitCode).toBe(0); - expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes( + 1, + ); expect(saveAccountsMock).toHaveBeenCalledWith( expect.objectContaining({ accounts: expect.arrayContaining([ @@ -1131,7 +1198,9 @@ describe("codex manager cli commands", () => { ]); expect(exitCode).toBe(0); - expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes( + 1, + ); const savedStorage = saveAccountsMock.mock.calls.at(-1)?.[0]; expect(savedStorage).toEqual( expect.objectContaining({ @@ -1199,7 +1268,11 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); const savedStorage = saveAccountsMock.mock.calls.at(-1)?.[0] as { - accounts: Array<{ accountId?: string; accountIdSource?: string; refreshToken?: string }>; + accounts: Array<{ + accountId?: string; + accountIdSource?: string; + refreshToken?: string; + }>; }; expect(savedStorage.accounts[0]).toEqual( expect.objectContaining({ @@ -1556,21 +1629,19 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - const extractAccountEmailMock = vi.mocked(accountsModule.extractAccountEmail); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-stale") return "shared-workspace"; - if (accessToken === "access-alpha-refreshed") return "shared-workspace"; - if (accessToken === "access-beta") return "shared-workspace"; - return "acc_test"; - }, - ); - extractAccountEmailMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; - return undefined; - }, + const extractAccountEmailMock = vi.mocked( + accountsModule.extractAccountEmail, ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-stale") return "shared-workspace"; + if (accessToken === "access-alpha-refreshed") return "shared-workspace"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }); + extractAccountEmailMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + return undefined; + }); fetchCodexQuotaSnapshotMock .mockResolvedValueOnce({ status: 200, @@ -1690,21 +1761,19 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - const extractAccountEmailMock = vi.mocked(accountsModule.extractAccountEmail); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-stale") return "shared-workspace"; - if (accessToken === "access-alpha-refreshed") return "shared-workspace"; - if (accessToken === "access-beta") return "shared-workspace"; - return "acc_test"; - }, - ); - extractAccountEmailMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; - return undefined; - }, + const extractAccountEmailMock = vi.mocked( + accountsModule.extractAccountEmail, ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-stale") return "shared-workspace"; + if (accessToken === "access-alpha-refreshed") return "shared-workspace"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }); + extractAccountEmailMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + return undefined; + }); fetchCodexQuotaSnapshotMock .mockResolvedValueOnce({ status: 200, @@ -1850,10 +1919,12 @@ describe("codex manager cli commands", () => { ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - await expect(runCodexMultiAuthCli(["auth", "check"])).rejects.toMatchObject({ - code: "EBUSY", - message: "save failed", - }); + await expect(runCodexMultiAuthCli(["auth", "check"])).rejects.toMatchObject( + { + code: "EBUSY", + message: "save failed", + }, + ); expect(originalQuotaCache).toEqual({ byAccountId: {}, byEmail: {}, @@ -1959,7 +2030,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2080,7 +2153,12 @@ describe("codex manager cli commands", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "best", "--live", "--json"]); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "best", + "--live", + "--json", + ]); expect(exitCode).toBe(0); expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); @@ -2128,7 +2206,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2282,9 +2362,11 @@ describe("codex manager cli commands", () => { ); expect(saveAccountsMock).not.toHaveBeenCalled(); expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("Already on best account 1"), - )).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Already on best account 1"), + ), + ).toBe(true); }); it("syncs refreshed current best account during live best check", async () => { @@ -2304,7 +2386,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2350,9 +2434,11 @@ describe("codex manager cli commands", () => { idToken: "id-best-next", }), ); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("Already on best account 1"), - )).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Already on best account 1"), + ), + ).toBe(true); }); it("reports synced=false in already-best json output when live sync fails", async () => { @@ -2372,7 +2458,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2402,7 +2490,12 @@ describe("codex manager cli commands", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "best", "--live", "--json"]); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "best", + "--live", + "--json", + ]); expect(exitCode).toBe(0); expect(saveAccountsMock).toHaveBeenCalledTimes(1); @@ -2506,7 +2599,9 @@ describe("codex manager cli commands", () => { }, ], }); - fetchCodexQuotaSnapshotMock.mockRejectedValueOnce(new Error("network timeout")); + fetchCodexQuotaSnapshotMock.mockRejectedValueOnce( + new Error("network timeout"), + ); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -2515,15 +2610,21 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(saveAccountsMock).not.toHaveBeenCalled(); expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("Already on best account 1"), - )).toBe(true); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("Live check notes (1)"), - )).toBe(true); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("network timeout"), - )).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Already on best account 1"), + ), + ).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Live check notes (1)"), + ), + ).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("network timeout"), + ), + ).toBe(true); }); it("reuses the queued refresh result across concurrent live best runs", async () => { @@ -2543,7 +2644,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2591,7 +2694,12 @@ describe("codex manager cli commands", () => { const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const firstRun = runCodexMultiAuthCli(["auth", "best", "--live", "--json"]); - const secondRun = runCodexMultiAuthCli(["auth", "best", "--live", "--json"]); + const secondRun = runCodexMultiAuthCli([ + "auth", + "best", + "--live", + "--json", + ]); refreshDeferred.resolve({ type: "success", @@ -2601,7 +2709,10 @@ describe("codex manager cli commands", () => { idToken: "id-best-next", }); - const [firstExitCode, secondExitCode] = await Promise.all([firstRun, secondRun]); + const [firstExitCode, secondExitCode] = await Promise.all([ + firstRun, + secondRun, + ]); expect(firstExitCode).toBe(0); expect(secondExitCode).toBe(0); @@ -2612,14 +2723,16 @@ describe("codex manager cli commands", () => { expect(storageState.accounts[0]?.refreshToken).toBe("refresh-best-next"); expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(2); for (const call of setCodexCliActiveSelectionMock.mock.calls) { - expect(call[0]).toEqual(expect.objectContaining({ - accountId: "acc_test", - email: "best@example.com", - accessToken: "access-best-next", - refreshToken: "refresh-best-next", - expiresAt: now + 3_600_000, - idToken: "id-best-next", - })); + expect(call[0]).toEqual( + expect.objectContaining({ + accountId: "acc_test", + email: "best@example.com", + accessToken: "access-best-next", + refreshToken: "refresh-best-next", + expiresAt: now + 3_600_000, + idToken: "id-best-next", + }), + ); } expect(logSpy.mock.calls).toHaveLength(2); for (const call of logSpy.mock.calls) { @@ -3022,7 +3135,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -3064,12 +3179,16 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(waitForCodeMock).not.toHaveBeenCalled(); - expect(renderedLogs.some((entry) => entry.includes("Manual mode active"))).toBe( - true, - ); - expect(renderedLogs.some((entry) => entry.includes("No callback received"))).toBe(false); + expect( + renderedLogs.some((entry) => entry.includes("Manual mode active")), + ).toBe(true); + expect( + renderedLogs.some((entry) => entry.includes("No callback received")), + ).toBe(false); expect(storageState.accounts).toHaveLength(1); }); @@ -3138,7 +3257,9 @@ describe("codex manager cli commands", () => { mtimeMs: now - 60_000, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); selectMock .mockResolvedValueOnce("restore-backup") @@ -3163,9 +3284,10 @@ describe("codex manager cli commands", () => { expect(signInItems.map((item) => item.label)).toContain( "Recover saved accounts", ); - expect(signInItems.find((item) => item.label === "Recover saved accounts")?.kind).toBe( - "heading", - ); + expect( + signInItems.find((item) => item.label === "Recover saved accounts") + ?.kind, + ).toBe("heading"); expect( signInItems.find((item) => item.label === "Restore Saved Backup")?.hint, ).toBe("last-good.json | 2 accounts | saved Localized Saved Time"); @@ -3180,7 +3302,9 @@ describe("codex manager cli commands", () => { "/mock/backups/last-good.json", { persist: false }, ); - expect(confirmMock).toHaveBeenCalledWith("Load last-good.json (2 accounts)?"); + expect(confirmMock).toHaveBeenCalledWith( + "Load last-good.json (2 accounts)?", + ); expect(saveAccountsMock).toHaveBeenCalledTimes(1); expect(saveAccountsMock.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ @@ -3280,7 +3404,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(restoreAccountsFromBackupMock).not.toHaveBeenCalled(); - expect(confirmMock).not.toHaveBeenCalledWith("Load manual-choice.json (1 account)?"); + expect(confirmMock).not.toHaveBeenCalledWith( + "Load manual-choice.json (1 account)?", + ); expect(saveAccountsMock).not.toHaveBeenCalled(); expect(promptLoginModeMock).not.toHaveBeenCalled(); }); @@ -3356,7 +3482,9 @@ describe("codex manager cli commands", () => { expect.any(String), ]); expect(restoreAccountsFromBackupMock).not.toHaveBeenCalled(); - expect(confirmMock).toHaveBeenCalledWith("Load replacement.json (2 accounts)?"); + expect(confirmMock).toHaveBeenCalledWith( + "Load replacement.json (2 accounts)?", + ); }); it("does not offer backup restore on onboarding when accounts already exist", async () => { @@ -3494,7 +3622,9 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); setCodexCliActiveSelectionMock.mockResolvedValueOnce(false); selectMock .mockResolvedValueOnce("restore-backup") @@ -3556,7 +3686,9 @@ describe("codex manager cli commands", () => { expect(saveAccountsMock).not.toHaveBeenCalled(); expect(promptLoginModeMock).not.toHaveBeenCalled(); expect(selectMock).toHaveBeenCalledTimes(3); - expect(errorSpy).toHaveBeenCalledWith("Backup restore failed: File is busy"); + expect(errorSpy).toHaveBeenCalledWith( + "Backup restore failed: File is busy", + ); }); it("prints the storage hint only once when restore fails with StorageError", async () => { @@ -3627,8 +3759,12 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); - saveAccountsMock.mockRejectedValueOnce(new Error("save selected account failed")); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); + saveAccountsMock.mockRejectedValueOnce( + new Error("save selected account failed"), + ); selectMock .mockResolvedValueOnce("restore-backup") .mockResolvedValueOnce("latest") @@ -3688,7 +3824,9 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); selectMock .mockResolvedValueOnce("restore-backup") .mockResolvedValueOnce("latest"); @@ -3763,7 +3901,9 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(confirmMock).toHaveBeenCalledWith("Load manual-choice.json (1 account)?"); + expect(confirmMock).toHaveBeenCalledWith( + "Load manual-choice.json (1 account)?", + ); expect(restoreAccountsFromBackupMock).not.toHaveBeenCalled(); expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); }); @@ -3830,17 +3970,21 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(selectMock).toHaveBeenCalled(); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(waitForCodeMock).not.toHaveBeenCalled(); const signInItems = selectMock.mock.calls[0]?.[0] as Array<{ label: string; value?: string; }>; expect(signInItems.some((item) => item.value === "manual")).toBe(true); - expect(renderedLogs.some((entry) => entry.includes("Manual mode active"))).toBe( - true, - ); - expect(renderedLogs.some((entry) => entry.includes("No callback received"))).toBe(false); + expect( + renderedLogs.some((entry) => entry.includes("Manual mode active")), + ).toBe(true); + expect( + renderedLogs.some((entry) => entry.includes("No callback received")), + ).toBe(false); expect(logSpy).toHaveBeenCalledWith("Refreshed account 1."); }); @@ -3853,7 +3997,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -3881,9 +4027,13 @@ describe("codex manager cli commands", () => { const browserModule = await import("../lib/auth/browser.js"); const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); - vi.mocked(browserModule.isBrowserLaunchSuppressed).mockReturnValueOnce(true); + vi.mocked(browserModule.isBrowserLaunchSuppressed).mockReturnValueOnce( + true, + ); const serverModule = await import("../lib/auth/server.js"); - const startLocalOAuthServerMock = vi.mocked(serverModule.startLocalOAuthServer); + const startLocalOAuthServerMock = vi.mocked( + serverModule.startLocalOAuthServer, + ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); @@ -3904,7 +4054,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -3938,7 +4090,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(1); }); @@ -3950,7 +4104,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -3965,7 +4121,9 @@ describe("codex manager cli commands", () => { state: "oauth-state", url: "https://auth.openai.com/mock", }); - const exchangeAuthorizationCodeMock = vi.mocked(authModule.exchangeAuthorizationCode); + const exchangeAuthorizationCodeMock = vi.mocked( + authModule.exchangeAuthorizationCode, + ); const browserModule = await import("../lib/auth/browser.js"); const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); @@ -3977,7 +4135,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(exchangeAuthorizationCodeMock).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(0); }); @@ -3991,7 +4151,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -4025,7 +4187,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(1); }); @@ -4037,7 +4201,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -4050,7 +4216,9 @@ describe("codex manager cli commands", () => { state: "oauth-state", url: "https://auth.openai.com/mock", }); - const exchangeAuthorizationCodeMock = vi.mocked(authModule.exchangeAuthorizationCode); + const exchangeAuthorizationCodeMock = vi.mocked( + authModule.exchangeAuthorizationCode, + ); const browserModule = await import("../lib/auth/browser.js"); const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); @@ -4062,7 +4230,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(exchangeAuthorizationCodeMock).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(0); }); @@ -4108,7 +4278,9 @@ describe("codex manager cli commands", () => { mtimeMs: now - 60_000, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); selectMock .mockResolvedValueOnce("restore-backup") @@ -4130,7 +4302,9 @@ describe("codex manager cli commands", () => { "/mock/backups/manual-choice.json", { persist: false }, ); - expect(confirmMock).toHaveBeenCalledWith("Load manual-choice.json (1 account)?"); + expect(confirmMock).toHaveBeenCalledWith( + "Load manual-choice.json (1 account)?", + ); expect(saveAccountsMock).toHaveBeenCalledTimes(1); expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -4186,7 +4360,9 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); selectMock .mockResolvedValueOnce("restore-backup") @@ -4242,9 +4418,7 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - selectMock - .mockResolvedValueOnce("browser") - .mockResolvedValueOnce("cancel"); + selectMock.mockResolvedValueOnce("browser").mockResolvedValueOnce("cancel"); promptAddAnotherAccountMock.mockResolvedValueOnce(true); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); @@ -4292,12 +4466,12 @@ describe("codex manager cli commands", () => { label: string; value?: string; }>; - expect(firstSignInItems.some((item) => item.value === "restore-backup")).toBe( - true, - ); - expect(secondSignInItems.some((item) => item.value === "restore-backup")).toBe( - false, - ); + expect( + firstSignInItems.some((item) => item.value === "restore-backup"), + ).toBe(true); + expect( + secondSignInItems.some((item) => item.value === "restore-backup"), + ).toBe(false); expect(promptLoginModeMock).toHaveBeenCalledTimes(1); }); it("preserves distinct same-email workspaces when oauth login reuses a refresh token", async () => { @@ -4908,13 +5082,11 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha") return "workspace-alpha"; - if (accessToken === "access-beta") return "workspace-beta"; - return "acc_test"; - }, - ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha") return "workspace-alpha"; + if (accessToken === "access-beta") return "workspace-beta"; + return "acc_test"; + }); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -4993,13 +5165,11 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha") return "workspace-alpha"; - if (accessToken === "access-beta") return "workspace-beta"; - return "acc_test"; - }, - ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha") return "workspace-alpha"; + if (accessToken === "access-beta") return "workspace-beta"; + return "acc_test"; + }); fetchCodexQuotaSnapshotMock .mockResolvedValueOnce({ status: 200, @@ -5745,9 +5915,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(readSettingsHubPanelContract()).toEqual( - SETTINGS_HUB_MENU_ORDER, - ); + expect(readSettingsHubPanelContract()).toEqual(SETTINGS_HUB_MENU_ORDER); expect(selectSequence.remaining()).toBe(0); expect(saveDashboardDisplaySettingsMock).toHaveBeenCalled(); expect(savePluginConfigMock).toHaveBeenCalledTimes(1); @@ -5774,7 +5942,17 @@ describe("codex manager cli commands", () => { it("runs experimental oc sync with mandatory preview before apply", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ + kind: "target", + descriptor: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + }); planOcChatgptSyncMock.mockResolvedValue({ kind: "ready", target: { @@ -5830,7 +6008,11 @@ describe("codex manager cli commands", () => { expect(applyOcChatgptSyncMock).toHaveBeenCalledOnce(); expect(selectMock).toHaveBeenCalledWith( expect.arrayContaining([ - expect.objectContaining({ label: expect.stringContaining("Active selection: preserve-destination") }), + expect.objectContaining({ + label: expect.stringContaining( + "Active selection: preserve-destination", + ), + }), ]), expect.any(Object), ); @@ -5908,10 +6090,24 @@ describe("codex manager cli commands", () => { it("shows guidance when experimental oc sync target is ambiguous or unreadable", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ + kind: "target", + descriptor: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + }); planOcChatgptSyncMock.mockResolvedValue({ kind: "blocked-ambiguous", - detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] }, + detection: { + kind: "ambiguous", + reason: "multiple targets", + candidates: [], + }, }); const selectSequence = queueSettingsSelectSequence([ { type: "experimental" }, @@ -5930,12 +6126,14 @@ describe("codex manager cli commands", () => { expect(applyOcChatgptSyncMock).not.toHaveBeenCalled(); }); - it("exports named pool backup from experimental settings", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); promptQuestionMock.mockResolvedValueOnce("backup-2026-03-10"); - runNamedBackupExportMock.mockResolvedValueOnce({ kind: "exported", path: "/mock/backups/backup-2026-03-10.json" }); + runNamedBackupExportMock.mockResolvedValueOnce({ + kind: "exported", + path: "/mock/backups/backup-2026-03-10.json", + }); const selectSequence = queueSettingsSelectSequence([ { type: "experimental" }, { type: "backup" }, @@ -5950,7 +6148,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(selectSequence.remaining()).toBe(0); expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "backup-2026-03-10" }); + expect(runNamedBackupExportMock).toHaveBeenCalledWith({ + name: "backup-2026-03-10", + }); }); it("supports backup hotkeys from experimental menu through result status", async () => { @@ -5984,7 +6184,10 @@ describe("codex manager cli commands", () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); promptQuestionMock.mockResolvedValueOnce("../bad-name"); - runNamedBackupExportMock.mockResolvedValueOnce({ kind: "collision", path: "/mock/backups/bad-name.json" }); + runNamedBackupExportMock.mockResolvedValueOnce({ + kind: "collision", + path: "/mock/backups/bad-name.json", + }); const selectSequence = queueSettingsSelectSequence([ { type: "experimental" }, { type: "backup" }, @@ -5999,18 +6202,49 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(selectSequence.remaining()).toBe(0); expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "../bad-name" }); + expect(runNamedBackupExportMock).toHaveBeenCalledWith({ + name: "../bad-name", + }); }); it("backs out of experimental sync preview without applying", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); - normalizeAccountStorageMock.mockReturnValue({ version: 3, accounts: [], activeIndex: 0 }); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ + kind: "target", + descriptor: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + }); + normalizeAccountStorageMock.mockReturnValue({ + version: 3, + accounts: [], + activeIndex: 0, + }); planOcChatgptSyncMock.mockResolvedValue({ kind: "ready", - target: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" }, - preview: { payload: { version: 3, accounts: [], activeIndex: 0 }, merged: { version: 3, accounts: [], activeIndex: 0 }, toAdd: [], toUpdate: [], toSkip: [], unchangedDestinationOnly: [], activeSelectionBehavior: "preserve-destination" }, + target: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + preview: { + payload: { version: 3, accounts: [], activeIndex: 0 }, + merged: { version: 3, accounts: [], activeIndex: 0 }, + toAdd: [], + toUpdate: [], + toSkip: [], + unchangedDestinationOnly: [], + activeSelectionBehavior: "preserve-destination", + }, payload: { version: 3, accounts: [], activeIndex: 0 }, destination: { version: 3, accounts: [], activeIndex: 0 }, }); @@ -6078,7 +6312,11 @@ describe("codex manager cli commands", () => { }); planOcChatgptSyncMock.mockResolvedValue({ kind: "blocked-ambiguous", - detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] }, + detection: { + kind: "ambiguous", + reason: "multiple targets", + candidates: [], + }, }); const selectSequence = queueSettingsSelectSequence([ { type: "experimental" }, @@ -6237,9 +6475,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(readSettingsHubPanelContract()).toEqual( - SETTINGS_HUB_MENU_ORDER, - ); + expect(readSettingsHubPanelContract()).toEqual(SETTINGS_HUB_MENU_ORDER); expect(selectSequence.remaining()).toBe(0); expect(saveDashboardDisplaySettingsMock).toHaveBeenCalledTimes(4); expect(saveDashboardDisplaySettingsMock.mock.calls[0]?.[0]).toEqual( @@ -6276,7 +6512,6 @@ describe("codex manager cli commands", () => { ); }); - it("moves guardian controls into experimental settings", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); @@ -6299,7 +6534,8 @@ describe("codex manager cli commands", () => { expect(savePluginConfigMock).toHaveBeenCalledWith( expect.objectContaining({ proactiveRefreshGuardian: !(defaults.proactiveRefreshGuardian ?? false), - proactiveRefreshIntervalMs: (defaults.proactiveRefreshIntervalMs ?? 60000) + 60000, + proactiveRefreshIntervalMs: + (defaults.proactiveRefreshIntervalMs ?? 60000) + 60000, }), ); }); @@ -6359,8 +6595,7 @@ describe("codex manager cli commands", () => { preemptiveQuotaRemainingPercent5h: (defaults.preemptiveQuotaRemainingPercent5h ?? 0) + 1, storageBackupEnabled: !(defaults.storageBackupEnabled ?? false), - tokenRefreshSkewMs: - (defaults.tokenRefreshSkewMs ?? 60_000) + 10_000, + tokenRefreshSkewMs: (defaults.tokenRefreshSkewMs ?? 60_000) + 10_000, parallelProbing: !(defaults.parallelProbing ?? false), fetchTimeoutMs: (defaults.fetchTimeoutMs ?? 60_000) + 5_000, }), @@ -7121,7 +7356,8 @@ describe("codex manager cli commands", () => { ); vi.mocked(accountsModule.extractAccountEmail).mockImplementation( (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + if (accessToken === "access-alpha-refreshed") + return "owner@example.com"; return undefined; }, ); @@ -7243,21 +7479,19 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - const extractAccountEmailMock = vi.mocked(accountsModule.extractAccountEmail); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-stale") return "shared-workspace"; - if (accessToken === "access-alpha-refreshed") return "shared-workspace"; - if (accessToken === "access-beta") return "shared-workspace"; - return "acc_test"; - }, - ); - extractAccountEmailMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; - return undefined; - }, + const extractAccountEmailMock = vi.mocked( + accountsModule.extractAccountEmail, ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-stale") return "shared-workspace"; + if (accessToken === "access-alpha-refreshed") return "shared-workspace"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }); + extractAccountEmailMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + return undefined; + }); fetchCodexQuotaSnapshotMock .mockResolvedValueOnce({ status: 200, @@ -7419,7 +7653,8 @@ describe("codex manager cli commands", () => { ); vi.mocked(accountsModule.extractAccountEmail).mockImplementation( (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + if (accessToken === "access-alpha-refreshed") + return "owner@example.com"; return undefined; }, ); From fb40881ae61959cd510d0a5d83b99e769f290426 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:42:08 +0800 Subject: [PATCH 2/4] test: cover forecast explain output --- test/codex-manager-cli.test.ts | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 9bd16887..29ccf0d5 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -691,6 +691,52 @@ describe("codex manager cli commands", () => { expect(payload.explanation.considered[0]?.selected).toBe(true); }); + it("prints explain details in text forecast mode", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + { + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "forecast", + "--explain", + ]); + expect(exitCode).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + expect( + logSpy.mock.calls.some((call) => String(call[0]).includes("Explain:")), + ).toBe(true); + expect( + logSpy.mock.calls.some( + (call) => + String(call[0]).includes("ready, low risk (0)") || + String(call[0]).includes("Lowest risk ready account"), + ), + ).toBe(true); + }); + it("does not mutate loaded quota cache when live forecast save fails", async () => { const now = Date.now(); const originalQuotaCache = { From 07222d890954a4422260cc36d64710cf960bace3 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:56:37 +0800 Subject: [PATCH 3/4] docs: document forecast explain flag --- docs/reference/commands.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index c69b3065..d416564d 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -47,6 +47,7 @@ Compatibility aliases are supported: | --- | --- | --- | | `--manual`, `--no-browser` | login | Skip browser launch and use manual callback flow | | `--json` | verify-flagged, forecast, report, fix, doctor | Print machine-readable output | +| `--explain` | forecast | Include recommendation reasoning details | | `--live` | forecast, report, fix | Use live probe before decisions/output | | `--dry-run` | verify-flagged, fix, doctor | Preview without writing storage | | `--model ` | forecast, report, fix | Specify model for live probe paths | @@ -117,7 +118,7 @@ Health and planning: ```bash codex auth check -codex auth forecast --live --model gpt-5-codex +codex auth forecast --live --explain --model gpt-5-codex codex auth report --live --json ``` From 7ec5b1cfa0aef47289695fd5558e8aaacf9cd1c6 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:03:01 +0800 Subject: [PATCH 4/4] fix: keep forecast explain output visible --- docs/reference/commands.md | 3 +- lib/codex-manager.ts | 26 +++---- test/codex-manager-cli.test.ts | 123 ++++++++++++++++++++++++++++++++- 3 files changed, 138 insertions(+), 14 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index d416564d..5b1c8100 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -47,7 +47,7 @@ Compatibility aliases are supported: | --- | --- | --- | | `--manual`, `--no-browser` | login | Skip browser launch and use manual callback flow | | `--json` | verify-flagged, forecast, report, fix, doctor | Print machine-readable output | -| `--explain` | forecast | Include recommendation reasoning details | +| `--explain` | forecast | Include recommendation reasoning details in text and JSON output, even when recommendation summary lines are hidden | | `--live` | forecast, report, fix | Use live probe before decisions/output | | `--dry-run` | verify-flagged, fix, doctor | Preview without writing storage | | `--model ` | forecast, report, fix | Specify model for live probe paths | @@ -63,6 +63,7 @@ Compatibility aliases are supported: - `codex auth login --manual` and `codex auth login --no-browser` force the manual callback flow instead of launching a browser. - `CODEX_AUTH_NO_BROWSER=1` suppresses browser launch for automation/headless sessions. False-like values such as `0` and `false` do not disable browser launch by themselves. - In non-TTY/manual shells, pass the full redirect URL on stdin, for example: `echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual`. +- `codex auth forecast --explain` now keeps the explain breakdown visible in text mode even when dashboard settings hide recommendation summary lines. Pair it with `--json` for machine-readable reasoning snapshots. - No new npm scripts or storage migration steps were introduced for this auth-flow update. --- diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index bf00c254..3fd15fca 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -2742,7 +2742,7 @@ async function runForecast(args: string[]): Promise { return 1; } const options = parsedArgs.options; - const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; + const display = await loadDashboardDisplaySettings(); const quotaCache = options.live ? await loadQuotaCache() : null; const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; let quotaCacheChanged = false; @@ -2939,8 +2939,11 @@ async function runForecast(args: string[]): Promise { ); } - if (display.showRecommendations) { + if (display.showRecommendations || options.explain) { console.log(""); + } + + if (display.showRecommendations) { if (recommendation.recommendedIndex !== null) { const index = recommendation.recommendedIndex; const account = forecastResults.find((result) => result.index === index); @@ -2962,16 +2965,15 @@ async function runForecast(args: string[]): Promise { `${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, ); } - if (options.explain) { - console.log(""); - console.log(stylePromptText("Explain:", "accent")); - for (const item of explanation.considered) { - const prefix = item.selected ? "*" : "-"; - const reasons = item.reasons.slice(0, 3).join("; "); - console.log( - `${stylePromptText(prefix, item.selected ? "success" : "muted")} ${stylePromptText(`${item.index + 1}. ${item.label}`, item.selected ? "success" : "accent")} ${stylePromptText("|", "muted")} ${stylePromptText(`${item.availability}, ${item.riskLevel} risk (${item.riskScore})`, item.selected ? "success" : "muted")}${reasons ? ` ${stylePromptText("|", "muted")} ${stylePromptText(reasons, "muted")}` : ""}`, - ); - } + } + if (options.explain) { + console.log(stylePromptText("Explain:", "accent")); + for (const item of explanation.considered) { + const prefix = item.selected ? "*" : "-"; + const reasons = item.reasons.slice(0, 3).join("; "); + console.log( + `${stylePromptText(prefix, item.selected ? "success" : "muted")} ${stylePromptText(`${item.index + 1}. ${item.label}`, item.selected ? "success" : "accent")} ${stylePromptText("|", "muted")} ${stylePromptText(`${item.availability}, ${item.riskLevel} risk (${item.riskScore})`, item.selected ? "success" : "muted")}${reasons ? ` ${stylePromptText("|", "muted")} ${stylePromptText(reasons, "muted")}` : ""}`, + ); } } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 29ccf0d5..5e674232 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -634,10 +634,12 @@ describe("codex manager cli commands", () => { command: string; summary: { total: number }; recommendation: { recommendedIndex: number | null }; + explanation?: unknown; }; expect(payload.command).toBe("forecast"); expect(payload.summary.total).toBe(2); expect(payload.recommendation.recommendedIndex).toBe(0); + expect(payload.explanation).toBeUndefined(); }); it("runs forecast in json explain mode", async () => { @@ -688,7 +690,13 @@ describe("codex manager cli commands", () => { }; expect(payload.explanation.recommendedIndex).toBe(0); expect(payload.explanation.considered).toHaveLength(2); - expect(payload.explanation.considered[0]?.selected).toBe(true); + expect(payload.explanation.considered.map((item) => item.selected)).toEqual([ + true, + false, + ]); + expect( + payload.explanation.considered.find((item) => item.selected)?.index, + ).toBe(payload.explanation.recommendedIndex); }); it("prints explain details in text forecast mode", async () => { @@ -737,6 +745,119 @@ describe("codex manager cli commands", () => { ).toBe(true); }); + it("prints explain details even when recommendation summaries are hidden", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + { + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: false, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "forecast", + "--explain", + ]); + expect(exitCode).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + expect( + logSpy.mock.calls.some((call) => String(call[0]).includes("Explain:")), + ).toBe(true); + expect( + logSpy.mock.calls.some((call) => String(call[0]).includes("Best next account:")), + ).toBe(false); + }); + + it("keeps forecast json explain output isolated across concurrent runs", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + { + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }); + + const outputs: string[] = []; + const logSpy = vi + .spyOn(console, "log") + .mockImplementation((value?: unknown) => outputs.push(String(value))); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const [plainExitCode, explainExitCode] = await Promise.all([ + runCodexMultiAuthCli(["auth", "forecast", "--json"]), + runCodexMultiAuthCli(["auth", "forecast", "--json", "--explain"]), + ]); + expect(plainExitCode).toBe(0); + expect(explainExitCode).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledTimes(2); + + const payloads = outputs.map((entry) => + JSON.parse(entry), + ) as Array<{ + explanation?: { + recommendedIndex: number | null; + considered: Array<{ selected: boolean }>; + }; + recommendation?: { recommendedIndex: number | null }; + }>; + expect(payloads.filter((payload) => payload.explanation)).toHaveLength(1); + const explainPayload = payloads.find((payload) => payload.explanation); + const plainPayload = payloads.find((payload) => !payload.explanation); + expect(plainPayload?.recommendation?.recommendedIndex).toBe(0); + expect(plainPayload?.explanation).toBeUndefined(); + expect(explainPayload?.explanation?.recommendedIndex).toBe(0); + expect( + explainPayload?.explanation?.considered.some((item) => item.selected), + ).toBe(true); + }); + it("does not mutate loaded quota cache when live forecast save fails", async () => { const now = Date.now(); const originalQuotaCache = {