From 47a2ff9fcdea9601e8474f9bf45fadbb8ff58e05 Mon Sep 17 00:00:00 2001 From: Kreato Date: Tue, 27 Jan 2026 10:01:24 +0300 Subject: [PATCH 1/4] feat: add Chutes.ai quota support --- README.md | 22 ++++ src/lib/chutes-config.ts | 222 ++++++++++++++++++++++++++++++++++++++ src/lib/chutes.ts | 102 ++++++++++++++++++ src/lib/quota-status.ts | 21 ++++ src/lib/types.ts | 12 +++ src/providers/chutes.ts | 42 ++++++++ src/providers/registry.ts | 9 +- tests/lib.chutes.test.ts | 91 ++++++++++++++++ 8 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 src/lib/chutes-config.ts create mode 100644 src/lib/chutes.ts create mode 100644 src/providers/chutes.ts create mode 100644 tests/lib.chutes.test.ts diff --git a/README.md b/README.md index 8b1aab4..fa0febf 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ That's it. Toasts appear automatically after main agent responses. | GitHub Copilot | `copilot` | Uses OpenCode auth\* | | OpenAI (Plus/Pro) | `openai` | Uses OpenCode auth | | Firmware AI | `firmware` | Uses OpenCode auth or API key | +| Chutes AI | `chutes` | Uses OpenCode auth or API key | | Google Antigravity | `google-antigravity` | Multi-account via `opencode-antigravity-auth` | ### Firmware AI Setup @@ -83,6 +84,27 @@ Firmware works automatically if OpenCode has Firmware configured. Alternatively, The `apiKey` field supports the `{env:VAR_NAME}` syntax to reference environment variables, or you can provide the key directly. +### Chutes AI Setup + +Chutes works automatically if OpenCode has Chutes configured. Alternatively, you can provide an API key in your `opencode.json`: + +```jsonc +{ + "provider": { + "chutes": { + "options": { + "apiKey": "{env:CHUTES_API_KEY}", + }, + }, + }, + "experimental": { + "quotaToast": { + "enabledProviders": ["chutes"], + }, + }, +} +``` + ### GitHub Copilot Setup (optional) Copilot works with no extra setup as long as OpenCode already has Copilot configured and logged in. diff --git a/src/lib/chutes-config.ts b/src/lib/chutes-config.ts new file mode 100644 index 0000000..3838738 --- /dev/null +++ b/src/lib/chutes-config.ts @@ -0,0 +1,222 @@ +/** + * Chutes API key configuration resolver + * + * Resolution priority (first wins): + * 1. Environment variable: CHUTES_API_KEY + * 2. opencode.json/opencode.jsonc: provider.chutes.options.apiKey + * - Supports {env:VAR_NAME} syntax for environment variable references + * 3. auth.json: chutes.key (legacy/fallback) + */ + +import { existsSync } from "fs"; +import { readFile } from "fs/promises"; +import { homedir } from "os"; +import { join } from "path"; +import { readAuthFile } from "./opencode-auth.js"; + +/** Result of Chutes API key resolution */ +export interface ChutesApiKeyResult { + key: string; + source: ChutesKeySource; +} + +/** Source of the resolved API key */ +export type ChutesKeySource = + | "env:CHUTES_API_KEY" + | "opencode.json" + | "opencode.jsonc" + | "auth.json"; + +/** + * Strip JSONC comments (// and /* ... *​/) from a string. + */ +function stripJsonComments(content: string): string { + let result = ""; + let i = 0; + let inString = false; + let stringChar = ""; + + while (i < content.length) { + const char = content[i]; + const nextChar = content[i + 1]; + + if ((char === '"' || char === "'") && (i === 0 || content[i - 1] !== "\\")) { + if (!inString) { + inString = true; + stringChar = char; + } else if (char === stringChar) { + inString = false; + } + result += char; + i++; + continue; + } + + if (!inString) { + if (char === "/" && nextChar === "/") { + while (i < content.length && content[i] !== "\n") { + i++; + } + continue; + } + + if (char === "/" && nextChar === "*") { + i += 2; + while (i < content.length - 1 && !(content[i] === "*" && content[i + 1] === "/")) { + i++; + } + i += 2; + continue; + } + } + + result += char; + i++; + } + + return result; +} + +/** + * Parse JSON or JSONC content + */ +function parseJsonOrJsonc(content: string, isJsonc: boolean): unknown { + const toParse = isJsonc ? stripJsonComments(content) : content; + return JSON.parse(toParse); +} + +/** + * Resolve {env:VAR_NAME} syntax in a string value + */ +function resolveEnvTemplate(value: string): string | null { + const match = value.match(/^\{env:([^}]+)\}$/); + if (!match) return value; + + const envVar = match[1]; + const envValue = process.env[envVar]; + return envValue && envValue.trim().length > 0 ? envValue.trim() : null; +} + +/** + * Extract Chutes API key from opencode config object + */ +function extractChutesKeyFromConfig(config: unknown): string | null { + if (!config || typeof config !== "object") return null; + + const root = config as Record; + const provider = root.provider; + if (!provider || typeof provider !== "object") return null; + + const chutes = (provider as Record).chutes; + if (!chutes || typeof chutes !== "object") return null; + + const options = (chutes as Record).options; + if (!options || typeof options !== "object") return null; + + const apiKey = (options as Record).apiKey; + if (typeof apiKey !== "string" || apiKey.trim().length === 0) return null; + + return resolveEnvTemplate(apiKey.trim()); +} + +/** + * Get candidate paths for opencode.json/opencode.jsonc files + */ +export function getOpencodeConfigCandidatePaths(): Array<{ path: string; isJsonc: boolean }> { + const cwd = process.cwd(); + const configBaseDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); + + return [ + { path: join(configBaseDir, "opencode", "opencode.jsonc"), isJsonc: true }, + { path: join(configBaseDir, "opencode", "opencode.json"), isJsonc: false }, + { path: join(cwd, "opencode.jsonc"), isJsonc: true }, + { path: join(cwd, "opencode.json"), isJsonc: false }, + ]; +} + +/** + * Read and parse opencode config file + */ +async function readOpencodeConfig( + filePath: string, + isJsonc: boolean, +): Promise<{ config: unknown; path: string; isJsonc: boolean } | null> { + try { + if (!existsSync(filePath)) return null; + const content = await readFile(filePath, "utf-8"); + const config = parseJsonOrJsonc(content, isJsonc); + return { config, path: filePath, isJsonc }; + } catch { + return null; + } +} + +/** + * Resolve Chutes API key from all available sources. + */ +export async function resolveChutesApiKey(): Promise { + const envKey = process.env.CHUTES_API_KEY?.trim(); + if (envKey && envKey.length > 0) { + return { key: envKey, source: "env:CHUTES_API_KEY" }; + } + + const candidates = getOpencodeConfigCandidatePaths(); + for (const candidate of candidates) { + const result = await readOpencodeConfig(candidate.path, candidate.isJsonc); + if (!result) continue; + + const key = extractChutesKeyFromConfig(result.config); + if (key) { + return { + key, + source: result.isJsonc ? "opencode.jsonc" : "opencode.json", + }; + } + } + + const auth = await readAuthFile(); + const chutes = auth?.chutes; + if (chutes && chutes.type === "api" && chutes.key && chutes.key.trim().length > 0) { + return { key: chutes.key.trim(), source: "auth.json" }; + } + + return null; +} + +/** + * Check if a Chutes API key is configured + */ +export async function hasChutesApiKey(): Promise { + const result = await resolveChutesApiKey(); + return result !== null; +} + +/** + * Get diagnostic info about Chutes API key configuration + */ +export async function getChutesKeyDiagnostics(): Promise<{ + configured: boolean; + source: ChutesKeySource | null; + checkedPaths: string[]; +}> { + const checkedPaths: string[] = []; + + if (process.env.CHUTES_API_KEY !== undefined) { + checkedPaths.push("env:CHUTES_API_KEY"); + } + + const candidates = getOpencodeConfigCandidatePaths(); + for (const candidate of candidates) { + if (existsSync(candidate.path)) { + checkedPaths.push(candidate.path); + } + } + + const result = await resolveChutesApiKey(); + + return { + configured: result !== null, + source: result?.source ?? null, + checkedPaths, + }; +} diff --git a/src/lib/chutes.ts b/src/lib/chutes.ts new file mode 100644 index 0000000..ad06f3a --- /dev/null +++ b/src/lib/chutes.ts @@ -0,0 +1,102 @@ +/** + * Chutes AI quota fetcher + * + * Resolves API key from multiple sources and queries: + * https://api.chutes.ai/users/me/quota_usage/me + */ + +import type { ChutesResult } from "./types.js"; +import { REQUEST_TIMEOUT_MS } from "./types.js"; +import { + resolveChutesApiKey, + hasChutesApiKey, + getChutesKeyDiagnostics, + type ChutesKeySource, +} from "./chutes-config.js"; + +interface ChutesQuotaResponse { + quota: number; + used: number; +} + +function clampPercent(n: number): number { + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(100, Math.round(n))); +} + +async function fetchWithTimeout(url: string, options: RequestInit): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new Error(`Request timeout after ${Math.round(REQUEST_TIMEOUT_MS / 1000)}s`); + } + throw err; + } finally { + clearTimeout(timeoutId); + } +} + +type ChutesApiAuth = { + type: "api"; + key: string; + source: ChutesKeySource; +}; + +async function readChutesAuth(): Promise { + const result = await resolveChutesApiKey(); + if (!result) return null; + return { type: "api", key: result.key, source: result.source }; +} + +const CHUTES_QUOTA_URL = "https://api.chutes.ai/users/me/quota_usage/me"; + +export async function hasChutesApiKeyConfigured(): Promise { + return await hasChutesApiKey(); +} + +export { getChutesKeyDiagnostics, type ChutesKeySource } from "./chutes-config.js"; + +export async function queryChutesQuota(): Promise { + const auth = await readChutesAuth(); + if (!auth) return null; + + try { + const resp = await fetchWithTimeout(CHUTES_QUOTA_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${auth.key}`, + "User-Agent": "OpenCode-Quota-Toast/1.0", + }, + }); + + if (!resp.ok) { + const text = await resp.text(); + return { + success: false, + error: `Chutes API error ${resp.status}: ${text.slice(0, 120)}`, + }; + } + + const data = (await resp.json()) as ChutesQuotaResponse; + + // Chutes returns used and quota. + const used = typeof data.used === "number" ? data.used : 0; + const quota = typeof data.quota === "number" ? data.quota : 0; + + const percentRemaining = quota > 0 ? clampPercent(((quota - used) / quota) * 100) : 0; + + return { + success: true, + percentRemaining, + }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/lib/quota-status.ts b/src/lib/quota-status.ts index 8014aed..6dc4630 100644 --- a/src/lib/quota-status.ts +++ b/src/lib/quota-status.ts @@ -6,6 +6,7 @@ import { readAntigravityAccounts, } from "./google.js"; import { getFirmwareKeyDiagnostics } from "./firmware.js"; +import { getChutesKeyDiagnostics } from "./chutes.js"; import { getPricingSnapshotMeta, listProviders, @@ -99,6 +100,26 @@ export async function buildQuotaStatusReport(params: { if (firmwareDiag.checkedPaths.length > 0) { lines.push(`- firmware api key checked: ${firmwareDiag.checkedPaths.join(" | ")}`); } + + // Chutes API key diagnostics + let chutesDiag: { configured: boolean; source: string | null; checkedPaths: string[] } = { + configured: false, + source: null, + checkedPaths: [], + }; + try { + chutesDiag = await getChutesKeyDiagnostics(); + } catch { + // ignore + } + lines.push(`- chutes api key configured: ${chutesDiag.configured ? "true" : "false"}`); + if (chutesDiag.source) { + lines.push(`- chutes api key source: ${chutesDiag.source}`); + } + if (chutesDiag.checkedPaths.length > 0) { + lines.push(`- chutes api key checked: ${chutesDiag.checkedPaths.join(" | ")}`); + } + lines.push(`- google token cache: ${getGoogleTokenCachePath()}`); lines.push(`- antigravity accounts (selected): ${pickAntigravityAccountsPath()}`); const candidates = getAntigravityAccountsCandidatePaths(); diff --git a/src/lib/types.ts b/src/lib/types.ts index bf8a3a8..0a0e449 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -156,6 +156,10 @@ export interface AuthData { type: string; key?: string; }; + chutes?: { + type: string; + key?: string; + }; } // ============================================================================= @@ -286,6 +290,14 @@ export interface QuotaError { /** Combined quota result */ export type CopilotResult = CopilotQuotaResult | QuotaError | null; export type GoogleResult = GoogleQuotaResult | QuotaError | null; +export type ChutesResult = + | { + success: true; + percentRemaining: number; + resetTimeIso?: string; + } + | QuotaError + | null; /** Cached toast data */ export interface CachedToast { diff --git a/src/providers/chutes.ts b/src/providers/chutes.ts new file mode 100644 index 0000000..fa1311d --- /dev/null +++ b/src/providers/chutes.ts @@ -0,0 +1,42 @@ +import type { QuotaProvider, QuotaProviderResult } from "../lib/entries.js"; +import { queryChutesQuota, hasChutesApiKeyConfigured } from "../lib/chutes.js"; + +export const chutesProvider: QuotaProvider = { + id: "chutes", + + async isAvailable(): Promise { + return await hasChutesApiKeyConfigured(); + }, + + async fetch(): Promise { + const result = await queryChutesQuota(); + + if (!result) { + return { attempted: false, entries: [], errors: [] }; + } + + if (!result.success) { + return { + attempted: true, + entries: [], + errors: [{ label: "Chutes", message: result.error }], + }; + } + + return { + attempted: true, + entries: [ + { + name: "Chutes", + percentRemaining: result.percentRemaining, + resetTimeIso: result.resetTimeIso, + }, + ], + errors: [], + }; + }, + + matchesCurrentModel(model: string): boolean { + return model.toLowerCase().includes("chutes/"); + }, +}; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 3a8d146..22fcb51 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -9,8 +9,15 @@ import { copilotProvider } from "./copilot.js"; import { openaiProvider } from "./openai.js"; import { googleAntigravityProvider } from "./google-antigravity.js"; import { firmwareProvider } from "./firmware.js"; +import { chutesProvider } from "./chutes.js"; export function getProviders(): QuotaProvider[] { // Order here defines display ordering in the toast. - return [copilotProvider, openaiProvider, firmwareProvider, googleAntigravityProvider]; + return [ + copilotProvider, + openaiProvider, + firmwareProvider, + chutesProvider, + googleAntigravityProvider, + ]; } diff --git a/tests/lib.chutes.test.ts b/tests/lib.chutes.test.ts new file mode 100644 index 0000000..0fa02e7 --- /dev/null +++ b/tests/lib.chutes.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { queryChutesQuota } from "../src/lib/chutes.js"; + +// Mock auth reader +vi.mock("../src/lib/opencode-auth.js", () => ({ + readAuthFile: vi.fn(), +})); + +// Mock config paths +vi.mock("../src/lib/chutes-config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getOpencodeConfigCandidatePaths: vi.fn(() => []), + }; +}); + +describe("queryChutesQuota", () => { + beforeEach(() => { + vi.stubGlobal("process", { + ...process, + env: { ...process.env }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("returns null when not configured", async () => { + const { readAuthFile } = await import("../src/lib/opencode-auth.js"); + (readAuthFile as any).mockResolvedValueOnce({}); + + // Ensure env is empty + delete process.env.CHUTES_API_KEY; + + await expect(queryChutesQuota()).resolves.toBeNull(); + }); + + it("returns quota data from API", async () => { + process.env.CHUTES_API_KEY = "test-key"; + + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + quota: 1000, + used: 250, + }), + { status: 200 }, + ), + ) as any, + ); + + const out = await queryChutesQuota(); + expect(out && out.success ? out.percentRemaining : -1).toBe(75); + }); + + it("handles API errors", async () => { + process.env.CHUTES_API_KEY = "test-key"; + + vi.stubGlobal("fetch", vi.fn(async () => new Response("Unauthorized", { status: 401 })) as any); + + const out = await queryChutesQuota(); + expect(out && !out.success ? out.error : "").toContain("Chutes API error 401"); + }); + + it("handles zero quota safely", async () => { + process.env.CHUTES_API_KEY = "test-key"; + + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + quota: 0, + used: 0, + }), + { status: 200 }, + ), + ) as any, + ); + + const out = await queryChutesQuota(); + expect(out && out.success ? out.percentRemaining : -1).toBe(0); + }); +}); From d4cdce4d761493be6290f56687f8a16efc0aa373 Mon Sep 17 00:00:00 2001 From: Kreato Date: Tue, 27 Jan 2026 10:06:20 +0300 Subject: [PATCH 2/4] style: align Chutes provider with project conventions --- src/providers/chutes.ts | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/providers/chutes.ts b/src/providers/chutes.ts index fa1311d..17d2159 100644 --- a/src/providers/chutes.ts +++ b/src/providers/chutes.ts @@ -1,14 +1,33 @@ -import type { QuotaProvider, QuotaProviderResult } from "../lib/entries.js"; +/** + * Chutes AI provider wrapper. + */ + +import type { QuotaProvider, QuotaProviderContext, QuotaProviderResult } from "../lib/entries.js"; import { queryChutesQuota, hasChutesApiKeyConfigured } from "../lib/chutes.js"; export const chutesProvider: QuotaProvider = { id: "chutes", - async isAvailable(): Promise { + async isAvailable(ctx: QuotaProviderContext): Promise { + // Best-effort: if OpenCode exposes a chutes provider, prefer that. + try { + const resp = await ctx.client.config.providers(); + const ids = new Set((resp.data?.providers ?? []).map((p) => p.id)); + if (ids.has("chutes") || ids.has("chutes-ai")) return true; + } catch { + // ignore + } + return await hasChutesApiKeyConfigured(); }, - async fetch(): Promise { + matchesCurrentModel(model: string): boolean { + const provider = model.split("/")[0]?.toLowerCase(); + if (!provider) return false; + return provider.includes("chutes"); + }, + + async fetch(_ctx: QuotaProviderContext): Promise { const result = await queryChutesQuota(); if (!result) { @@ -35,8 +54,4 @@ export const chutesProvider: QuotaProvider = { errors: [], }; }, - - matchesCurrentModel(model: string): boolean { - return model.toLowerCase().includes("chutes/"); - }, }; From f47207ca226e55a3edc0c1967b8091ea7db9562d Mon Sep 17 00:00:00 2001 From: Kreato Date: Tue, 27 Jan 2026 10:07:32 +0300 Subject: [PATCH 3/4] feat: add automatic daily reset time for Chutes provider --- src/lib/chutes.ts | 9 +++++++++ tests/lib.chutes.test.ts | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/src/lib/chutes.ts b/src/lib/chutes.ts index ad06f3a..d291100 100644 --- a/src/lib/chutes.ts +++ b/src/lib/chutes.ts @@ -24,6 +24,14 @@ function clampPercent(n: number): number { return Math.max(0, Math.min(100, Math.round(n))); } +function getNextDailyResetUtc(): string { + const now = new Date(); + const reset = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1, 0, 0, 0, 0), + ); + return reset.toISOString(); +} + async function fetchWithTimeout(url: string, options: RequestInit): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); @@ -92,6 +100,7 @@ export async function queryChutesQuota(): Promise { return { success: true, percentRemaining, + resetTimeIso: getNextDailyResetUtc(), }; } catch (err) { return { diff --git a/tests/lib.chutes.test.ts b/tests/lib.chutes.test.ts index 0fa02e7..a582ea9 100644 --- a/tests/lib.chutes.test.ts +++ b/tests/lib.chutes.test.ts @@ -17,6 +17,8 @@ vi.mock("../src/lib/chutes-config.js", async (importOriginal) => { describe("queryChutesQuota", () => { beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T12:00:00.000Z")); vi.stubGlobal("process", { ...process, env: { ...process.env }, @@ -24,6 +26,7 @@ describe("queryChutesQuota", () => { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); vi.clearAllMocks(); }); @@ -57,6 +60,7 @@ describe("queryChutesQuota", () => { const out = await queryChutesQuota(); expect(out && out.success ? out.percentRemaining : -1).toBe(75); + expect(out && out.success ? out.resetTimeIso : "").toBe("2026-01-02T00:00:00.000Z"); }); it("handles API errors", async () => { From 66aec6208e03b0396d9c24aadc4eab236cfe6cfb Mon Sep 17 00:00:00 2001 From: "Shawn L. Kiser" <35721408+slkiser@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:15:06 +0100 Subject: [PATCH 4/4] Prioritize local config paths over global in config search --- src/lib/chutes-config.ts | 4 ++-- src/lib/firmware-config.ts | 6 +++--- tests/lib.chutes.test.ts | 31 +++++++++++++++++-------------- tests/lib.firmware-config.test.ts | 14 +++++++------- 4 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/lib/chutes-config.ts b/src/lib/chutes-config.ts index 3838738..ff7b5c1 100644 --- a/src/lib/chutes-config.ts +++ b/src/lib/chutes-config.ts @@ -127,10 +127,10 @@ export function getOpencodeConfigCandidatePaths(): Array<{ path: string; isJsonc const configBaseDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); return [ - { path: join(configBaseDir, "opencode", "opencode.jsonc"), isJsonc: true }, - { path: join(configBaseDir, "opencode", "opencode.json"), isJsonc: false }, { path: join(cwd, "opencode.jsonc"), isJsonc: true }, { path: join(cwd, "opencode.json"), isJsonc: false }, + { path: join(configBaseDir, "opencode", "opencode.jsonc"), isJsonc: true }, + { path: join(configBaseDir, "opencode", "opencode.json"), isJsonc: false }, ]; } diff --git a/src/lib/firmware-config.ts b/src/lib/firmware-config.ts index 96eb605..ff8652f 100644 --- a/src/lib/firmware-config.ts +++ b/src/lib/firmware-config.ts @@ -136,13 +136,13 @@ export function getOpencodeConfigCandidatePaths(): Array<{ path: string; isJsonc const cwd = process.cwd(); const configBaseDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); - // Order: global config first, then local overrides + // Order: local overrides first, then global fallback // Check both .json and .jsonc variants return [ - { path: join(configBaseDir, "opencode", "opencode.jsonc"), isJsonc: true }, - { path: join(configBaseDir, "opencode", "opencode.json"), isJsonc: false }, { path: join(cwd, "opencode.jsonc"), isJsonc: true }, { path: join(cwd, "opencode.json"), isJsonc: false }, + { path: join(configBaseDir, "opencode", "opencode.jsonc"), isJsonc: true }, + { path: join(configBaseDir, "opencode", "opencode.json"), isJsonc: false }, ]; } diff --git a/tests/lib.chutes.test.ts b/tests/lib.chutes.test.ts index a582ea9..17b9d3c 100644 --- a/tests/lib.chutes.test.ts +++ b/tests/lib.chutes.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; import { queryChutesQuota } from "../src/lib/chutes.js"; // Mock auth reader @@ -6,29 +9,29 @@ vi.mock("../src/lib/opencode-auth.js", () => ({ readAuthFile: vi.fn(), })); -// Mock config paths -vi.mock("../src/lib/chutes-config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getOpencodeConfigCandidatePaths: vi.fn(() => []), - }; -}); - describe("queryChutesQuota", () => { + const originalEnv = process.env; + const originalCwd = process.cwd(); + let tempDir: string; + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T12:00:00.000Z")); - vi.stubGlobal("process", { - ...process, - env: { ...process.env }, - }); + + tempDir = mkdtempSync(join(tmpdir(), "opencode-quota-chutes-")); + process.env = { ...originalEnv, XDG_CONFIG_HOME: tempDir }; + process.chdir(tempDir); }); afterEach(() => { vi.useRealTimers(); - vi.unstubAllGlobals(); vi.clearAllMocks(); + + process.chdir(originalCwd); + process.env = originalEnv; + rmSync(tempDir, { recursive: true, force: true }); + + vi.unstubAllGlobals(); }); it("returns null when not configured", async () => { diff --git a/tests/lib.firmware-config.test.ts b/tests/lib.firmware-config.test.ts index ca398f7..312b6ea 100644 --- a/tests/lib.firmware-config.test.ts +++ b/tests/lib.firmware-config.test.ts @@ -310,19 +310,19 @@ describe("firmware-config", () => { const { getOpencodeConfigCandidatePaths } = await import("../src/lib/firmware-config.js"); const paths = getOpencodeConfigCandidatePaths(); - // Should have 4 candidates: global jsonc, global json, local jsonc, local json + // Should have 4 candidates: local jsonc, local json, global jsonc, global json expect(paths.length).toBe(4); - // Global paths should come before local paths - expect(paths[0].path).toContain(".config"); + // Local paths should come before global paths + expect(paths[0].path).toContain(process.cwd()); expect(paths[0].isJsonc).toBe(true); - expect(paths[1].path).toContain(".config"); + expect(paths[1].path).toContain(process.cwd()); expect(paths[1].isJsonc).toBe(false); - // Local paths - expect(paths[2].path).toContain(process.cwd()); + // Global paths + expect(paths[2].path).toContain(".config"); expect(paths[2].isJsonc).toBe(true); - expect(paths[3].path).toContain(process.cwd()); + expect(paths[3].path).toContain(".config"); expect(paths[3].isJsonc).toBe(false); }); });