diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 7f1be092..5c26f290 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -6,7 +6,6 @@ import { extractAccountEmail, extractAccountId, formatAccountLabel, - formatCooldown, formatWaitTime, getAccountIdCandidates, resolveRequestAccountId, @@ -37,6 +36,10 @@ import { loadCodexCliState, } from "./codex-cli/state.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; +import { + runFeaturesCommand, + runStatusCommand, +} from "./codex-manager/commands/status.js"; import { runSwitchCommand } from "./codex-manager/commands/switch.js"; import { applyUiThemeFromDashboardSettings, @@ -431,12 +434,7 @@ const IMPLEMENTED_FEATURES: ImplementedFeature[] = [ ]; function runFeaturesReport(): number { - console.log(`Implemented features (${IMPLEMENTED_FEATURES.length})`); - console.log(""); - for (const feature of IMPLEMENTED_FEATURES) { - console.log(`${feature.id}. ${feature.name}`); - } - return 0; + return runFeaturesCommand({ implementedFeatures: IMPLEMENTED_FEATURES }); } function resolveActiveIndex( @@ -1985,38 +1983,13 @@ async function syncSelectionToCodex( } async function showAccountStatus(): Promise { - setStoragePath(null); - const storage = await loadAccounts(); - const path = getStoragePath(); - if (!storage || storage.accounts.length === 0) { - console.log("No accounts configured."); - console.log(`Storage: ${path}`); - return; - } - - const now = Date.now(); - const activeIndex = resolveActiveIndex(storage, "codex"); - console.log(`Accounts (${storage.accounts.length})`); - console.log(`Storage: ${path}`); - console.log(""); - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account) continue; - const label = formatAccountLabel(account, i); - const markers: string[] = []; - if (i === activeIndex) markers.push("current"); - if (account.enabled === false) markers.push("disabled"); - const rateLimit = formatRateLimitEntry(account, now, "codex"); - if (rateLimit) markers.push("rate-limited"); - 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"; - console.log(`${i + 1}. ${label}${markerLabel} ${lastUsed}`); - } + await runStatusCommand({ + setStoragePath, + getStoragePath, + loadAccounts, + resolveActiveIndex, + formatRateLimitEntry, + }); } interface HealthCheckOptions { diff --git a/lib/codex-manager/commands/status.ts b/lib/codex-manager/commands/status.ts new file mode 100644 index 00000000..4851795b --- /dev/null +++ b/lib/codex-manager/commands/status.ts @@ -0,0 +1,82 @@ +import { + formatAccountLabel, + formatCooldown, + formatWaitTime, +} from "../../accounts.js"; +import type { ModelFamily } from "../../prompts/codex.js"; +import type { AccountStorageV3 } from "../../storage.js"; + +type LoadedStorage = AccountStorageV3 | null; + +export interface StatusCommandDeps { + setStoragePath: (path: string | null) => void; + getStoragePath: () => string | null; + loadAccounts: () => Promise; + resolveActiveIndex: ( + storage: AccountStorageV3, + family?: ModelFamily, + ) => number; + formatRateLimitEntry: ( + account: AccountStorageV3["accounts"][number], + now: number, + family: ModelFamily, + ) => string | null; + getNow?: () => number; + logInfo?: (message: string) => void; +} + +export async function runStatusCommand( + deps: StatusCommandDeps, +): Promise { + deps.setStoragePath(null); + const storage = await deps.loadAccounts(); + const path = deps.getStoragePath(); + const logInfo = deps.logInfo ?? console.log; + if (!storage || storage.accounts.length === 0) { + logInfo("No accounts configured."); + logInfo(`Storage: ${path}`); + return 0; + } + + const now = deps.getNow?.() ?? Date.now(); + const activeIndex = deps.resolveActiveIndex(storage, "codex"); + logInfo(`Accounts (${storage.accounts.length})`); + logInfo(`Storage: ${path}`); + logInfo(""); + + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account) continue; + const label = formatAccountLabel(account, i); + const markers: string[] = []; + if (i === activeIndex) markers.push("current"); + if (account.enabled === false) markers.push("disabled"); + const rateLimit = deps.formatRateLimitEntry(account, now, "codex"); + if (rateLimit) markers.push("rate-limited"); + 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"; + logInfo(`${i + 1}. ${label}${markerLabel} ${lastUsed}`); + } + + return 0; +} + +export interface FeaturesCommandDeps { + implementedFeatures: ReadonlyArray<{ id: number; name: string }>; + logInfo?: (message: string) => void; +} + +export function runFeaturesCommand(deps: FeaturesCommandDeps): number { + const logInfo = deps.logInfo ?? console.log; + logInfo(`Implemented features (${deps.implementedFeatures.length})`); + logInfo(""); + for (const feature of deps.implementedFeatures) { + logInfo(`${feature.id}. ${feature.name}`); + } + return 0; +} diff --git a/test/codex-manager-status-command.test.ts b/test/codex-manager-status-command.test.ts new file mode 100644 index 00000000..dc189228 --- /dev/null +++ b/test/codex-manager-status-command.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type FeaturesCommandDeps, + runFeaturesCommand, + runStatusCommand, + type StatusCommandDeps, +} from "../lib/codex-manager/commands/status.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +function createStorage(): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "one@example.com", + refreshToken: "refresh-token-1", + addedAt: 1, + lastUsed: 1, + }, + { + email: "two@example.com", + refreshToken: "refresh-token-2", + addedAt: 2, + lastUsed: 2, + enabled: false, + }, + ], + }; +} + +function createStatusDeps( + overrides: Partial = {}, +): StatusCommandDeps { + return { + setStoragePath: vi.fn(), + getStoragePath: vi.fn(() => "/tmp/codex.json"), + loadAccounts: vi.fn(async () => createStorage()), + resolveActiveIndex: vi.fn(() => 0), + formatRateLimitEntry: vi.fn(() => null), + getNow: vi.fn(() => 2_000), + logInfo: vi.fn(), + ...overrides, + }; +} + +describe("runStatusCommand", () => { + it("prints empty storage state", async () => { + const deps = createStatusDeps({ loadAccounts: vi.fn(async () => null) }); + + const result = await runStatusCommand(deps); + + expect(result).toBe(0); + expect(deps.getStoragePath).toHaveBeenCalledTimes(1); + expect(deps.logInfo).toHaveBeenCalledWith("No accounts configured."); + expect(deps.logInfo).toHaveBeenCalledWith("Storage: /tmp/codex.json"); + }); + + it("prints account rows with current and disabled markers", async () => { + const deps = createStatusDeps({ + formatRateLimitEntry: vi.fn((_account, _now, _family) => "limited"), + }); + + const result = await runStatusCommand(deps); + + expect(result).toBe(0); + expect(deps.getStoragePath).toHaveBeenCalledTimes(1); + expect(deps.logInfo).toHaveBeenCalledWith("Accounts (2)"); + expect(deps.logInfo).toHaveBeenCalledWith("Storage: /tmp/codex.json"); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining( + "1. Account 1 (one@example.com) [current, rate-limited]", + ), + ); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining( + "2. Account 2 (two@example.com) [disabled, rate-limited]", + ), + ); + }); +}); + +describe("runFeaturesCommand", () => { + it("prints the implemented feature list", () => { + const deps: FeaturesCommandDeps = { + implementedFeatures: [ + { id: 1, name: "Alpha" }, + { id: 2, name: "Beta" }, + ], + logInfo: vi.fn(), + }; + + const result = runFeaturesCommand(deps); + + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith("Implemented features (2)"); + expect(deps.logInfo).toHaveBeenCalledWith("1. Alpha"); + expect(deps.logInfo).toHaveBeenCalledWith("2. Beta"); + }); +});