From c49cbdfc3acf1a26e6e6ff255c068cb4dbb6a81e Mon Sep 17 00:00:00 2001 From: Mike Kold Hermann Date: Tue, 5 May 2026 12:16:36 +0200 Subject: [PATCH 01/10] feat: add Command Code plugin --- README.md | 1 + docs/providers/command-code.md | 84 ++++++ plugins/command-code/icon.svg | 1 + plugins/command-code/plugin.js | 162 ++++++++++++ plugins/command-code/plugin.json | 17 ++ plugins/command-code/plugin.test.js | 387 ++++++++++++++++++++++++++++ 6 files changed, 652 insertions(+) create mode 100644 docs/providers/command-code.md create mode 100644 plugins/command-code/icon.svg create mode 100644 plugins/command-code/plugin.js create mode 100644 plugins/command-code/plugin.json create mode 100644 plugins/command-code/plugin.test.js diff --git a/README.md b/README.md index 1bcdda59..891bf321 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Antigravity**](docs/providers/antigravity.md) / all models - [**Claude**](docs/providers/claude.md) / session, weekly, peak/off-peak, extra usage, local token usage (ccusage) - [**Codex**](docs/providers/codex.md) / session, weekly, reviews, credits +- [**Command Code**](docs/providers/command-code.md) / plan usage, monthly credits - [**Copilot**](docs/providers/copilot.md) / premium, chat, completions - [**Cursor**](docs/providers/cursor.md) / credits, total usage, auto usage, API usage, on-demand, CLI auth - [**Factory / Droid**](docs/providers/factory.md) / standard, premium tokens diff --git a/docs/providers/command-code.md b/docs/providers/command-code.md new file mode 100644 index 00000000..10730e5a --- /dev/null +++ b/docs/providers/command-code.md @@ -0,0 +1,84 @@ +# Command Code + +> Uses the Command Code billing API to track plan credits usage. + +## Overview + +- **Source of truth:** `https://api.commandcode.ai` +- **Auth discovery:** `~/.commandcode/auth.json` +- **Provider ID:** `command-code` +- **Usage scope:** account-level monthly plan credits + +## Detection + +The plugin enables when `~/.commandcode/auth.json` exists and contains a non-empty `apiKey`. + +If the secrets file is missing, the plugin stays hidden. + +## Data Source + +OpenUsage calls two Command Code API endpoints: + +``` +GET https://api.commandcode.ai/alpha/billing/credits +GET https://api.commandcode.ai/alpha/billing/subscriptions +``` + +Both are authenticated with `Authorization: Bearer `. + +### Credits Response + +```jsonc +{ + "credits": { + "monthlyCredits": 9.9859 // remaining plan credits + } +} +``` + +### Subscriptions Response + +```jsonc +{ + "success": true, + "data": { + "planId": "individual-go", + "currentPeriodEnd": "2026-06-05T07:58:40.000Z" + } +} +``` + +`planId` determines which plan limit applies. + +## Limits + +OpenUsage uses the current published Command Code plan limits: + +- `individual-go`: `$10` +- `individual-pro`: `$30` +- `individual-max`: `$150` +- `individual-ultra`: `$300` +- `teams-pro`: `$40` + +Bars show used credits in dollars (plan label) and as a percentage (Monthly Quota), clamped at `100%`. + +## Window Rules + +- **Period:** subscription billing period (`currentPeriodEnd` from the API) +- **Resets at:** UTC midnight, 24h period duration displayed + +Usage is account-level from the API, not estimated from local history. + +## Failure Behavior + +| Condition | Behavior | +|---|---| +| Secrets file missing | Plugin hidden | +| API returns 401/403 | Red error: `Session expired. Re-authenticate in CommandCode.` | +| API returns HTTP error | Red error with status code or detail message | +| Network failure | Red error: `Request failed. Check your connection.` | +| Unexpected response structure | Red error: `Could not parse usage data.` | + +## Future Compatibility + +The public provider identity stays `command-code`. If Command Code later changes billing endpoint paths or response schemas, OpenUsage can update the plugin without changing the provider ID or UI contract. diff --git a/plugins/command-code/icon.svg b/plugins/command-code/icon.svg new file mode 100644 index 00000000..b234eed5 --- /dev/null +++ b/plugins/command-code/icon.svg @@ -0,0 +1 @@ + diff --git a/plugins/command-code/plugin.js b/plugins/command-code/plugin.js new file mode 100644 index 00000000..8463ad8b --- /dev/null +++ b/plugins/command-code/plugin.js @@ -0,0 +1,162 @@ +(function () { + var SECRETS_FILE = "~/.commandcode/auth.json" + var SECRETS_KEY = "apiKey" + var CREDITS_URL = "https://api.commandcode.ai/alpha/billing/credits" + var SUBS_URL = "https://api.commandcode.ai/alpha/billing/subscriptions" + + var PLAN_LIMITS = { + "individual-go": 10, + "individual-pro": 30, + "individual-max": 150, + "individual-ultra": 300, + "teams-pro": 40, + } + + var PLAN_LABELS = { + "individual-go": "Go", + "individual-pro": "Pro", + "individual-max": "Max", + "individual-ultra": "Ultra", + "teams-pro": "Teams Pro", + } + + function loadApiKey(ctx) { + if (!ctx.host.fs.exists(SECRETS_FILE)) return null + try { + var text = ctx.host.fs.readText(SECRETS_FILE) + var parsed = ctx.util.tryParseJson(text) + if (parsed && parsed[SECRETS_KEY]) { + ctx.host.log.info("api key loaded from secrets file") + return parsed[SECRETS_KEY] + } + } catch (e) { + ctx.host.log.warn("secrets file read failed: " + String(e)) + } + return null + } + + function fetchCredits(ctx, apiKey) { + return ctx.util.requestJson({ + method: "GET", + url: CREDITS_URL, + headers: { + "Authorization": "Bearer " + apiKey, + "Content-Type": "application/json", + }, + timeoutMs: 15000, + }) + } + + function fetchSubscriptions(ctx, apiKey) { + return ctx.util.requestJson({ + method: "GET", + url: SUBS_URL, + headers: { + "Authorization": "Bearer " + apiKey, + "Content-Type": "application/json", + }, + timeoutMs: 15000, + }) + } + + function formatPlanLabel(planId) { + return PLAN_LABELS[planId] || planId.split("-").map(function (w) { return w.charAt(0).toUpperCase() + w.slice(1) }).join(" ") + } + + async function probe(ctx) { + var apiKey = loadApiKey(ctx) + if (!apiKey) { + throw "CommandCode not installed. Install CommandCode to get started." + } + + var result + try { + result = fetchCredits(ctx, apiKey) + } catch (e) { + ctx.host.log.error("credits request failed: " + String(e)) + throw "Request failed. Check your connection." + } + + var resp = result.resp + var json = result.json + + if (resp.status === 401 || resp.status === 403) { + throw "Session expired. Re-authenticate in CommandCode." + } + if (resp.status < 200 || resp.status >= 300) { + var detail = json && json.error && json.error.message ? json.error.message : "" + if (detail) { + ctx.host.log.error("api returned " + resp.status + ": " + detail) + throw detail + } + ctx.host.log.error("api returned: " + resp.status) + throw "Request failed (HTTP " + resp.status + "). Try again later." + } + + if (!json || !json.credits || typeof json.credits.monthlyCredits !== "number") { + ctx.host.log.error("unexpected credits response structure") + throw "Could not parse usage data." + } + + var remaining = json.credits.monthlyCredits + + var subResult + try { + subResult = await fetchSubscriptions(ctx, apiKey) + } catch (e) { + ctx.host.log.error("subscription request failed: " + String(e)) + throw "Request failed. Check your connection." + } + + var subResp = subResult.resp + var subJson = subResult.json + + if (subResp.status === 401 || subResp.status === 403) { + throw "Session expired. Re-authenticate in CommandCode." + } + if (subResp.status < 200 || subResp.status >= 300) { + var detail = subJson && subJson.error && subJson.error.message ? subJson.error.message : "" + if (detail) { + ctx.host.log.error("api returned " + subResp.status + ": " + detail) + throw detail + } + ctx.host.log.error("api returned: " + subResp.status) + throw "Request failed (HTTP " + subResp.status + "). Try again later." + } + + if (!subJson || !subJson.success || !subJson.data) { + ctx.host.log.error("unexpected subscription response structure") + throw "Could not parse subscription data." + } + + var planId = subJson.data.planId + var total = PLAN_LIMITS[planId] || 0 + var used = Math.max(0, total - remaining) + + var resetsAtMs = new Date(subJson.data.currentPeriodEnd).getTime() + + var lines = [] + if (planId && total > 0) { + lines.push(ctx.line.progress({ + label: "Monthly Quota", + used: Math.min(100, Math.max(0, Math.round((used / total) * 100))), + limit: 100, + format: { kind: "percent" }, + resetsAt: ctx.util.toIso(resetsAtMs), + periodDurationMs: 30 * 24 * 3600 * 1000, + })) + lines.push(ctx.line.progress({ + label: formatPlanLabel(planId), + used: used, + limit: total, + format: { kind: "dollars" }, + resetsAt: ctx.util.toIso(resetsAtMs), + periodDurationMs: 30 * 24 * 3600 * 1000, + })) + } + + return { plan: planId, lines: lines } + } + + globalThis.__openusage_plugin = { id: "command-code", probe: probe } +})() diff --git a/plugins/command-code/plugin.json b/plugins/command-code/plugin.json new file mode 100644 index 00000000..4bc0e0d6 --- /dev/null +++ b/plugins/command-code/plugin.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "id": "command-code", + "name": "CommandCode", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#575757", + "links": [ + { "label": "Docs", "url": "https://commandcode.ai/docs" }, + { "label": "Dashboard", "url": "https://commandcode.ai/studio" } + ], + "lines": [ + { "type": "progress", "label": "Monthly Quota", "scope": "overview", "primaryOrder": 1 }, + { "type": "progress", "label": "Plan", "scope": "overview" } + ] +} diff --git a/plugins/command-code/plugin.test.js b/plugins/command-code/plugin.test.js new file mode 100644 index 00000000..2a4933bd --- /dev/null +++ b/plugins/command-code/plugin.test.js @@ -0,0 +1,387 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { makeCtx } from "../test-helpers.js" + +var SECRETS_FILE = "~/.commandcode/auth.json" +var SECRETS_KEY = "apiKey" +var CREDITS_URL = "https://api.commandcode.ai/alpha/billing/credits" +var SUBS_URL = "https://api.commandcode.ai/alpha/billing/subscriptions" + +const loadPlugin = async () => { + await import("./plugin.js") + return globalThis.__openusage_plugin +} + +function writeSecrets(ctx, apiKey) { + var obj = {} + obj[SECRETS_KEY] = apiKey || "test-api-key" + ctx.host.fs.writeText(SECRETS_FILE, JSON.stringify(obj)) +} + +function creditsResponse(monthlyCredits) { + return { + status: 200, + bodyText: JSON.stringify({ + credits: { + belowThreshold: false, + creditThreshold: 0, + monthlyCredits: monthlyCredits, + purchasedCredits: 0, + freeCredits: 0, + }, + }), + } +} + +function subsResponse(overrides) { + overrides = overrides || {} + return { + status: 200, + bodyText: JSON.stringify({ + success: true, + data: { + id: "sub_redacted", + status: "active", + userId: "test-user", + orgId: null, + createdAt: "2026-03-03T03:03:03.000Z", + priceId: "price_redacted", + metadata: { commandCode: "true" }, + quantity: 1, + cancelAtPeriodEnd: false, + currentPeriodStart: "2026-03-03T03:03:03.000Z", + currentPeriodEnd: overrides.currentPeriodEnd || "2026-04-03T03:03:03.000Z", + endedAt: null, + cancelAt: null, + canceledAt: null, + planId: overrides.planId || "individual-go", + }, + }), + } +} + +describe("command plugin", function () { + beforeEach(function () { + delete globalThis.__openusage_plugin + vi.resetModules() + }) + + // --- Auth --- + + it("throws when secrets file not found", async function () { + var ctx = makeCtx() + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("CommandCode not installed") + }) + + it("throws when secrets file has no api key", async function () { + var ctx = makeCtx() + ctx.host.fs.writeText(SECRETS_FILE, JSON.stringify({ other: "value" })) + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("CommandCode not installed") + }) + + it("throws on invalid JSON in secrets file", async function () { + var ctx = makeCtx() + ctx.host.fs.writeText(SECRETS_FILE, "{bad json") + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("CommandCode not installed") + }) + + // --- API requests --- + + it("sends GET to credits URL with Bearer auth", async function () { + var ctx = makeCtx() + writeSecrets(ctx, "my-api-key") + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) + ctx.host.http.request.mockReturnValueOnce(subsResponse()) + var plugin = await loadPlugin() + await plugin.probe(ctx) + var call = ctx.host.http.request.mock.calls[0][0] + expect(call.method).toBe("GET") + expect(call.url).toBe(CREDITS_URL) + expect(call.headers.Authorization).toBe("Bearer my-api-key") + expect(call.headers["Content-Type"]).toBe("application/json") + }) + + it("sends GET to subscriptions URL with Bearer auth", async function () { + var ctx = makeCtx() + writeSecrets(ctx, "my-api-key") + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) + ctx.host.http.request.mockReturnValueOnce(subsResponse()) + var plugin = await loadPlugin() + await plugin.probe(ctx) + var call = ctx.host.http.request.mock.calls[1][0] + expect(call.method).toBe("GET") + expect(call.url).toBe(SUBS_URL) + expect(call.headers.Authorization).toBe("Bearer my-api-key") + }) + + // --- HTTP errors: credits --- + + it("throws on credits HTTP 401", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce({ status: 401, bodyText: "" }) + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("Session expired") + }) + + it("throws on credits HTTP 403", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce({ status: 403, bodyText: "" }) + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("Session expired") + }) + + it("throws with error detail on credits non-2xx with JSON error", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce({ + status: 402, + bodyText: JSON.stringify({ error: { message: "Credits required." } }), + }) + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("Credits required.") + }) + + it("throws on credits HTTP 500", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce({ status: 500, bodyText: "" }) + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("Request failed (HTTP 500)") + }) + + it("throws on credits network error", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockImplementationOnce(function () { throw new Error("ECONNREFUSED") }) + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("Request failed. Check your connection.") + }) + + // --- Response structure errors: credits --- + + it("throws when credits response has no credits field", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce({ status: 200, bodyText: JSON.stringify({}) }) + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("Could not parse usage data") + }) + + it("throws when monthlyCredits is not a number", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce({ + status: 200, + bodyText: JSON.stringify({ credits: { monthlyCredits: "not-a-number" } }), + }) + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("Could not parse usage data") + }) + + // --- HTTP errors: subscriptions --- + + it("throws on subscriptions HTTP 401", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) + ctx.host.http.request.mockReturnValueOnce({ status: 401, bodyText: "" }) + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("Session expired") + }) + + it("throws on subscriptions HTTP 500", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) + ctx.host.http.request.mockReturnValueOnce({ status: 500, bodyText: "" }) + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("Request failed (HTTP 500)") + }) + + it("throws on subscriptions network error", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) + ctx.host.http.request.mockImplementationOnce(function () { throw new Error("ECONNREFUSED") }) + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("Request failed. Check your connection.") + }) + + it("throws when subscriptions response is missing success", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) + ctx.host.http.request.mockReturnValueOnce({ status: 200, bodyText: JSON.stringify({ data: {} }) }) + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("Could not parse subscription data") + }) + + it("throws when subscriptions response is missing data", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) + ctx.host.http.request.mockReturnValueOnce({ status: 200, bodyText: JSON.stringify({ success: true }) }) + var plugin = await loadPlugin() + await expect(plugin.probe(ctx)).rejects.toThrow("Could not parse subscription data") + }) + + // --- Progress line --- + + it("returns plan and progress line", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + // individual-go: total=10, remaining=3 → used=7 + ctx.host.http.request.mockReturnValueOnce(creditsResponse(3)) + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })) + var plugin = await loadPlugin() + var result = await plugin.probe(ctx) + expect(result.plan).toBe("individual-go") + expect(result.lines.length).toBe(2) + var line = result.lines[0] + expect(line.label).toBe("Go") + expect(line.used).toBe(7) + expect(line.limit).toBe(10) + expect(line.format.kind).toBe("dollars") + var pctLine = result.lines[1] + expect(pctLine.label).toBe("Monthly Quota") + expect(pctLine.used).toBe(70) + expect(pctLine.limit).toBe(100) + expect(pctLine.format.kind).toBe("percent") + }) + + it("returns resetsAt and periodDurationMs", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(3)) + ctx.host.http.request.mockReturnValueOnce(subsResponse()) + var plugin = await loadPlugin() + var result = await plugin.probe(ctx) + var line = result.lines[0] + expect(line.resetsAt).toBeTruthy() + expect(line.periodDurationMs).toBe(30 * 24 * 3600 * 1000) + }) + + it("clamaps used to 0 when remaining exceeds total", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(15)) + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })) + var plugin = await loadPlugin() + var result = await plugin.probe(ctx) + expect(result.lines[0].used).toBe(0) + expect(result.lines[1].used).toBe(0) + }) + + it("returns percent line with correct calculation", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + // individual-pro: total=30, remaining=12 → used=18 → 60% + ctx.host.http.request.mockReturnValueOnce(creditsResponse(12)) + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-pro" })) + var plugin = await loadPlugin() + var result = await plugin.probe(ctx) + expect(result.lines.length).toBe(2) + var pctLine = result.lines[1] + expect(pctLine.label).toBe("Monthly Quota") + expect(pctLine.used).toBe(60) + expect(pctLine.limit).toBe(100) + expect(pctLine.format.kind).toBe("percent") + }) + + it("clamaps percent line to 100 when used exceeds total", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + // individual-go: total=10, remaining=-5 → used=15 → 150% → clamps to 100 + ctx.host.http.request.mockReturnValueOnce(creditsResponse(-5)) + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })) + var plugin = await loadPlugin() + var result = await plugin.probe(ctx) + expect(result.lines[1].used).toBe(100) + expect(result.lines[1].limit).toBe(100) + }) + + // --- Plan labels --- + + it("displays Go for individual-go", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })) + var plugin = await loadPlugin() + var result = await plugin.probe(ctx) + expect(result.lines[0].label).toBe("Go") + }) + + it("displays Pro for individual-pro", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(15)) + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-pro" })) + var plugin = await loadPlugin() + var result = await plugin.probe(ctx) + expect(result.lines[0].label).toBe("Pro") + expect(result.lines[0].used).toBe(15) + expect(result.lines[0].limit).toBe(30) + }) + + it("displays Max for individual-max", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(50)) + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-max" })) + var plugin = await loadPlugin() + var result = await plugin.probe(ctx) + expect(result.lines[0].label).toBe("Max") + expect(result.lines[0].used).toBe(100) + expect(result.lines[0].limit).toBe(150) + }) + + it("displays Ultra for individual-ultra", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(200)) + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-ultra" })) + var plugin = await loadPlugin() + var result = await plugin.probe(ctx) + expect(result.lines[0].label).toBe("Ultra") + expect(result.lines[0].used).toBe(100) + expect(result.lines[0].limit).toBe(300) + }) + + it("displays Teams Pro for teams-pro", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(10)) + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "teams-pro" })) + var plugin = await loadPlugin() + var result = await plugin.probe(ctx) + expect(result.lines[0].label).toBe("Teams Pro") + expect(result.lines[0].used).toBe(30) + expect(result.lines[0].limit).toBe(40) + }) + + it("falls back to capitalized planId for unknown plans", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(0)) + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "enterprise-custom" })) + var plugin = await loadPlugin() + var result = await plugin.probe(ctx) + // unknown plan has no limit → no progress line + expect(result.lines.length).toBe(0) + }) + + it("returns plan from subscriptions data", async function () { + var ctx = makeCtx() + writeSecrets(ctx) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-pro" })) + var plugin = await loadPlugin() + var result = await plugin.probe(ctx) + expect(result.plan).toBe("individual-pro") + }) +}) From 68d104904f8ee27ef7ae9ce1d1494d3587dde8da Mon Sep 17 00:00:00 2001 From: Mike Kold Hermann Date: Tue, 5 May 2026 17:56:54 +0200 Subject: [PATCH 02/10] feat: add more progress labels to command-code plugin --- plugins/command-code/plugin.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/command-code/plugin.json b/plugins/command-code/plugin.json index 4bc0e0d6..c78d539f 100644 --- a/plugins/command-code/plugin.json +++ b/plugins/command-code/plugin.json @@ -12,6 +12,10 @@ ], "lines": [ { "type": "progress", "label": "Monthly Quota", "scope": "overview", "primaryOrder": 1 }, - { "type": "progress", "label": "Plan", "scope": "overview" } + { "type": "progress", "label": "Go", "scope": "overview" }, + { "type": "progress", "label": "Pro", "scope": "overview" }, + { "type": "progress", "label": "Max", "scope": "overview" }, + { "type": "progress", "label": "Ultra", "scope": "overview" }, + { "type": "progress", "label": "Teams Pro", "scope": "overview" } ] } From 4ec0ea9ca5ee74d1a8ee74b1b29dc4e099aea6a1 Mon Sep 17 00:00:00 2001 From: Mike Kold Hermann Date: Tue, 5 May 2026 17:58:14 +0200 Subject: [PATCH 03/10] feat: add plan label formatting to command-code plugin --- plugins/command-code/plugin.js | 158 ++++++++++++++++++--------------- 1 file changed, 85 insertions(+), 73 deletions(-) diff --git a/plugins/command-code/plugin.js b/plugins/command-code/plugin.js index 8463ad8b..6c008ad0 100644 --- a/plugins/command-code/plugin.js +++ b/plugins/command-code/plugin.js @@ -1,8 +1,8 @@ (function () { - var SECRETS_FILE = "~/.commandcode/auth.json" - var SECRETS_KEY = "apiKey" - var CREDITS_URL = "https://api.commandcode.ai/alpha/billing/credits" - var SUBS_URL = "https://api.commandcode.ai/alpha/billing/subscriptions" + var SECRETS_FILE = "~/.commandcode/auth.json"; + var SECRETS_KEY = "apiKey"; + var CREDITS_URL = "https://api.commandcode.ai/alpha/billing/credits"; + var SUBS_URL = "https://api.commandcode.ai/alpha/billing/subscriptions"; var PLAN_LIMITS = { "individual-go": 10, @@ -10,7 +10,7 @@ "individual-max": 150, "individual-ultra": 300, "teams-pro": 40, - } + }; var PLAN_LABELS = { "individual-go": "Go", @@ -18,21 +18,21 @@ "individual-max": "Max", "individual-ultra": "Ultra", "teams-pro": "Teams Pro", - } + }; function loadApiKey(ctx) { - if (!ctx.host.fs.exists(SECRETS_FILE)) return null + if (!ctx.host.fs.exists(SECRETS_FILE)) return null; try { - var text = ctx.host.fs.readText(SECRETS_FILE) - var parsed = ctx.util.tryParseJson(text) + var text = ctx.host.fs.readText(SECRETS_FILE); + var parsed = ctx.util.tryParseJson(text); if (parsed && parsed[SECRETS_KEY]) { - ctx.host.log.info("api key loaded from secrets file") - return parsed[SECRETS_KEY] + ctx.host.log.info("api key loaded from secrets file"); + return parsed[SECRETS_KEY]; } } catch (e) { - ctx.host.log.warn("secrets file read failed: " + String(e)) + ctx.host.log.warn("secrets file read failed: " + String(e)); } - return null + return null; } function fetchCredits(ctx, apiKey) { @@ -40,11 +40,11 @@ method: "GET", url: CREDITS_URL, headers: { - "Authorization": "Bearer " + apiKey, + Authorization: "Bearer " + apiKey, "Content-Type": "application/json", }, timeoutMs: 15000, - }) + }); } function fetchSubscriptions(ctx, apiKey) { @@ -52,111 +52,123 @@ method: "GET", url: SUBS_URL, headers: { - "Authorization": "Bearer " + apiKey, + Authorization: "Bearer " + apiKey, "Content-Type": "application/json", }, timeoutMs: 15000, - }) + }); } function formatPlanLabel(planId) { - return PLAN_LABELS[planId] || planId.split("-").map(function (w) { return w.charAt(0).toUpperCase() + w.slice(1) }).join(" ") + return ( + PLAN_LABELS[planId] || + planId + .split("-") + .map(function (w) { + return w.charAt(0).toUpperCase() + w.slice(1); + }) + .join(" ") + ); } async function probe(ctx) { - var apiKey = loadApiKey(ctx) + var apiKey = loadApiKey(ctx); if (!apiKey) { - throw "CommandCode not installed. Install CommandCode to get started." + throw "CommandCode not installed. Install CommandCode to get started."; } - var result + var result; try { - result = fetchCredits(ctx, apiKey) + result = fetchCredits(ctx, apiKey); } catch (e) { - ctx.host.log.error("credits request failed: " + String(e)) - throw "Request failed. Check your connection." + ctx.host.log.error("credits request failed: " + String(e)); + throw "Request failed. Check your connection."; } - var resp = result.resp - var json = result.json + var resp = result.resp; + var json = result.json; if (resp.status === 401 || resp.status === 403) { - throw "Session expired. Re-authenticate in CommandCode." + throw "Session expired. Re-authenticate in CommandCode."; } if (resp.status < 200 || resp.status >= 300) { - var detail = json && json.error && json.error.message ? json.error.message : "" + var detail = json && json.error && json.error.message ? json.error.message : ""; if (detail) { - ctx.host.log.error("api returned " + resp.status + ": " + detail) - throw detail + ctx.host.log.error("api returned " + resp.status + ": " + detail); + throw detail; } - ctx.host.log.error("api returned: " + resp.status) - throw "Request failed (HTTP " + resp.status + "). Try again later." + ctx.host.log.error("api returned: " + resp.status); + throw "Request failed (HTTP " + resp.status + "). Try again later."; } if (!json || !json.credits || typeof json.credits.monthlyCredits !== "number") { - ctx.host.log.error("unexpected credits response structure") - throw "Could not parse usage data." + ctx.host.log.error("unexpected credits response structure"); + throw "Could not parse usage data."; } - var remaining = json.credits.monthlyCredits + var remaining = json.credits.monthlyCredits; - var subResult + var subResult; try { - subResult = await fetchSubscriptions(ctx, apiKey) + subResult = await fetchSubscriptions(ctx, apiKey); } catch (e) { - ctx.host.log.error("subscription request failed: " + String(e)) - throw "Request failed. Check your connection." + ctx.host.log.error("subscription request failed: " + String(e)); + throw "Request failed. Check your connection."; } - var subResp = subResult.resp - var subJson = subResult.json + var subResp = subResult.resp; + var subJson = subResult.json; if (subResp.status === 401 || subResp.status === 403) { - throw "Session expired. Re-authenticate in CommandCode." + throw "Session expired. Re-authenticate in CommandCode."; } if (subResp.status < 200 || subResp.status >= 300) { - var detail = subJson && subJson.error && subJson.error.message ? subJson.error.message : "" + var detail = subJson && subJson.error && subJson.error.message ? subJson.error.message : ""; if (detail) { - ctx.host.log.error("api returned " + subResp.status + ": " + detail) - throw detail + ctx.host.log.error("api returned " + subResp.status + ": " + detail); + throw detail; } - ctx.host.log.error("api returned: " + subResp.status) - throw "Request failed (HTTP " + subResp.status + "). Try again later." + ctx.host.log.error("api returned: " + subResp.status); + throw "Request failed (HTTP " + subResp.status + "). Try again later."; } if (!subJson || !subJson.success || !subJson.data) { - ctx.host.log.error("unexpected subscription response structure") - throw "Could not parse subscription data." + ctx.host.log.error("unexpected subscription response structure"); + throw "Could not parse subscription data."; } - var planId = subJson.data.planId - var total = PLAN_LIMITS[planId] || 0 - var used = Math.max(0, total - remaining) + var planId = subJson.data.planId; + var total = PLAN_LIMITS[planId] || 0; + var used = Math.max(0, total - remaining); - var resetsAtMs = new Date(subJson.data.currentPeriodEnd).getTime() + var resetsAtMs = new Date(subJson.data.currentPeriodEnd).getTime(); - var lines = [] + var lines = []; if (planId && total > 0) { - lines.push(ctx.line.progress({ - label: "Monthly Quota", - used: Math.min(100, Math.max(0, Math.round((used / total) * 100))), - limit: 100, - format: { kind: "percent" }, - resetsAt: ctx.util.toIso(resetsAtMs), - periodDurationMs: 30 * 24 * 3600 * 1000, - })) - lines.push(ctx.line.progress({ - label: formatPlanLabel(planId), - used: used, - limit: total, - format: { kind: "dollars" }, - resetsAt: ctx.util.toIso(resetsAtMs), - periodDurationMs: 30 * 24 * 3600 * 1000, - })) + lines.push( + ctx.line.progress({ + label: "Monthly Quota", + used: Math.min(100, Math.max(0, Math.round((used / total) * 100))), + limit: 100, + format: { kind: "percent" }, + resetsAt: ctx.util.toIso(resetsAtMs), + periodDurationMs: 30 * 24 * 3600 * 1000, + }), + ); + lines.push( + ctx.line.progress({ + label: formatPlanLabel(planId), + used: used, + limit: total, + format: { kind: "dollars" }, + resetsAt: ctx.util.toIso(resetsAtMs), + periodDurationMs: 30 * 24 * 3600 * 1000, + }), + ); } - return { plan: planId, lines: lines } + return { plan: planId ? formatPlanLabel(planId) : planId, lines: lines }; } - globalThis.__openusage_plugin = { id: "command-code", probe: probe } -})() + globalThis.__openusage_plugin = { id: "command-code", probe: probe }; +})(); From a2728822061f9a21dc3b52c4d61011a7c3b7fbb8 Mon Sep 17 00:00:00 2001 From: Mike Kold Hermann Date: Tue, 5 May 2026 17:59:20 +0200 Subject: [PATCH 04/10] feat: update test constants to use semicolons --- plugins/command-code/plugin.test.js | 524 ++++++++++++++-------------- 1 file changed, 267 insertions(+), 257 deletions(-) diff --git a/plugins/command-code/plugin.test.js b/plugins/command-code/plugin.test.js index 2a4933bd..af2856b3 100644 --- a/plugins/command-code/plugin.test.js +++ b/plugins/command-code/plugin.test.js @@ -1,20 +1,20 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" -import { makeCtx } from "../test-helpers.js" +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeCtx } from "../test-helpers.js"; -var SECRETS_FILE = "~/.commandcode/auth.json" -var SECRETS_KEY = "apiKey" -var CREDITS_URL = "https://api.commandcode.ai/alpha/billing/credits" -var SUBS_URL = "https://api.commandcode.ai/alpha/billing/subscriptions" +var SECRETS_FILE = "~/.commandcode/auth.json"; +var SECRETS_KEY = "apiKey"; +var CREDITS_URL = "https://api.commandcode.ai/alpha/billing/credits"; +var SUBS_URL = "https://api.commandcode.ai/alpha/billing/subscriptions"; const loadPlugin = async () => { - await import("./plugin.js") - return globalThis.__openusage_plugin -} + await import("./plugin.js"); + return globalThis.__openusage_plugin; +}; function writeSecrets(ctx, apiKey) { - var obj = {} - obj[SECRETS_KEY] = apiKey || "test-api-key" - ctx.host.fs.writeText(SECRETS_FILE, JSON.stringify(obj)) + var obj = {}; + obj[SECRETS_KEY] = apiKey || "test-api-key"; + ctx.host.fs.writeText(SECRETS_FILE, JSON.stringify(obj)); } function creditsResponse(monthlyCredits) { @@ -29,11 +29,11 @@ function creditsResponse(monthlyCredits) { freeCredits: 0, }, }), - } + }; } function subsResponse(overrides) { - overrides = overrides || {} + overrides = overrides || {}; return { status: 200, bodyText: JSON.stringify({ @@ -56,332 +56,342 @@ function subsResponse(overrides) { planId: overrides.planId || "individual-go", }, }), - } + }; } describe("command plugin", function () { beforeEach(function () { - delete globalThis.__openusage_plugin - vi.resetModules() - }) + delete globalThis.__openusage_plugin; + vi.resetModules(); + }); // --- Auth --- it("throws when secrets file not found", async function () { - var ctx = makeCtx() - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("CommandCode not installed") - }) + var ctx = makeCtx(); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("CommandCode not installed"); + }); it("throws when secrets file has no api key", async function () { - var ctx = makeCtx() - ctx.host.fs.writeText(SECRETS_FILE, JSON.stringify({ other: "value" })) - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("CommandCode not installed") - }) + var ctx = makeCtx(); + ctx.host.fs.writeText(SECRETS_FILE, JSON.stringify({ other: "value" })); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("CommandCode not installed"); + }); it("throws on invalid JSON in secrets file", async function () { - var ctx = makeCtx() - ctx.host.fs.writeText(SECRETS_FILE, "{bad json") - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("CommandCode not installed") - }) + var ctx = makeCtx(); + ctx.host.fs.writeText(SECRETS_FILE, "{bad json"); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("CommandCode not installed"); + }); // --- API requests --- it("sends GET to credits URL with Bearer auth", async function () { - var ctx = makeCtx() - writeSecrets(ctx, "my-api-key") - ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) - ctx.host.http.request.mockReturnValueOnce(subsResponse()) - var plugin = await loadPlugin() - await plugin.probe(ctx) - var call = ctx.host.http.request.mock.calls[0][0] - expect(call.method).toBe("GET") - expect(call.url).toBe(CREDITS_URL) - expect(call.headers.Authorization).toBe("Bearer my-api-key") - expect(call.headers["Content-Type"]).toBe("application/json") - }) + var ctx = makeCtx(); + writeSecrets(ctx, "my-api-key"); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)); + ctx.host.http.request.mockReturnValueOnce(subsResponse()); + var plugin = await loadPlugin(); + await plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.method).toBe("GET"); + expect(call.url).toBe(CREDITS_URL); + expect(call.headers.Authorization).toBe("Bearer my-api-key"); + expect(call.headers["Content-Type"]).toBe("application/json"); + }); it("sends GET to subscriptions URL with Bearer auth", async function () { - var ctx = makeCtx() - writeSecrets(ctx, "my-api-key") - ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) - ctx.host.http.request.mockReturnValueOnce(subsResponse()) - var plugin = await loadPlugin() - await plugin.probe(ctx) - var call = ctx.host.http.request.mock.calls[1][0] - expect(call.method).toBe("GET") - expect(call.url).toBe(SUBS_URL) - expect(call.headers.Authorization).toBe("Bearer my-api-key") - }) + var ctx = makeCtx(); + writeSecrets(ctx, "my-api-key"); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)); + ctx.host.http.request.mockReturnValueOnce(subsResponse()); + var plugin = await loadPlugin(); + await plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[1][0]; + expect(call.method).toBe("GET"); + expect(call.url).toBe(SUBS_URL); + expect(call.headers.Authorization).toBe("Bearer my-api-key"); + }); // --- HTTP errors: credits --- it("throws on credits HTTP 401", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce({ status: 401, bodyText: "" }) - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("Session expired") - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce({ status: 401, bodyText: "" }); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("Session expired"); + }); it("throws on credits HTTP 403", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce({ status: 403, bodyText: "" }) - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("Session expired") - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce({ status: 403, bodyText: "" }); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("Session expired"); + }); it("throws with error detail on credits non-2xx with JSON error", async function () { - var ctx = makeCtx() - writeSecrets(ctx) + var ctx = makeCtx(); + writeSecrets(ctx); ctx.host.http.request.mockReturnValueOnce({ status: 402, bodyText: JSON.stringify({ error: { message: "Credits required." } }), - }) - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("Credits required.") - }) + }); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("Credits required."); + }); it("throws on credits HTTP 500", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce({ status: 500, bodyText: "" }) - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("Request failed (HTTP 500)") - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce({ status: 500, bodyText: "" }); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("Request failed (HTTP 500)"); + }); it("throws on credits network error", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockImplementationOnce(function () { throw new Error("ECONNREFUSED") }) - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("Request failed. Check your connection.") - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockImplementationOnce(function () { + throw new Error("ECONNREFUSED"); + }); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("Request failed. Check your connection."); + }); // --- Response structure errors: credits --- it("throws when credits response has no credits field", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce({ status: 200, bodyText: JSON.stringify({}) }) - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("Could not parse usage data") - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce({ status: 200, bodyText: JSON.stringify({}) }); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("Could not parse usage data"); + }); it("throws when monthlyCredits is not a number", async function () { - var ctx = makeCtx() - writeSecrets(ctx) + var ctx = makeCtx(); + writeSecrets(ctx); ctx.host.http.request.mockReturnValueOnce({ status: 200, bodyText: JSON.stringify({ credits: { monthlyCredits: "not-a-number" } }), - }) - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("Could not parse usage data") - }) + }); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("Could not parse usage data"); + }); // --- HTTP errors: subscriptions --- it("throws on subscriptions HTTP 401", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) - ctx.host.http.request.mockReturnValueOnce({ status: 401, bodyText: "" }) - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("Session expired") - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)); + ctx.host.http.request.mockReturnValueOnce({ status: 401, bodyText: "" }); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("Session expired"); + }); it("throws on subscriptions HTTP 500", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) - ctx.host.http.request.mockReturnValueOnce({ status: 500, bodyText: "" }) - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("Request failed (HTTP 500)") - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)); + ctx.host.http.request.mockReturnValueOnce({ status: 500, bodyText: "" }); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("Request failed (HTTP 500)"); + }); it("throws on subscriptions network error", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) - ctx.host.http.request.mockImplementationOnce(function () { throw new Error("ECONNREFUSED") }) - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("Request failed. Check your connection.") - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)); + ctx.host.http.request.mockImplementationOnce(function () { + throw new Error("ECONNREFUSED"); + }); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("Request failed. Check your connection."); + }); it("throws when subscriptions response is missing success", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) - ctx.host.http.request.mockReturnValueOnce({ status: 200, bodyText: JSON.stringify({ data: {} }) }) - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("Could not parse subscription data") - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)); + ctx.host.http.request.mockReturnValueOnce({ + status: 200, + bodyText: JSON.stringify({ data: {} }), + }); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("Could not parse subscription data"); + }); it("throws when subscriptions response is missing data", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) - ctx.host.http.request.mockReturnValueOnce({ status: 200, bodyText: JSON.stringify({ success: true }) }) - var plugin = await loadPlugin() - await expect(plugin.probe(ctx)).rejects.toThrow("Could not parse subscription data") - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)); + ctx.host.http.request.mockReturnValueOnce({ + status: 200, + bodyText: JSON.stringify({ success: true }), + }); + var plugin = await loadPlugin(); + await expect(plugin.probe(ctx)).rejects.toThrow("Could not parse subscription data"); + }); // --- Progress line --- it("returns plan and progress line", async function () { - var ctx = makeCtx() - writeSecrets(ctx) + var ctx = makeCtx(); + writeSecrets(ctx); // individual-go: total=10, remaining=3 → used=7 - ctx.host.http.request.mockReturnValueOnce(creditsResponse(3)) - ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })) - var plugin = await loadPlugin() - var result = await plugin.probe(ctx) - expect(result.plan).toBe("individual-go") - expect(result.lines.length).toBe(2) - var line = result.lines[0] - expect(line.label).toBe("Go") - expect(line.used).toBe(7) - expect(line.limit).toBe(10) - expect(line.format.kind).toBe("dollars") - var pctLine = result.lines[1] - expect(pctLine.label).toBe("Monthly Quota") - expect(pctLine.used).toBe(70) - expect(pctLine.limit).toBe(100) - expect(pctLine.format.kind).toBe("percent") - }) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(3)); + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })); + var plugin = await loadPlugin(); + var result = await plugin.probe(ctx); + expect(result.plan).toBe("individual-go"); + expect(result.lines.length).toBe(2); + var line = result.lines[0]; + expect(line.label).toBe("Go"); + expect(line.used).toBe(7); + expect(line.limit).toBe(10); + expect(line.format.kind).toBe("dollars"); + var pctLine = result.lines[1]; + expect(pctLine.label).toBe("Monthly Quota"); + expect(pctLine.used).toBe(70); + expect(pctLine.limit).toBe(100); + expect(pctLine.format.kind).toBe("percent"); + }); it("returns resetsAt and periodDurationMs", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce(creditsResponse(3)) - ctx.host.http.request.mockReturnValueOnce(subsResponse()) - var plugin = await loadPlugin() - var result = await plugin.probe(ctx) - var line = result.lines[0] - expect(line.resetsAt).toBeTruthy() - expect(line.periodDurationMs).toBe(30 * 24 * 3600 * 1000) - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(3)); + ctx.host.http.request.mockReturnValueOnce(subsResponse()); + var plugin = await loadPlugin(); + var result = await plugin.probe(ctx); + var line = result.lines[0]; + expect(line.resetsAt).toBeTruthy(); + expect(line.periodDurationMs).toBe(31 * 24 * 3600 * 1000); + }); it("clamaps used to 0 when remaining exceeds total", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce(creditsResponse(15)) - ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })) - var plugin = await loadPlugin() - var result = await plugin.probe(ctx) - expect(result.lines[0].used).toBe(0) - expect(result.lines[1].used).toBe(0) - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(15)); + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })); + var plugin = await loadPlugin(); + var result = await plugin.probe(ctx); + expect(result.lines[0].used).toBe(0); + expect(result.lines[1].used).toBe(0); + }); it("returns percent line with correct calculation", async function () { - var ctx = makeCtx() - writeSecrets(ctx) + var ctx = makeCtx(); + writeSecrets(ctx); // individual-pro: total=30, remaining=12 → used=18 → 60% - ctx.host.http.request.mockReturnValueOnce(creditsResponse(12)) - ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-pro" })) - var plugin = await loadPlugin() - var result = await plugin.probe(ctx) - expect(result.lines.length).toBe(2) - var pctLine = result.lines[1] - expect(pctLine.label).toBe("Monthly Quota") - expect(pctLine.used).toBe(60) - expect(pctLine.limit).toBe(100) - expect(pctLine.format.kind).toBe("percent") - }) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(12)); + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-pro" })); + var plugin = await loadPlugin(); + var result = await plugin.probe(ctx); + expect(result.lines.length).toBe(2); + var pctLine = result.lines[1]; + expect(pctLine.label).toBe("Monthly Quota"); + expect(pctLine.used).toBe(60); + expect(pctLine.limit).toBe(100); + expect(pctLine.format.kind).toBe("percent"); + }); it("clamaps percent line to 100 when used exceeds total", async function () { - var ctx = makeCtx() - writeSecrets(ctx) + var ctx = makeCtx(); + writeSecrets(ctx); // individual-go: total=10, remaining=-5 → used=15 → 150% → clamps to 100 - ctx.host.http.request.mockReturnValueOnce(creditsResponse(-5)) - ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })) - var plugin = await loadPlugin() - var result = await plugin.probe(ctx) - expect(result.lines[1].used).toBe(100) - expect(result.lines[1].limit).toBe(100) - }) + ctx.host.http.request.mockReturnValueOnce(creditsResponse(-5)); + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })); + var plugin = await loadPlugin(); + var result = await plugin.probe(ctx); + expect(result.lines[1].used).toBe(100); + expect(result.lines[1].limit).toBe(100); + }); // --- Plan labels --- it("displays Go for individual-go", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) - ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })) - var plugin = await loadPlugin() - var result = await plugin.probe(ctx) - expect(result.lines[0].label).toBe("Go") - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)); + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })); + var plugin = await loadPlugin(); + var result = await plugin.probe(ctx); + expect(result.lines[0].label).toBe("Go"); + }); it("displays Pro for individual-pro", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce(creditsResponse(15)) - ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-pro" })) - var plugin = await loadPlugin() - var result = await plugin.probe(ctx) - expect(result.lines[0].label).toBe("Pro") - expect(result.lines[0].used).toBe(15) - expect(result.lines[0].limit).toBe(30) - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(15)); + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-pro" })); + var plugin = await loadPlugin(); + var result = await plugin.probe(ctx); + expect(result.lines[0].label).toBe("Pro"); + expect(result.lines[0].used).toBe(15); + expect(result.lines[0].limit).toBe(30); + }); it("displays Max for individual-max", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce(creditsResponse(50)) - ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-max" })) - var plugin = await loadPlugin() - var result = await plugin.probe(ctx) - expect(result.lines[0].label).toBe("Max") - expect(result.lines[0].used).toBe(100) - expect(result.lines[0].limit).toBe(150) - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(50)); + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-max" })); + var plugin = await loadPlugin(); + var result = await plugin.probe(ctx); + expect(result.lines[0].label).toBe("Max"); + expect(result.lines[0].used).toBe(100); + expect(result.lines[0].limit).toBe(150); + }); it("displays Ultra for individual-ultra", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce(creditsResponse(200)) - ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-ultra" })) - var plugin = await loadPlugin() - var result = await plugin.probe(ctx) - expect(result.lines[0].label).toBe("Ultra") - expect(result.lines[0].used).toBe(100) - expect(result.lines[0].limit).toBe(300) - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(200)); + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-ultra" })); + var plugin = await loadPlugin(); + var result = await plugin.probe(ctx); + expect(result.lines[0].label).toBe("Ultra"); + expect(result.lines[0].used).toBe(100); + expect(result.lines[0].limit).toBe(300); + }); it("displays Teams Pro for teams-pro", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce(creditsResponse(10)) - ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "teams-pro" })) - var plugin = await loadPlugin() - var result = await plugin.probe(ctx) - expect(result.lines[0].label).toBe("Teams Pro") - expect(result.lines[0].used).toBe(30) - expect(result.lines[0].limit).toBe(40) - }) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(10)); + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "teams-pro" })); + var plugin = await loadPlugin(); + var result = await plugin.probe(ctx); + expect(result.lines[0].label).toBe("Teams Pro"); + expect(result.lines[0].used).toBe(30); + expect(result.lines[0].limit).toBe(40); + }); it("falls back to capitalized planId for unknown plans", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce(creditsResponse(0)) - ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "enterprise-custom" })) - var plugin = await loadPlugin() - var result = await plugin.probe(ctx) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(0)); + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "enterprise-custom" })); + var plugin = await loadPlugin(); + var result = await plugin.probe(ctx); // unknown plan has no limit → no progress line - expect(result.lines.length).toBe(0) - }) + expect(result.lines.length).toBe(0); + }); it("returns plan from subscriptions data", async function () { - var ctx = makeCtx() - writeSecrets(ctx) - ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)) - ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-pro" })) - var plugin = await loadPlugin() - var result = await plugin.probe(ctx) - expect(result.plan).toBe("individual-pro") - }) -}) + var ctx = makeCtx(); + writeSecrets(ctx); + ctx.host.http.request.mockReturnValueOnce(creditsResponse(5)); + ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-pro" })); + var plugin = await loadPlugin(); + var result = await plugin.probe(ctx); + expect(result.plan).toBe("individual-pro"); + }); +}); From 205f5c0704161d71918d878c75d0f38995ad9adc Mon Sep 17 00:00:00 2001 From: Mike Kold Hermann Date: Tue, 5 May 2026 18:04:19 +0200 Subject: [PATCH 05/10] fix: swap dollar and percent labels in command plugin test --- plugins/command-code/plugin.test.js | 54 ++++++++++++++--------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/plugins/command-code/plugin.test.js b/plugins/command-code/plugin.test.js index af2856b3..6fd6f420 100644 --- a/plugins/command-code/plugin.test.js +++ b/plugins/command-code/plugin.test.js @@ -249,18 +249,18 @@ describe("command plugin", function () { ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })); var plugin = await loadPlugin(); var result = await plugin.probe(ctx); - expect(result.plan).toBe("individual-go"); + expect(result.plan).toBe("Go"); expect(result.lines.length).toBe(2); var line = result.lines[0]; - expect(line.label).toBe("Go"); - expect(line.used).toBe(7); - expect(line.limit).toBe(10); - expect(line.format.kind).toBe("dollars"); + expect(line.label).toBe("Monthly Quota"); + expect(line.used).toBe(70); + expect(line.limit).toBe(100); + expect(line.format.kind).toBe("percent"); var pctLine = result.lines[1]; - expect(pctLine.label).toBe("Monthly Quota"); - expect(pctLine.used).toBe(70); - expect(pctLine.limit).toBe(100); - expect(pctLine.format.kind).toBe("percent"); + expect(pctLine.label).toBe("Go"); + expect(pctLine.used).toBe(7); + expect(pctLine.limit).toBe(10); + expect(pctLine.format.kind).toBe("dollars"); }); it("returns resetsAt and periodDurationMs", async function () { @@ -272,7 +272,7 @@ describe("command plugin", function () { var result = await plugin.probe(ctx); var line = result.lines[0]; expect(line.resetsAt).toBeTruthy(); - expect(line.periodDurationMs).toBe(31 * 24 * 3600 * 1000); + expect(line.periodDurationMs).toBe(30 * 24 * 3600 * 1000); }); it("clamaps used to 0 when remaining exceeds total", async function () { @@ -295,7 +295,7 @@ describe("command plugin", function () { var plugin = await loadPlugin(); var result = await plugin.probe(ctx); expect(result.lines.length).toBe(2); - var pctLine = result.lines[1]; + var pctLine = result.lines[0]; expect(pctLine.label).toBe("Monthly Quota"); expect(pctLine.used).toBe(60); expect(pctLine.limit).toBe(100); @@ -310,8 +310,8 @@ describe("command plugin", function () { ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })); var plugin = await loadPlugin(); var result = await plugin.probe(ctx); - expect(result.lines[1].used).toBe(100); - expect(result.lines[1].limit).toBe(100); + expect(result.lines[0].used).toBe(100); + expect(result.lines[0].limit).toBe(100); }); // --- Plan labels --- @@ -323,7 +323,7 @@ describe("command plugin", function () { ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-go" })); var plugin = await loadPlugin(); var result = await plugin.probe(ctx); - expect(result.lines[0].label).toBe("Go"); + expect(result.lines[1].label).toBe("Go"); }); it("displays Pro for individual-pro", async function () { @@ -333,9 +333,9 @@ describe("command plugin", function () { ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-pro" })); var plugin = await loadPlugin(); var result = await plugin.probe(ctx); - expect(result.lines[0].label).toBe("Pro"); - expect(result.lines[0].used).toBe(15); - expect(result.lines[0].limit).toBe(30); + expect(result.lines[1].label).toBe("Pro"); + expect(result.lines[1].used).toBe(15); + expect(result.lines[1].limit).toBe(30); }); it("displays Max for individual-max", async function () { @@ -345,9 +345,9 @@ describe("command plugin", function () { ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-max" })); var plugin = await loadPlugin(); var result = await plugin.probe(ctx); - expect(result.lines[0].label).toBe("Max"); - expect(result.lines[0].used).toBe(100); - expect(result.lines[0].limit).toBe(150); + expect(result.lines[1].label).toBe("Max"); + expect(result.lines[1].used).toBe(100); + expect(result.lines[1].limit).toBe(150); }); it("displays Ultra for individual-ultra", async function () { @@ -357,9 +357,9 @@ describe("command plugin", function () { ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-ultra" })); var plugin = await loadPlugin(); var result = await plugin.probe(ctx); - expect(result.lines[0].label).toBe("Ultra"); - expect(result.lines[0].used).toBe(100); - expect(result.lines[0].limit).toBe(300); + expect(result.lines[1].label).toBe("Ultra"); + expect(result.lines[1].used).toBe(100); + expect(result.lines[1].limit).toBe(300); }); it("displays Teams Pro for teams-pro", async function () { @@ -369,9 +369,9 @@ describe("command plugin", function () { ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "teams-pro" })); var plugin = await loadPlugin(); var result = await plugin.probe(ctx); - expect(result.lines[0].label).toBe("Teams Pro"); - expect(result.lines[0].used).toBe(30); - expect(result.lines[0].limit).toBe(40); + expect(result.lines[1].label).toBe("Teams Pro"); + expect(result.lines[1].used).toBe(30); + expect(result.lines[1].limit).toBe(40); }); it("falls back to capitalized planId for unknown plans", async function () { @@ -392,6 +392,6 @@ describe("command plugin", function () { ctx.host.http.request.mockReturnValueOnce(subsResponse({ planId: "individual-pro" })); var plugin = await loadPlugin(); var result = await plugin.probe(ctx); - expect(result.plan).toBe("individual-pro"); + expect(result.plan).toBe("Pro"); }); }); From eeaf6da415a8edecc14c5db6ae87421d7f7b95f4 Mon Sep 17 00:00:00 2001 From: Mike Kold Hermann Date: Tue, 5 May 2026 18:08:09 +0200 Subject: [PATCH 06/10] feat: calculate period duration dynamically --- plugins/command-code/plugin.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/command-code/plugin.js b/plugins/command-code/plugin.js index 6c008ad0..ee94d71e 100644 --- a/plugins/command-code/plugin.js +++ b/plugins/command-code/plugin.js @@ -144,6 +144,9 @@ var resetsAtMs = new Date(subJson.data.currentPeriodEnd).getTime(); var lines = []; + var periodDurationMs = + new Date(subJson.data.currentPeriodEnd).getTime() - + new Date(subJson.data.currentPeriodStart).getTime(); if (planId && total > 0) { lines.push( ctx.line.progress({ @@ -152,7 +155,7 @@ limit: 100, format: { kind: "percent" }, resetsAt: ctx.util.toIso(resetsAtMs), - periodDurationMs: 30 * 24 * 3600 * 1000, + periodDurationMs, }), ); lines.push( @@ -162,7 +165,7 @@ limit: total, format: { kind: "dollars" }, resetsAt: ctx.util.toIso(resetsAtMs), - periodDurationMs: 30 * 24 * 3600 * 1000, + periodDurationMs, }), ); } From 1f0b1181003940d068e09c9ebfb5b35df06b95eb Mon Sep 17 00:00:00 2001 From: Mike Kold Hermann Date: Tue, 5 May 2026 18:08:33 +0200 Subject: [PATCH 07/10] fix: update period duration to 31 days --- plugins/command-code/plugin.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/command-code/plugin.test.js b/plugins/command-code/plugin.test.js index 6fd6f420..3513146c 100644 --- a/plugins/command-code/plugin.test.js +++ b/plugins/command-code/plugin.test.js @@ -272,7 +272,7 @@ describe("command plugin", function () { var result = await plugin.probe(ctx); var line = result.lines[0]; expect(line.resetsAt).toBeTruthy(); - expect(line.periodDurationMs).toBe(30 * 24 * 3600 * 1000); + expect(line.periodDurationMs).toBe(31 * 24 * 3600 * 1000); }); it("clamaps used to 0 when remaining exceeds total", async function () { From fc2b2e6b39041de632127f89da70a3db78acbcb6 Mon Sep 17 00:00:00 2001 From: Mike Kold Hermann Date: Tue, 5 May 2026 18:10:29 +0200 Subject: [PATCH 08/10] docs: update subscription period and reset time details --- docs/providers/command-code.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/providers/command-code.md b/docs/providers/command-code.md index 10730e5a..9e564001 100644 --- a/docs/providers/command-code.md +++ b/docs/providers/command-code.md @@ -64,8 +64,8 @@ Bars show used credits in dollars (plan label) and as a percentage (Monthly Quot ## Window Rules -- **Period:** subscription billing period (`currentPeriodEnd` from the API) -- **Resets at:** UTC midnight, 24h period duration displayed +- **Period:** subscription billing period (`currentPeriodStart` - `currentPeriodEnd` from the API) +- **Resets at:** the `currentPeriodEnd` timestamp returned by the API Usage is account-level from the API, not estimated from local history. From e38a47cc8325a35690b7102811060f2f1ba602f6 Mon Sep 17 00:00:00 2001 From: Mike Kold Hermann Date: Tue, 5 May 2026 18:10:33 +0200 Subject: [PATCH 09/10] feat: rename plugin to "Command Code" --- plugins/command-code/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/command-code/plugin.json b/plugins/command-code/plugin.json index c78d539f..efebdf7d 100644 --- a/plugins/command-code/plugin.json +++ b/plugins/command-code/plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "command-code", - "name": "CommandCode", + "name": "Command Code", "version": "0.0.1", "entry": "plugin.js", "icon": "icon.svg", From 74d18aadc1520dc328a6ed681fd3ff9bf4961fe3 Mon Sep 17 00:00:00 2001 From: LovelessCodes Date: Thu, 7 May 2026 08:44:12 +0200 Subject: [PATCH 10/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- plugins/command-code/plugin.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/command-code/plugin.test.js b/plugins/command-code/plugin.test.js index 3513146c..e352e8c0 100644 --- a/plugins/command-code/plugin.test.js +++ b/plugins/command-code/plugin.test.js @@ -275,7 +275,7 @@ describe("command plugin", function () { expect(line.periodDurationMs).toBe(31 * 24 * 3600 * 1000); }); - it("clamaps used to 0 when remaining exceeds total", async function () { + it("clamps used to 0 when remaining exceeds total", async function () { var ctx = makeCtx(); writeSecrets(ctx); ctx.host.http.request.mockReturnValueOnce(creditsResponse(15));