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..9e564001 --- /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 (`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. + +## 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..ee94d71e --- /dev/null +++ b/plugins/command-code/plugin.js @@ -0,0 +1,177 @@ +(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 = []; + var periodDurationMs = + new Date(subJson.data.currentPeriodEnd).getTime() - + new Date(subJson.data.currentPeriodStart).getTime(); + 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, + }), + ); + lines.push( + ctx.line.progress({ + label: formatPlanLabel(planId), + used: used, + limit: total, + format: { kind: "dollars" }, + resetsAt: ctx.util.toIso(resetsAtMs), + periodDurationMs, + }), + ); + } + + return { plan: planId ? formatPlanLabel(planId) : 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..efebdf7d --- /dev/null +++ b/plugins/command-code/plugin.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "id": "command-code", + "name": "Command Code", + "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": "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" } + ] +} diff --git a/plugins/command-code/plugin.test.js b/plugins/command-code/plugin.test.js new file mode 100644 index 00000000..e352e8c0 --- /dev/null +++ b/plugins/command-code/plugin.test.js @@ -0,0 +1,397 @@ +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("Go"); + expect(result.lines.length).toBe(2); + var line = result.lines[0]; + 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("Go"); + expect(pctLine.used).toBe(7); + expect(pctLine.limit).toBe(10); + expect(pctLine.format.kind).toBe("dollars"); + }); + + 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(31 * 24 * 3600 * 1000); + }); + + it("clamps 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[0]; + 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[0].used).toBe(100); + expect(result.lines[0].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[1].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[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 () { + 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[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 () { + 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[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 () { + 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[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 () { + 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("Pro"); + }); +});