From 22a1719e36af3a756b5e4dfa03a728a6b912fb77 Mon Sep 17 00:00:00 2001 From: Praveen Juge Date: Fri, 6 Mar 2026 18:49:34 +0530 Subject: [PATCH 1/2] feat(opencode-go): add OpenCode Go plugin with tracking and limits --- README.md | 1 + docs/providers/opencode-go.md | 62 +++++++ plugins/opencode-go/icon.svg | 1 + plugins/opencode-go/plugin.js | 277 +++++++++++++++++++++++++++++ plugins/opencode-go/plugin.json | 37 ++++ plugins/opencode-go/plugin.test.js | 246 +++++++++++++++++++++++++ 6 files changed, 624 insertions(+) create mode 100644 docs/providers/opencode-go.md create mode 100644 plugins/opencode-go/icon.svg create mode 100644 plugins/opencode-go/plugin.js create mode 100644 plugins/opencode-go/plugin.json create mode 100644 plugins/opencode-go/plugin.test.js diff --git a/README.md b/README.md index a4feede..84c27e2 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**JetBrains AI Assistant**](docs/providers/jetbrains-ai-assistant.md) / quota, remaining - [**Kimi Code**](docs/providers/kimi.md) / session, weekly - [**MiniMax**](docs/providers/minimax.md) / coding plan session +- [**OpenCode Go**](docs/providers/opencode-go.md) / 5h, weekly, monthly spend limits - [**Windsurf**](docs/providers/windsurf.md) / prompt credits, flex credits - [**Z.ai**](docs/providers/zai.md) / session, weekly, web searches diff --git a/docs/providers/opencode-go.md b/docs/providers/opencode-go.md new file mode 100644 index 0000000..dbd952f --- /dev/null +++ b/docs/providers/opencode-go.md @@ -0,0 +1,62 @@ +# OpenCode Go + +> Uses local OpenCode history from SQLite to track observed OpenCode Go spend on this machine. + +## Overview + +- **Source of truth:** `~/.local/share/opencode/opencode.db` +- **Auth discovery:** `~/.local/share/opencode/auth.json` +- **Provider ID:** `opencode-go` +- **Usage scope:** local observed assistant spend only + +## Detection + +The plugin enables when either condition is true: + +- `~/.local/share/opencode/auth.json` contains an `opencode-go` entry with a non-empty `key` +- local OpenCode history already contains `opencode-go` assistant messages with numeric `cost` + +If neither signal exists, the plugin stays hidden. + +## Data Source + +OpenUsage reads the local OpenCode SQLite database directly: + +```sql +SELECT + CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, + CAST(json_extract(data, '$.cost') AS REAL) AS cost +FROM message +WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND json_type(data, '$.cost') IN ('integer', 'real') +``` + +Only assistant messages with numeric `cost` count. Missing remote or other-device usage is not estimated. + +## Limits + +OpenUsage uses the current published OpenCode Go plan limits from the official docs: + +- `5h`: `$12` +- `Weekly`: `$30` +- `Monthly`: `$60` + +Bars show observed local spend as a percentage of those fixed limits and clamp at `100%`. + +## Window Rules + +- `5h`: rolling last 5 hours from now +- `Weekly`: UTC Monday `00:00` through the next UTC Monday `00:00` +- `Monthly`: inferred subscription-style monthly window using the earliest local OpenCode Go usage timestamp as the anchor + +Monthly usage is inferred from local history, not read from OpenCode’s account API. OpenUsage reuses the earliest observed local OpenCode Go usage timestamp as the monthly anchor. If no local history exists yet, it falls back to UTC calendar month boundaries until the first Go usage is recorded. + +## Failure Behavior + +If auth or prior history already indicates OpenCode Go is in use, but SQLite becomes unreadable or malformed, the provider stays visible and falls back to a soft empty state instead of failing hard. + +## Future Compatibility + +The public provider identity stays `opencode-go`. If OpenCode later exposes account-truth usage by API key, OpenUsage can swap the backend without changing the provider ID or UI contract. diff --git a/plugins/opencode-go/icon.svg b/plugins/opencode-go/icon.svg new file mode 100644 index 0000000..b74c401 --- /dev/null +++ b/plugins/opencode-go/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/opencode-go/plugin.js b/plugins/opencode-go/plugin.js new file mode 100644 index 0000000..747a0d0 --- /dev/null +++ b/plugins/opencode-go/plugin.js @@ -0,0 +1,277 @@ +(function () { + const PROVIDER_ID = "opencode-go"; + const AUTH_PATH = "~/.local/share/opencode/auth.json"; + const DB_PATH = "~/.local/share/opencode/opencode.db"; + const FIVE_HOURS_MS = 5 * 60 * 60 * 1000; + const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + const LIMITS = { + session: 12, + weekly: 30, + monthly: 60, + }; + + const HISTORY_EXISTS_SQL = ` + SELECT 1 AS present + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND json_type(data, '$.cost') IN ('integer', 'real') + LIMIT 1 + `; + + const HISTORY_ROWS_SQL = ` + SELECT + CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, + CAST(json_extract(data, '$.cost') AS REAL) AS cost + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND json_type(data, '$.cost') IN ('integer', 'real') + `; + + function readNumber(value) { + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + + function readNowMs() { + return Date.now(); + } + + function clampPercent(used, limit) { + if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0) + return 0; + const percent = (used / limit) * 100; + if (!Number.isFinite(percent)) return 0; + return Math.round(Math.max(0, Math.min(100, percent)) * 10) / 10; + } + + function toIso(ms) { + if (!Number.isFinite(ms)) return null; + return new Date(ms).toISOString(); + } + + function startOfUtcWeek(nowMs) { + const date = new Date(nowMs); + const offset = (date.getUTCDay() + 6) % 7; + date.setUTCDate(date.getUTCDate() - offset); + date.setUTCHours(0, 0, 0, 0); + return date.getTime(); + } + + function startOfUtcMonth(nowMs) { + const date = new Date(nowMs); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0); + } + + function startOfNextUtcMonth(nowMs) { + const date = new Date(nowMs); + return Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth() + 1, + 1, + 0, + 0, + 0, + 0, + ); + } + + function shiftMonth(year, month, delta) { + const total = year * 12 + month + delta; + return [Math.floor(total / 12), ((total % 12) + 12) % 12]; + } + + function anchorMonth(year, month, anchorDate) { + const maxDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + return Date.UTC( + year, + month, + Math.min(anchorDate.getUTCDate(), maxDay), + anchorDate.getUTCHours(), + anchorDate.getUTCMinutes(), + anchorDate.getUTCSeconds(), + anchorDate.getUTCMilliseconds(), + ); + } + + function anchoredMonthBounds(nowMs, anchorMs) { + if (!Number.isFinite(anchorMs)) { + const startMs = startOfUtcMonth(nowMs); + return { startMs, endMs: startOfNextUtcMonth(nowMs) }; + } + + const nowDate = new Date(nowMs); + const anchorDate = new Date(anchorMs); + let year = nowDate.getUTCFullYear(); + let month = nowDate.getUTCMonth(); + let startMs = anchorMonth(year, month, anchorDate); + + if (startMs > nowMs) { + const previous = shiftMonth(year, month, -1); + year = previous[0]; + month = previous[1]; + startMs = anchorMonth(year, month, anchorDate); + } + + const next = shiftMonth(year, month, 1); + return { + startMs, + endMs: anchorMonth(next[0], next[1], anchorDate), + }; + } + + function sumRange(rows, startMs, endMs) { + let total = 0; + for (let i = 0; i < rows.length; i += 1) { + const row = rows[i]; + if (row.createdMs < startMs || row.createdMs >= endMs) continue; + total += row.cost; + } + return Math.round(total * 10000) / 10000; + } + + function nextRollingReset(rows, nowMs) { + const startMs = nowMs - FIVE_HOURS_MS; + let oldest = null; + for (let i = 0; i < rows.length; i += 1) { + const row = rows[i]; + if (row.createdMs < startMs || row.createdMs >= nowMs) continue; + if (oldest === null || row.createdMs < oldest) oldest = row.createdMs; + } + return toIso((oldest === null ? nowMs : oldest) + FIVE_HOURS_MS); + } + + function queryRows(ctx, sql) { + try { + const raw = ctx.host.sqlite.query(DB_PATH, sql); + const rows = Array.isArray(raw) ? raw : ctx.util.tryParseJson(raw); + if (!Array.isArray(rows)) { + ctx.host.log.warn("sqlite query returned non-array result"); + return { ok: false, rows: [] }; + } + return { ok: true, rows }; + } catch (e) { + ctx.host.log.warn("sqlite query failed: " + String(e)); + return { ok: false, rows: [] }; + } + } + + function loadAuthKey(ctx) { + if (!ctx.host.fs.exists(AUTH_PATH)) return null; + + try { + const text = ctx.host.fs.readText(AUTH_PATH); + const parsed = ctx.util.tryParseJson(text); + if (!parsed || typeof parsed !== "object") { + ctx.host.log.warn("opencode auth file is not valid json"); + return null; + } + const entry = parsed[PROVIDER_ID]; + if (!entry || typeof entry !== "object") return null; + const key = typeof entry.key === "string" ? entry.key.trim() : ""; + return key || null; + } catch (e) { + ctx.host.log.warn("opencode auth read failed: " + String(e)); + return null; + } + } + + function hasHistory(ctx) { + const result = queryRows(ctx, HISTORY_EXISTS_SQL); + if (!result.ok) return { ok: false, present: false }; + return { ok: true, present: result.rows.length > 0 }; + } + + function loadHistory(ctx) { + const result = queryRows(ctx, HISTORY_ROWS_SQL); + if (!result.ok) return result; + + const rows = []; + for (let i = 0; i < result.rows.length; i += 1) { + const row = result.rows[i]; + if (!row || typeof row !== "object") continue; + const createdMs = readNumber(row.createdMs); + const cost = readNumber(row.cost); + if (createdMs === null || createdMs <= 0) continue; + if (cost === null || cost < 0) continue; + rows.push({ createdMs, cost }); + } + + return { ok: true, rows }; + } + + function buildProgressLines(ctx, rows, nowMs) { + const sessionStartMs = nowMs - FIVE_HOURS_MS; + const weeklyStartMs = startOfUtcWeek(nowMs); + const weeklyEndMs = weeklyStartMs + WEEK_MS; + let earliestMs = null; + for (let i = 0; i < rows.length; i += 1) { + const createdMs = rows[i].createdMs; + if (!Number.isFinite(createdMs)) continue; + if (earliestMs === null || createdMs < earliestMs) earliestMs = createdMs; + } + const monthBounds = anchoredMonthBounds(nowMs, earliestMs); + const monthlyStartMs = monthBounds.startMs; + const monthlyEndMs = monthBounds.endMs; + + const sessionCost = sumRange(rows, sessionStartMs, nowMs); + const weeklyCost = sumRange(rows, weeklyStartMs, weeklyEndMs); + const monthlyCost = sumRange(rows, monthlyStartMs, monthlyEndMs); + + return [ + ctx.line.progress({ + label: "5h", + used: clampPercent(sessionCost, LIMITS.session), + limit: 100, + format: { kind: "percent" }, + resetsAt: nextRollingReset(rows, nowMs), + periodDurationMs: FIVE_HOURS_MS, + }), + ctx.line.progress({ + label: "Weekly", + used: clampPercent(weeklyCost, LIMITS.weekly), + limit: 100, + format: { kind: "percent" }, + resetsAt: toIso(weeklyEndMs), + periodDurationMs: WEEK_MS, + }), + ctx.line.progress({ + label: "Monthly", + used: clampPercent(monthlyCost, LIMITS.monthly), + limit: 100, + format: { kind: "percent" }, + resetsAt: toIso(monthlyEndMs), + periodDurationMs: monthlyEndMs - monthlyStartMs, + }), + ]; + } + + function probe(ctx) { + const authKey = loadAuthKey(ctx); + const history = hasHistory(ctx); + const detected = !!authKey || (history.ok && history.present); + + if (!detected) { + throw "OpenCode Go not detected. Log in with OpenCode Go or use it locally first."; + } + + if (!history.ok) { + return { plan: "Go", lines: [] }; + } + + const rowsResult = loadHistory(ctx); + if (!rowsResult.ok) { + return { plan: "Go", lines: [] }; + } + + return { + plan: "Go", + lines: buildProgressLines(ctx, rowsResult.rows, readNowMs()), + }; + } + + globalThis.__openusage_plugin = { id: PROVIDER_ID, probe }; +})(); diff --git a/plugins/opencode-go/plugin.json b/plugins/opencode-go/plugin.json new file mode 100644 index 0000000..35f5458 --- /dev/null +++ b/plugins/opencode-go/plugin.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "id": "opencode-go", + "name": "OpenCode Go", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#000000", + "links": [ + { + "label": "Console", + "url": "https://opencode.ai/auth" + }, + { + "label": "Docs", + "url": "https://opencode.ai/docs/go/" + } + ], + "lines": [ + { + "type": "progress", + "label": "5h", + "scope": "overview", + "primaryOrder": 1 + }, + { + "type": "progress", + "label": "Weekly", + "scope": "detail" + }, + { + "type": "progress", + "label": "Monthly", + "scope": "detail" + } + ] +} \ No newline at end of file diff --git a/plugins/opencode-go/plugin.test.js b/plugins/opencode-go/plugin.test.js new file mode 100644 index 0000000..a41e946 --- /dev/null +++ b/plugins/opencode-go/plugin.test.js @@ -0,0 +1,246 @@ +import { readFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeCtx } from "../test-helpers.js"; + +const AUTH_PATH = "~/.local/share/opencode/auth.json"; + +const loadPlugin = async () => { + await import("./plugin.js"); + return globalThis.__openusage_plugin; +}; + +function setAuth(ctx, value = "go-key") { + ctx.host.fs.writeText( + AUTH_PATH, + JSON.stringify({ + "opencode-go": { type: "api-key", key: value }, + }), + ); +} + +function setHistoryQuery(ctx, rows, options = {}) { + const list = Array.isArray(rows) ? rows : []; + ctx.host.sqlite.query.mockImplementation((dbPath, sql) => { + expect(dbPath).toBe("~/.local/share/opencode/opencode.db"); + + if (String(sql).includes("SELECT 1 AS present")) { + if (options.assertFilters !== false) { + expect(String(sql)).toContain( + "json_extract(data, '$.providerID') = 'opencode-go'", + ); + expect(String(sql)).toContain( + "json_extract(data, '$.role') = 'assistant'", + ); + expect(String(sql)).toContain( + "json_type(data, '$.cost') IN ('integer', 'real')", + ); + } + return JSON.stringify(list.length > 0 ? [{ present: 1 }] : []); + } + + if (options.assertFilters !== false) { + expect(String(sql)).toContain( + "json_extract(data, '$.providerID') = 'opencode-go'", + ); + expect(String(sql)).toContain( + "json_extract(data, '$.role') = 'assistant'", + ); + expect(String(sql)).toContain( + "json_type(data, '$.cost') IN ('integer', 'real')", + ); + expect(String(sql)).toContain( + "COALESCE(json_extract(data, '$.time.created'), time_created)", + ); + } + + return JSON.stringify(list); + }); +} + +describe("opencode-go plugin", () => { + beforeEach(() => { + delete globalThis.__openusage_plugin; + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("ships plugin metadata with links and expected line layout", () => { + const manifest = JSON.parse( + readFileSync("plugins/opencode-go/plugin.json", "utf8"), + ); + + expect(manifest.id).toBe("opencode-go"); + expect(manifest.name).toBe("OpenCode Go"); + expect(manifest.brandColor).toBe("#000000"); + expect(manifest.links).toEqual([ + { label: "Console", url: "https://opencode.ai/auth" }, + { label: "Docs", url: "https://opencode.ai/docs/go/" }, + ]); + expect(manifest.lines).toEqual([ + { type: "progress", label: "5h", scope: "overview", primaryOrder: 1 }, + { type: "progress", label: "Weekly", scope: "detail" }, + { type: "progress", label: "Monthly", scope: "detail" }, + ]); + }); + + it("throws when neither auth nor local history is present", async () => { + const ctx = makeCtx(); + setHistoryQuery(ctx, []); + + const plugin = await loadPlugin(); + expect(() => plugin.probe(ctx)).toThrow( + "OpenCode Go not detected. Log in with OpenCode Go or use it locally first.", + ); + }); + + it("enables with auth only and returns zeroed bars", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00.000Z")); + + const ctx = makeCtx(); + setAuth(ctx); + setHistoryQuery(ctx, []); + + const plugin = await loadPlugin(); + const result = plugin.probe(ctx); + + expect(result.plan).toBe("Go"); + expect(result.lines.map((line) => line.label)).toEqual([ + "5h", + "Weekly", + "Monthly", + ]); + expect(result.lines.every((line) => line.used === 0)).toBe(true); + expect(result.lines[0].resetsAt).toBe("2026-03-06T17:00:00.000Z"); + expect(result.lines[1].resetsAt).toBe("2026-03-09T00:00:00.000Z"); + expect(result.lines[2].resetsAt).toBe("2026-04-01T00:00:00.000Z"); + }); + + it("enables with history only when auth is absent", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00.000Z")); + + const ctx = makeCtx(); + setHistoryQuery(ctx, [ + { createdMs: Date.parse("2026-03-06T11:00:00.000Z"), cost: 3 }, + ]); + + const plugin = await loadPlugin(); + const result = plugin.probe(ctx); + + expect(result.plan).toBe("Go"); + expect(result.lines[0].used).toBe(25); + }); + + it("uses row timestamp fallback when JSON timestamp is missing", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00.000Z")); + + const ctx = makeCtx(); + setHistoryQuery(ctx, [ + { createdMs: Date.parse("2026-03-06T09:30:00.000Z"), cost: 1.2 }, + ]); + + const plugin = await loadPlugin(); + const result = plugin.probe(ctx); + + expect(result.lines[0].used).toBe(10); + expect(result.lines[0].resetsAt).toBe("2026-03-06T14:30:00.000Z"); + }); + + it("counts only the rolling 5h window", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00.000Z")); + + const ctx = makeCtx(); + setHistoryQuery(ctx, [ + { createdMs: Date.parse("2026-03-06T06:30:00.000Z"), cost: 9 }, + { createdMs: Date.parse("2026-03-06T08:00:00.000Z"), cost: 2.4 }, + { createdMs: Date.parse("2026-03-06T10:00:00.000Z"), cost: 1.2 }, + ]); + + const plugin = await loadPlugin(); + const result = plugin.probe(ctx); + + expect(result.lines[0].used).toBe(30); + expect(result.lines[0].resetsAt).toBe("2026-03-06T13:00:00.000Z"); + }); + + it("uses UTC Monday boundaries for weekly aggregation", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00.000Z")); + + const ctx = makeCtx(); + setHistoryQuery(ctx, [ + { createdMs: Date.parse("2026-03-01T23:59:59.000Z"), cost: 10 }, + { createdMs: Date.parse("2026-03-02T00:00:00.000Z"), cost: 6 }, + { createdMs: Date.parse("2026-03-05T09:00:00.000Z"), cost: 3 }, + ]); + + const plugin = await loadPlugin(); + const result = plugin.probe(ctx); + const weeklyLine = result.lines.find((line) => line.label === "Weekly"); + + expect(weeklyLine.used).toBe(30); + expect(weeklyLine.resetsAt).toBe("2026-03-09T00:00:00.000Z"); + }); + + it("uses the earliest local usage timestamp as the monthly anchor", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00.000Z")); + + const ctx = makeCtx(); + setHistoryQuery(ctx, [ + { createdMs: Date.parse("2026-02-25T07:53:16.000Z"), cost: 2.181 }, + { createdMs: Date.parse("2026-03-01T00:00:00.000Z"), cost: 0.2 }, + { createdMs: Date.parse("2026-03-04T12:00:00.000Z"), cost: 0.2904 }, + ]); + + const plugin = await loadPlugin(); + const result = plugin.probe(ctx); + const monthlyLine = result.lines.find((line) => line.label === "Monthly"); + + expect(monthlyLine.used).toBe(4.5); + expect(monthlyLine.resetsAt).toBe("2026-03-25T07:53:16.000Z"); + expect(monthlyLine.periodDurationMs).toBe(28 * 24 * 60 * 60 * 1000); + }); + + it("clamps percentages at 100", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00.000Z")); + + const ctx = makeCtx(); + setHistoryQuery(ctx, [ + { createdMs: Date.parse("2026-03-06T11:00:00.000Z"), cost: 40 }, + ]); + + const plugin = await loadPlugin(); + const result = plugin.probe(ctx); + + expect(result.lines[0].used).toBe(100); + }); + + it("returns a soft empty state when sqlite is unreadable but auth exists", async () => { + const ctx = makeCtx(); + setAuth(ctx); + ctx.host.sqlite.query.mockImplementation(() => { + throw new Error("disk I/O error"); + }); + + const plugin = await loadPlugin(); + expect(plugin.probe(ctx)).toEqual({ plan: "Go", lines: [] }); + }); + + it("returns a soft empty state when sqlite returns malformed JSON and auth exists", async () => { + const ctx = makeCtx(); + setAuth(ctx); + ctx.host.sqlite.query.mockReturnValue("not-json"); + + const plugin = await loadPlugin(); + expect(plugin.probe(ctx)).toEqual({ plan: "Go", lines: [] }); + }); +}); From 0e3af80b3e2451e2ab0fa8427898d4566194fdfe Mon Sep 17 00:00:00 2001 From: Praveen Juge Date: Tue, 10 Mar 2026 12:36:15 +0530 Subject: [PATCH 2/2] Fix soft badge on SQLite failure --- docs/providers/opencode-go.md | 2 +- plugins/opencode-go/plugin.js | 14 ++++++++++++-- plugins/opencode-go/plugin.test.js | 24 ++++++++++++++++++++++-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/providers/opencode-go.md b/docs/providers/opencode-go.md index dbd952f..3cd934d 100644 --- a/docs/providers/opencode-go.md +++ b/docs/providers/opencode-go.md @@ -55,7 +55,7 @@ Monthly usage is inferred from local history, not read from OpenCode’s account ## Failure Behavior -If auth or prior history already indicates OpenCode Go is in use, but SQLite becomes unreadable or malformed, the provider stays visible and falls back to a soft empty state instead of failing hard. +If auth or prior history already indicates OpenCode Go is in use, but SQLite becomes unreadable or malformed, the provider stays visible and shows a grey `Status: No usage data` badge instead of failing hard. ## Future Compatibility diff --git a/plugins/opencode-go/plugin.js b/plugins/opencode-go/plugin.js index 747a0d0..5aca395 100644 --- a/plugins/opencode-go/plugin.js +++ b/plugins/opencode-go/plugin.js @@ -249,6 +249,16 @@ ]; } + function buildSoftEmptyLines(ctx) { + return [ + ctx.line.badge({ + label: "Status", + text: "No usage data", + color: "#a3a3a3", + }), + ]; + } + function probe(ctx) { const authKey = loadAuthKey(ctx); const history = hasHistory(ctx); @@ -259,12 +269,12 @@ } if (!history.ok) { - return { plan: "Go", lines: [] }; + return { plan: "Go", lines: buildSoftEmptyLines(ctx) }; } const rowsResult = loadHistory(ctx); if (!rowsResult.ok) { - return { plan: "Go", lines: [] }; + return { plan: "Go", lines: buildSoftEmptyLines(ctx) }; } return { diff --git a/plugins/opencode-go/plugin.test.js b/plugins/opencode-go/plugin.test.js index a41e946..8315cd9 100644 --- a/plugins/opencode-go/plugin.test.js +++ b/plugins/opencode-go/plugin.test.js @@ -232,7 +232,17 @@ describe("opencode-go plugin", () => { }); const plugin = await loadPlugin(); - expect(plugin.probe(ctx)).toEqual({ plan: "Go", lines: [] }); + expect(plugin.probe(ctx)).toEqual({ + plan: "Go", + lines: [ + { + type: "badge", + label: "Status", + text: "No usage data", + color: "#a3a3a3", + }, + ], + }); }); it("returns a soft empty state when sqlite returns malformed JSON and auth exists", async () => { @@ -241,6 +251,16 @@ describe("opencode-go plugin", () => { ctx.host.sqlite.query.mockReturnValue("not-json"); const plugin = await loadPlugin(); - expect(plugin.probe(ctx)).toEqual({ plan: "Go", lines: [] }); + expect(plugin.probe(ctx)).toEqual({ + plan: "Go", + lines: [ + { + type: "badge", + label: "Status", + text: "No usage data", + color: "#a3a3a3", + }, + ], + }); }); });