diff --git a/README.md b/README.md index c111e34..7ebf4e2 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ Setup details live in the [Provider setup guide](docs/readme/providers.md#custom If quota or token data looks wrong: -1. Run `/quota_status`, or start with `opencode-quota show` for a terminal quota summary. +1. Run `/quota_status`, or run `opencode-quota status` from your terminal for the same diagnostics without launching OpenCode. 2. Confirm the expected provider appears in the detected provider list. 3. Confirm companion auth plugins are before `@slkiser/opencode-quota` in `opencode.json`. 4. If token reports are empty, start OpenCode once so it creates `opencode.db`, then run a session with model usage. diff --git a/docs/readme/troubleshooting.md b/docs/readme/troubleshooting.md index 97e79b2..01ccf86 100644 --- a/docs/readme/troubleshooting.md +++ b/docs/readme/troubleshooting.md @@ -6,10 +6,11 @@ Start with `/quota_status`. It shows which config, providers, authentication, an ## First checks -1. Run `/quota_status`. -2. Find the provider or feature that is failing. -3. Follow the matching fix below. -4. Restart OpenCode after changing config or authentication. +1. Run `/quota_status`, or run `opencode-quota status` from your terminal for the same diagnostics without launching OpenCode. +2. Confirm the expected provider appears in the detected provider list. +3. Confirm companion auth plugins are before `@slkiser/opencode-quota` in `opencode.json`. +4. If token reports are empty, start OpenCode once so it creates `opencode.db`, then run a session with model usage. +5. Use the provider-specific table below for the failing provider. If every provider is missing, confirm OpenCode Quota is listed in `opencode.jsonc` or `.json`. For TUI commands and displays, also confirm it is listed in `tui.jsonc` or `.json`. @@ -37,7 +38,19 @@ npx @slkiser/opencode-quota@latest update --dry-run npx @slkiser/opencode-quota@latest update ``` -The updater preserves unrelated settings, comments, and plugins. Restart OpenCode when it finishes. +| Symptom | Try this | +| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/quota` or other slash commands do not appear | Confirm `opencode.json` includes `@slkiser/opencode-quota`, then restart OpenCode. The server plugin registers each command once for both TUI and Desktop/server; `tui.json` only enables the visual TUI surfaces. | +| `/quota` shows no providers | Run `/quota_status` (or `opencode-quota status` in your terminal), then check provider detection and auth. You can also use `opencode-quota show` for a terminal quota summary. | +| Sidebar panel does not appear | Confirm `tui.json` includes `@slkiser/opencode-quota`, restart OpenCode, and check `tuiSidebarPanel.enabled`. | +| Compact status line does not appear anywhere | Confirm `tui.json` includes `@slkiser/opencode-quota`, restart OpenCode, check `tuiCompactStatus.enabled`, and check whether `tuiCompactStatus.suppressWhenNativeProviderQuota` is hiding it because OpenCode exposes native provider-quota support. | +| Compact status appears on home but not in chat/session | Check `tuiCompactStatus.sessionPrompt`; set it to `true` to show the chat/session prompt line. | +| Popup toasts do not appear | Check `enableToast`, `showOnIdle`, `showOnQuestion`, and `showOnCompact`. | +| Announcement home notice does not appear | Confirm `tui.json` includes `@slkiser/opencode-quota`, restart OpenCode, then check `maintainerAnnouncements.enabled`, `maintainerAnnouncements.home`, and the active count in the `maintainer_announcements` section of `/quota_status`. | +| Token reports are empty | Start OpenCode once so `opencode.db` exists, then run a session with model usage. | +| Pricing looks stale | Run `/pricing_refresh`. | +| `/tokens_between` needs dates | Run `/tokens_between YYYY-MM-DD YYYY-MM-DD`. Missing or invalid dates produce inline usage output; no date dialog opens. | +| Desktop shows HTTP 500/error toast after correct command output | OpenCode 1.17.18 has no successful command-cancellation contract. The deterministic output was already injected as one ignored/no-reply message, and the handled sentinel prevents model continuation and context pollution. This is the accepted upstream limitation tracked by anomalyco/opencode#18554 and anomalyco/opencode#18559. | ## Provider fixes diff --git a/src/bin/opencode-quota.ts b/src/bin/opencode-quota.ts index 63b2967..bcb2ba0 100644 --- a/src/bin/opencode-quota.ts +++ b/src/bin/opencode-quota.ts @@ -10,6 +10,7 @@ const USAGE = [ "Usage:", " npx @slkiser/opencode-quota init [--dry-run] [--sync-legacy-config]", " npx @slkiser/opencode-quota show [--provider ] [--json] [--threshold ]", + " npx @slkiser/opencode-quota status [--provider ] [--json]", " npx @slkiser/opencode-quota update [--dry-run] [--yes]", " npx @slkiser/opencode-quota provider add [--dry-run]", " npx @slkiser/opencode-quota --help", @@ -22,6 +23,9 @@ const USAGE = [ " --json Machine-readable JSON output (reads from cache)", " --threshold With --json, exit 1 if below %, 2 if incomplete/not comparable", " --provider Filter to one provider", + " status Print Quota Status diagnostics (same data as /quota_status)", + " --json Machine-readable JSON output", + " --provider Filter to one provider", " update Safely refresh only OpenCode Quota config and verified cache entries", " --dry-run Preview without changing config or cache", " --yes Apply noninteractively after printing the preview", @@ -81,6 +85,11 @@ export async function main(argv = process.argv.slice(2)): Promise { return await runCliShowCommand({ argv: rest }); } + if (command === "status") { + const { runCliStatusCommand } = await import("../lib/cli-status.js"); + return await runCliStatusCommand({ argv: rest }); + } + if (command === "update") { const { runScopedUpdateCommand } = await import("../lib/scoped-update.js"); return await runScopedUpdateCommand({ argv: rest }); diff --git a/src/lib/cli-show.ts b/src/lib/cli-show.ts index 78dcb30..c51aa2f 100644 --- a/src/lib/cli-show.ts +++ b/src/lib/cli-show.ts @@ -135,7 +135,7 @@ function cloneCliConfig(config: QuotaToastConfig): QuotaToastConfig { }; } -function resolveCliRoots(cwd: string): { +export function resolveCliRoots(cwd: string): { workspaceRoot: string; configRoot: string; fallbackDirectory: string; @@ -150,7 +150,7 @@ function resolveCliRoots(cwd: string): { }; } -function createCliQuotaClient(params: { configRootDir: string }): QuotaRuntimeClient { +export function createCliQuotaClient(params: { configRootDir: string }): QuotaRuntimeClient { let configPromise: Promise> | undefined; let providerIdsPromise: Promise | undefined; diff --git a/src/lib/cli-status.ts b/src/lib/cli-status.ts new file mode 100644 index 0000000..7f8549c --- /dev/null +++ b/src/lib/cli-status.ts @@ -0,0 +1,166 @@ +import type { QuotaRuntimeClient } from "./quota-runtime-context.js"; + +import { getQuotaProviderShape } from "./provider-metadata.js"; +import { resolveQuotaRuntimeContext } from "./quota-runtime-context.js"; +import { buildStatusReportData, type QuotaStatusReportPayload } from "./quota-dialog-commands.js"; +import { createCliQuotaClient, resolveCliRoots } from "./cli-show.js"; + +export interface RunCliStatusCommandOptions { + argv?: string[]; + cwd?: string; + stdout?: Pick; + stderr?: Pick; +} + +type ParsedStatusArgs = + | { ok: true; providerId?: string; help: boolean; json: boolean } + | { ok: false; error: string }; + +const STATUS_USAGE = [ + "Usage:", + " npx @slkiser/opencode-quota status [--provider ] [--json]", + "", + "Print the same Quota Status diagnostics as the /quota_status slash command,", + "without launching OpenCode.", + "", + "Options:", + " --provider Restrict provider availability and live probes to one provider", + " --json Machine-readable JSON output:", + " { version, generatedAt, config, providers, pricing, liveProbes }", + " --help, -h Show help", + "", + "Exit codes:", + " 0 success", + " 1 error or quota disabled (enabled: false)", + " 2 no comparable provider data (with --json only)", +].join("\n"); + +const THRESHOLD_REDIRECT = + "--threshold is not supported by status. For threshold exit codes, use: opencode-quota show --json --threshold "; + +function parseStatusArgs(argv: string[]): ParsedStatusArgs { + let providerId: string | undefined; + let json = false; + + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + + if (arg === "--help" || arg === "-h") { + return { ok: true, help: true, json: false }; + } + + if (arg === "--json") { + json = true; + continue; + } + + if (arg === "--threshold" || arg.startsWith("--threshold=")) { + return { ok: false, error: THRESHOLD_REDIRECT }; + } + + if (arg === "--provider") { + const value = argv[index + 1]; + if (!value || value.startsWith("-")) { + return { ok: false, error: "Missing value for --provider." }; + } + if (providerId) { + return { ok: false, error: "Specify --provider only once." }; + } + providerId = value; + index += 1; + continue; + } + + if (arg.startsWith("--provider=")) { + const value = arg.slice("--provider=".length).trim(); + if (!value) { + return { ok: false, error: "Missing value for --provider." }; + } + if (providerId) { + return { ok: false, error: "Specify --provider only once." }; + } + providerId = value; + continue; + } + + if (arg.startsWith("-")) { + return { ok: false, error: `Unknown option: ${arg}` }; + } + + return { ok: false, error: `Unexpected argument: ${arg}` }; + } + + return { ok: true, providerId, help: false, json }; +} + +function writeLine(stream: Pick, message: string): void { + stream.write(message.endsWith("\n") ? message : `${message}\n`); +} + +function hasComparableProviderData(payload: QuotaStatusReportPayload): boolean { + return payload.liveProbes.length > 0; +} + +export async function runCliStatusCommand( + options: RunCliStatusCommandOptions = {}, +): Promise { + const argv = options.argv ?? process.argv.slice(3); + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + + const parsed = parseStatusArgs(argv); + if (!parsed.ok) { + writeLine(stderr, parsed.error); + writeLine(stderr, STATUS_USAGE); + return 1; + } + + if (parsed.help) { + writeLine(stdout, STATUS_USAGE); + return 0; + } + + const providerId = parsed.providerId ? getQuotaProviderShape(parsed.providerId)?.id : undefined; + if (parsed.providerId && !providerId) { + writeLine(stderr, `Unknown provider: ${parsed.providerId}`); + return 1; + } + + try { + const roots = resolveCliRoots(options.cwd ?? process.cwd()); + const client: QuotaRuntimeClient = createCliQuotaClient({ configRootDir: roots.configRoot }); + const runtime = await resolveQuotaRuntimeContext({ + client, + roots, + includeSessionMeta: false, + }); + + if (!runtime.config.enabled) { + writeLine(stderr, "Quota disabled in config (enabled: false)."); + return 1; + } + + const data = await buildStatusReportData({ + runtime, + generatedAtMs: Date.now(), + providerFilterId: providerId, + }); + + if (!data.output || !data.payload) { + writeLine(stderr, "Quota disabled in config (enabled: false)."); + return 1; + } + + if (parsed.json) { + writeLine(stdout, JSON.stringify(data.payload, null, 2)); + return hasComparableProviderData(data.payload) ? 0 : 2; + } + + writeLine(stdout, data.output); + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeLine(stderr, `Failed to generate quota status: ${message}`); + return 1; + } +} diff --git a/src/lib/quota-dialog-commands.ts b/src/lib/quota-dialog-commands.ts index 7fb15b9..e0642ce 100644 --- a/src/lib/quota-dialog-commands.ts +++ b/src/lib/quota-dialog-commands.ts @@ -56,6 +56,7 @@ import { BUNDLED_MAINTAINER_ANNOUNCEMENTS, getMaintainerAnnouncementsSummary, } from "./maintainer-announcements.js"; +import { getPackageVersion } from "./version.js"; export type QuotaDialogCommandId = | "quota" @@ -437,7 +438,48 @@ async function buildQuotaReport(params: { }); } -async function buildStatusReport(params: { +export interface QuotaStatusReportConfigPayload { + configSource: string; + configPaths: string[]; + globalConfigPaths?: string[]; + workspaceConfigPaths?: string[]; + enabledProviders: string[] | "auto"; + onlyCurrentModel: boolean; + pricingSnapshotSource: PricingSnapshotSource; +} + +export interface QuotaStatusReportPricingPayload { + selection: PricingSnapshotSource; + activeSource: string; + snapshot: { + source: string; + generatedAt: string | null; + units: string; + }; + snapshotPath: string; + refreshStatePath: string; +} + +export interface QuotaStatusReportPayload { + version: string; + generatedAt: string; + config: QuotaStatusReportConfigPayload; + providers: Array<{ + id: string; + enabled: boolean; + available: boolean; + matchesCurrentModel?: boolean; + }>; + pricing: QuotaStatusReportPricingPayload; + liveProbes: QuotaStatusLiveProbe[]; +} + +export interface QuotaStatusReportData { + output: string | null; + payload: QuotaStatusReportPayload | null; +} + +export async function buildStatusReportData(params: { runtime: QuotaRuntimeContext; refreshGoogleTokens?: boolean; skewMs?: number; @@ -446,9 +488,13 @@ async function buildStatusReport(params: { generatedAtMs: number; lastSessionTokenError?: SessionTokenError; log?: (message: string, extra?: Record) => Promise; -}): Promise { + /** When set, restrict provider availability and live probes to this provider id. */ + providerFilterId?: string; +}): Promise { const runtimeConfig = params.runtime.config; - if (!runtimeConfig.enabled) return null; + if (!runtimeConfig.enabled) { + return { output: null, payload: null }; + } await kickPricingRefresh({ reason: "status", maxWaitMs: 750, @@ -469,7 +515,7 @@ async function buildStatusReport(params: { const providers = params.runtime.providers; const providerContext = createQuotaProviderRuntimeContext(params.runtime); - const availability = await Promise.all( + let availability = await Promise.all( providers.map(async (p) => { let ok = false; try { @@ -496,7 +542,7 @@ async function buildStatusReport(params: { ); const providersById = new Map(providers.map((provider) => [provider.id, provider] as const)); - const liveProbeProviders = availability.flatMap((item) => { + let liveProbeProviders = availability.flatMap((item) => { if (!item.enabled || !item.available) { return []; } @@ -504,6 +550,13 @@ async function buildStatusReport(params: { return provider ? [provider] : []; }); + if (params.providerFilterId) { + availability = availability.filter((item) => item.id === params.providerFilterId); + liveProbeProviders = liveProbeProviders.filter( + (provider) => provider.id === params.providerFilterId, + ); + } + let providerLiveProbes: QuotaStatusLiveProbe[] = []; if (liveProbeProviders.length > 0) { try { @@ -535,7 +588,7 @@ async function buildStatusReport(params: { enabledProviders: announcementProviderIds, }); - return await buildQuotaStatusReport({ + const output = await buildQuotaStatusReport({ tuiDiagnostics, configSource: params.runtime.configMeta.source, configPaths: params.runtime.configMeta.paths, @@ -572,6 +625,52 @@ async function buildStatusReport(params: { geminiCliClient: params.runtime.client, generatedAtMs: params.generatedAtMs, }); + + const version = (await getPackageVersion()) ?? "unknown"; + const pricingMeta = getPricingSnapshotMeta(); + const activePricingSource = getPricingSnapshotSource(); + const payload: QuotaStatusReportPayload = { + version, + generatedAt: new Date(params.generatedAtMs).toISOString(), + config: { + configSource: params.runtime.configMeta.source, + configPaths: params.runtime.configMeta.paths, + globalConfigPaths: params.runtime.configMeta.globalConfigPaths, + workspaceConfigPaths: params.runtime.configMeta.workspaceConfigPaths, + enabledProviders: runtimeConfig.enabledProviders, + onlyCurrentModel: runtimeConfig.onlyCurrentModel, + pricingSnapshotSource: runtimeConfig.pricingSnapshot.source, + }, + providers: availability, + pricing: { + selection: runtimeConfig.pricingSnapshot.source, + activeSource: activePricingSource, + snapshot: { + source: pricingMeta.source, + generatedAt: + pricingMeta.generatedAt > 0 ? new Date(pricingMeta.generatedAt).toISOString() : null, + units: pricingMeta.units, + }, + snapshotPath: getRuntimePricingSnapshotPath(), + refreshStatePath: getRuntimePricingRefreshStatePath(), + }, + liveProbes: providerLiveProbes, + }; + + return { output, payload }; +} + +async function buildStatusReport(params: { + runtime: QuotaRuntimeContext; + refreshGoogleTokens?: boolean; + skewMs?: number; + force?: boolean; + sessionID?: string; + generatedAtMs: number; + lastSessionTokenError?: SessionTokenError; + log?: (message: string, extra?: Record) => Promise; +}): Promise { + return (await buildStatusReportData(params)).output; } function formatIsoTimestamp(timestampMs: number | undefined): string { diff --git a/tests/bin.opencode-quota.test.ts b/tests/bin.opencode-quota.test.ts index 7170de4..f111c03 100644 --- a/tests/bin.opencode-quota.test.ts +++ b/tests/bin.opencode-quota.test.ts @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const commandMocks = vi.hoisted(() => ({ runInitInstaller: vi.fn(), runCliShowCommand: vi.fn(), + runCliStatusCommand: vi.fn(), runScopedUpdateCommand: vi.fn(), })); @@ -19,6 +20,10 @@ vi.mock("../src/lib/cli-show.js", () => ({ runCliShowCommand: commandMocks.runCliShowCommand, })); +vi.mock("../src/lib/cli-status.js", () => ({ + runCliStatusCommand: commandMocks.runCliStatusCommand, +})); + vi.mock("../src/lib/scoped-update.js", () => ({ runScopedUpdateCommand: commandMocks.runScopedUpdateCommand, })); @@ -28,6 +33,7 @@ describe("opencode-quota bin", () => { vi.clearAllMocks(); commandMocks.runInitInstaller.mockResolvedValue(0); commandMocks.runCliShowCommand.mockResolvedValue(0); + commandMocks.runCliStatusCommand.mockResolvedValue(0); commandMocks.runScopedUpdateCommand.mockResolvedValue(0); }); @@ -95,6 +101,27 @@ describe("opencode-quota bin", () => { }); }); + it("dispatches status to the quota status CLI command", async () => { + const { main } = await import("../src/bin/opencode-quota.js"); + + const code = await main(["status"]); + + expect(code).toBe(0); + expect(commandMocks.runCliStatusCommand).toHaveBeenCalledWith({ argv: [] }); + expect(commandMocks.runCliShowCommand).not.toHaveBeenCalled(); + }); + + it("passes status provider args through to the status CLI command", async () => { + const { main } = await import("../src/bin/opencode-quota.js"); + + const code = await main(["status", "--provider", "copilot", "--json"]); + + expect(code).toBe(0); + expect(commandMocks.runCliStatusCommand).toHaveBeenCalledWith({ + argv: ["--provider", "copilot", "--json"], + }); + }); + it("prints help and exits zero for --help", async () => { const { main } = await import("../src/bin/opencode-quota.js"); const log = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -104,6 +131,7 @@ describe("opencode-quota bin", () => { expect(code).toBe(0); expect(log).toHaveBeenCalledWith(expect.stringContaining("Usage:")); expect(log).toHaveBeenCalledWith(expect.stringContaining("opencode-quota show")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("opencode-quota status")); log.mockRestore(); }); @@ -115,7 +143,7 @@ describe("opencode-quota bin", () => { expect(code).toBe(1); expect(log).toHaveBeenCalledWith(expect.stringContaining("Usage:")); - expect(log).toHaveBeenCalledWith(expect.stringContaining("opencode-quota show")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("opencode-quota status")); log.mockRestore(); }); @@ -127,7 +155,7 @@ describe("opencode-quota bin", () => { expect(code).toBe(1); expect(log).toHaveBeenCalledWith(expect.stringContaining("Usage:")); - expect(log).toHaveBeenCalledWith(expect.stringContaining("opencode-quota show")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("opencode-quota status")); log.mockRestore(); }); diff --git a/tests/lib.cli-status.test.ts b/tests/lib.cli-status.test.ts new file mode 100644 index 0000000..94759ea --- /dev/null +++ b/tests/lib.cli-status.test.ts @@ -0,0 +1,394 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockProviders, runtimeDirs, statusData } = vi.hoisted(() => ({ + mockProviders: [] as any[], + runtimeDirs: { + value: { + dataDirs: [] as string[], + configDirs: [] as string[], + cacheDirs: [] as string[], + stateDirs: [] as string[], + }, + }, + statusData: { + value: null as null | { output: string; payload: Record }, + }, +})); + +vi.mock("../src/providers/registry.js", () => ({ + getProviders: () => mockProviders, +})); + +vi.mock("../src/lib/opencode-runtime-paths.js", () => ({ + getOpencodeRuntimeDirCandidates: () => runtimeDirs.value, + getOpencodeRuntimeDirs: () => ({ + dataDir: runtimeDirs.value.dataDirs[0] ?? "/tmp/opencode-quota-cli-status-data", + configDir: runtimeDirs.value.configDirs[0] ?? "/tmp/opencode-quota-cli-status-config", + cacheDir: runtimeDirs.value.cacheDirs[0] ?? "/tmp/opencode-quota-cli-status-cache", + stateDir: runtimeDirs.value.stateDirs[0] ?? "/tmp/opencode-quota-cli-status-state", + }), +})); + +vi.mock("../src/lib/quota-dialog-commands.js", () => ({ + buildStatusReportData: vi.fn(async (params: { providerFilterId?: string }) => { + if (!statusData.value) { + return { output: null, payload: null }; + } + return { + output: statusData.value.output, + payload: { ...statusData.value.payload, providerFilterId: params.providerFilterId ?? null }, + }; + }), +})); + +import { runCliStatusCommand } from "../src/lib/cli-status.js"; +import { buildStatusReportData } from "../src/lib/quota-dialog-commands.js"; + +function createCaptureStream() { + let output = ""; + return { + stream: { + write: (chunk: string | Uint8Array) => { + output += String(chunk); + return true; + }, + }, + get output() { + return output; + }, + }; +} + +function basePayload(overrides: Record = {}) { + return { + version: "3.11.2", + generatedAt: "2026-07-16T00:00:00.000Z", + config: { + configSource: "workspace", + configPaths: ["/tmp/opencode.json"], + enabledProviders: ["synthetic"], + onlyCurrentModel: false, + pricingSnapshotSource: "auto", + }, + providers: [ + { id: "synthetic", enabled: true, available: true, matchesCurrentModel: undefined }, + ], + pricing: { + selection: "auto", + activeSource: "bundled", + snapshot: { + source: "bundled", + generatedAt: "2026-01-01T00:00:00.000Z", + units: "USD per 1M tokens", + }, + snapshotPath: "/tmp/pricing.json", + refreshStatePath: "/tmp/pricing-state.json", + }, + liveProbes: [ + { + providerId: "synthetic", + result: { + attempted: true, + entries: [{ name: "Synthetic Weekly", percentRemaining: 75 }], + errors: [], + }, + }, + ], + ...overrides, + }; +} + +describe("runCliStatusCommand", () => { + let tempDir: string; + let globalConfigDir: string; + let workspaceDir: string; + let savedConfigDir: string | undefined; + + beforeEach(() => { + savedConfigDir = process.env.OPENCODE_CONFIG_DIR; + delete process.env.OPENCODE_CONFIG_DIR; + tempDir = mkdtempSync(join(tmpdir(), "opencode-quota-cli-status-")); + globalConfigDir = join(tempDir, "global-config", "opencode"); + workspaceDir = join(tempDir, "workspace"); + mkdirSync(globalConfigDir, { recursive: true }); + mkdirSync(workspaceDir, { recursive: true }); + runtimeDirs.value = { + dataDirs: [], + configDirs: [globalConfigDir], + cacheDirs: [join(tempDir, "cache")], + stateDirs: [], + }; + mockProviders.length = 0; + statusData.value = null; + vi.mocked(buildStatusReportData).mockClear(); + writeFileSync( + join(workspaceDir, "opencode.json"), + JSON.stringify({ + experimental: { quotaToast: { enabledProviders: ["synthetic"] } }, + }), + "utf8", + ); + }); + + afterEach(() => { + if (savedConfigDir !== undefined) process.env.OPENCODE_CONFIG_DIR = savedConfigDir; + else delete process.env.OPENCODE_CONFIG_DIR; + mockProviders.length = 0; + statusData.value = null; + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("prints a plain-text Quota Status report and returns zero", async () => { + statusData.value = { + output: "Quota Status (opencode-quota v3.11.2)\ntoast:\n- enabledProviders: synthetic", + payload: basePayload(), + }; + const stdout = createCaptureStream(); + const stderr = createCaptureStream(); + + const code = await runCliStatusCommand({ + argv: [], + cwd: workspaceDir, + stdout: stdout.stream as any, + stderr: stderr.stream as any, + }); + + expect(code).toBe(0); + expect(stdout.output).toContain("Quota Status"); + expect(stdout.output).toContain("enabledProviders: synthetic"); + expect(stderr.output).toBe(""); + expect(buildStatusReportData).toHaveBeenCalledWith( + expect.objectContaining({ providerFilterId: undefined }), + ); + }); + + it("--json emits a structured payload and returns zero when live probes exist", async () => { + statusData.value = { + output: "report text", + payload: basePayload(), + }; + const stdout = createCaptureStream(); + const stderr = createCaptureStream(); + + const code = await runCliStatusCommand({ + argv: ["--json"], + cwd: workspaceDir, + stdout: stdout.stream as any, + stderr: stderr.stream as any, + }); + + expect(code).toBe(0); + expect(stderr.output).toBe(""); + const parsed = JSON.parse(stdout.output); + expect(parsed).toHaveProperty("version", "3.11.2"); + expect(parsed).toHaveProperty("generatedAt"); + expect(parsed).toHaveProperty("config"); + expect(parsed).toHaveProperty("providers"); + expect(parsed).toHaveProperty("pricing"); + expect(parsed).toHaveProperty("liveProbes"); + expect(parsed.liveProbes).toHaveLength(1); + expect(parsed.liveProbes[0].providerId).toBe("synthetic"); + }); + + it("--json exits 2 when there is no comparable provider data", async () => { + statusData.value = { + output: "report text", + payload: basePayload({ liveProbes: [] }), + }; + const stdout = createCaptureStream(); + const stderr = createCaptureStream(); + + const code = await runCliStatusCommand({ + argv: ["--json"], + cwd: workspaceDir, + stdout: stdout.stream as any, + stderr: stderr.stream as any, + }); + + expect(code).toBe(2); + expect(stderr.output).toBe(""); + // JSON still printed even on exit 2. + const parsed = JSON.parse(stdout.output); + expect(parsed.liveProbes).toEqual([]); + }); + + it("--provider filters the report to one provider", async () => { + statusData.value = { + output: "report text", + payload: basePayload(), + }; + const stdout = createCaptureStream(); + const stderr = createCaptureStream(); + + const code = await runCliStatusCommand({ + argv: ["--provider", "synthetic"], + cwd: workspaceDir, + stdout: stdout.stream as any, + stderr: stderr.stream as any, + }); + + expect(code).toBe(0); + expect(stderr.output).toBe(""); + expect(buildStatusReportData).toHaveBeenCalledWith( + expect.objectContaining({ providerFilterId: "synthetic" }), + ); + }); + + it("--provider --json forwards the provider filter and still emits JSON", async () => { + statusData.value = { + output: "report text", + payload: basePayload(), + }; + const stdout = createCaptureStream(); + const stderr = createCaptureStream(); + + const code = await runCliStatusCommand({ + argv: ["--json", "--provider", "synthetic"], + cwd: workspaceDir, + stdout: stdout.stream as any, + stderr: stderr.stream as any, + }); + + expect(code).toBe(0); + expect(buildStatusReportData).toHaveBeenCalledWith( + expect.objectContaining({ providerFilterId: "synthetic" }), + ); + const parsed = JSON.parse(stdout.output); + expect(parsed.providerFilterId).toBe("synthetic"); + }); + + it("rejects --threshold with a redirect to show --json --threshold", async () => { + const stdout = createCaptureStream(); + const stderr = createCaptureStream(); + + const code = await runCliStatusCommand({ + argv: ["--threshold", "50"], + cwd: workspaceDir, + stdout: stdout.stream as any, + stderr: stderr.stream as any, + }); + + expect(code).toBe(1); + expect(stdout.output).toBe(""); + expect(stderr.output).toContain("--threshold is not supported by status"); + expect(stderr.output).toContain("opencode-quota show --json --threshold"); + expect(buildStatusReportData).not.toHaveBeenCalled(); + }); + + it("rejects --threshold even when combined with --json", async () => { + const stderr = createCaptureStream(); + const code = await runCliStatusCommand({ + argv: ["--json", "--threshold=10"], + cwd: workspaceDir, + stdout: { write: () => true } as any, + stderr: stderr.stream as any, + }); + + expect(code).toBe(1); + expect(stderr.output).toContain("--threshold is not supported by status"); + expect(buildStatusReportData).not.toHaveBeenCalled(); + }); + + it("rejects an unknown provider before building the report", async () => { + const stdout = createCaptureStream(); + const stderr = createCaptureStream(); + + const code = await runCliStatusCommand({ + argv: ["--provider", "not-a-provider"], + cwd: workspaceDir, + stdout: stdout.stream as any, + stderr: stderr.stream as any, + }); + + expect(code).toBe(1); + expect(stdout.output).toBe(""); + expect(stderr.output).toContain("Unknown provider: not-a-provider"); + expect(buildStatusReportData).not.toHaveBeenCalled(); + }); + + it("rejects a missing --provider value", async () => { + const stderr = createCaptureStream(); + const code = await runCliStatusCommand({ + argv: ["--provider"], + cwd: workspaceDir, + stdout: { write: () => true } as any, + stderr: stderr.stream as any, + }); + + expect(code).toBe(1); + expect(stderr.output).toContain("Missing value for --provider"); + expect(stderr.output).toContain("opencode-quota status"); + }); + + it("rejects an unknown flag", async () => { + const stderr = createCaptureStream(); + const code = await runCliStatusCommand({ + argv: ["--bogus"], + cwd: workspaceDir, + stdout: { write: () => true } as any, + stderr: stderr.stream as any, + }); + + expect(code).toBe(1); + expect(stderr.output).toContain("Unknown option: --bogus"); + expect(stderr.output).toContain("opencode-quota status"); + }); + + it("returns non-zero when quota is disabled in config", async () => { + writeFileSync( + join(workspaceDir, "opencode.json"), + JSON.stringify({ experimental: { quotaToast: { enabled: false } } }), + "utf8", + ); + const stdout = createCaptureStream(); + const stderr = createCaptureStream(); + + const code = await runCliStatusCommand({ + argv: [], + cwd: workspaceDir, + stdout: stdout.stream as any, + stderr: stderr.stream as any, + }); + + expect(code).toBe(1); + expect(stdout.output).toBe(""); + expect(stderr.output).toContain("Quota disabled in config"); + expect(buildStatusReportData).not.toHaveBeenCalled(); + }); + + it("prints help and returns zero for --help", async () => { + const stdout = createCaptureStream(); + const stderr = createCaptureStream(); + + const code = await runCliStatusCommand({ + argv: ["--help"], + cwd: workspaceDir, + stdout: stdout.stream as any, + stderr: stderr.stream as any, + }); + + expect(code).toBe(0); + expect(stderr.output).toBe(""); + expect(stdout.output).toContain("opencode-quota status"); + expect(stdout.output).toContain("Exit codes:"); + expect(buildStatusReportData).not.toHaveBeenCalled(); + }); + + it("returns non-zero when report building throws", async () => { + vi.mocked(buildStatusReportData).mockRejectedValueOnce(new Error("boom")); + const stderr = createCaptureStream(); + + const code = await runCliStatusCommand({ + argv: [], + cwd: workspaceDir, + stdout: { write: () => true } as any, + stderr: stderr.stream as any, + }); + + expect(code).toBe(1); + expect(stderr.output).toContain("Failed to generate quota status: boom"); + }); +});