diff --git a/.changeset/fn-7816-cursor-usage.md b/.changeset/fn-7816-cursor-usage.md
new file mode 100644
index 0000000000..e8635853a1
--- /dev/null
+++ b/.changeset/fn-7816-cursor-usage.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": minor
+---
+
+summary: Show Cursor subscription usage in the Usage dropdown.
+category: feature
+dev: usage.ts adds fetchCursorUsage via Cursor Admin API POST https://api.cursor.com/teams/spend with Basic auth API_KEY:, resolving the Admin API key from documented env `CURSOR_ADMIN_API_KEY` (or `CURSOR_API_KEY` alias) before internal test/auth-storage fallbacks. It maps teamMemberSpend overallSpendCents/spendCents plus hardLimitOverrideDollars/monthlyLimitDollars and subscriptionCycleStart; fetchAllProviderUsage wraps it with withTimeout and no-auth demotion, while UsageIndicator maps "Cursor" to cursor-cli. No personal Cursor CLI usage endpoint confirmed; CLI session only supplies userEmail/subscriptionTier metadata.
diff --git a/docs/settings-reference.md b/docs/settings-reference.md
index d42b1dfae2..93f6efbc96 100644
--- a/docs/settings-reference.md
+++ b/docs/settings-reference.md
@@ -990,6 +990,10 @@ When the Hermes Runtime plugin (`fusion-plugin-hermes-runtime`) is installed and
When the Cursor Runtime plugin (`fusion-plugin-cursor-runtime`) is installed and the `useCursorCli` toggle is enabled (Settings → Authentication), Cursor CLI-discovered models (`cursor-agent models --json`, with text/`model list` fallbacks) are surfaced additively in `/api/models` under the `cursor-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `cursor-agent` on every request; a missing/failed/unavailable Cursor CLI binary simply yields zero `cursor-cli` rows without affecting other providers. Disabling `useCursorCli` hides all `cursor-cli` rows.
+
+
+The Usage dropdown can show a Cursor card when the dashboard process has a Cursor Admin API key in `CURSOR_ADMIN_API_KEY` (preferred) or `CURSOR_API_KEY` (compatibility alias). Fusion calls Cursor Admin API `POST https://api.cursor.com/teams/spend` with Basic auth (`API_KEY:`) and maps documented team spend fields into the generic usage-window UI. If only the `cursor-agent` session/OAuth login is present, Fusion omits the Cursor usage card because Cursor has not documented a personal/session usage endpoint; expired Admin API keys remain visible as an error card so operators can rotate the environment secret.
+
When the Grok Runtime plugin (`fusion-plugin-grok-runtime`) is installed and the `useGrokCli` toggle is enabled (Settings → Authentication), Grok CLI-discovered models (`grok models`) are surfaced additively in `/api/models` under the `grok-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `grok` on every request; a missing/failed/unavailable Grok CLI binary simply yields zero `grok-cli` rows without affecting other providers. Disabling `useGrokCli` hides all `grok-cli` rows. Unlike Cursor (OAuth/session auth), Grok direct-endpoint auth is API-key based, but CLI-routed execution lets the `grok` binary use any auth source it supports; the Settings card still surfaces Fusion-visible key detection only as an informational hint.
The three GPT-5.6 codenamed OpenAI Codex variants (`gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) are additively surfaced under the `openai-codex` provider (FN-7745/FN-7754/FN-7759, mirroring the Anthropic/Z.ai supplemental-merge pattern above) so they appear both in dashboard `/api/models` and the engine/pi `createFnAgent` registry-seeding surface whenever `openai-codex` is configured — deduped against any pinned pi-ai catalog row that already carries one of the ids. FN-7759 specifically keeps the supplemental registration compatible with the real pi-coding-agent `ModelRegistry` by preserving the OpenAI Codex OAuth provider during dynamic full-provider replacement, so legacy catalogs without native 5.6 rows still survive `getAvailable()` auth filtering and remain executable.
diff --git a/packages/dashboard/app/components/UsageIndicator.tsx b/packages/dashboard/app/components/UsageIndicator.tsx
index 39e1a75e7a..492789c501 100644
--- a/packages/dashboard/app/components/UsageIndicator.tsx
+++ b/packages/dashboard/app/components/UsageIndicator.tsx
@@ -442,6 +442,13 @@ function getProviderIconKey(providerName: string): string {
if (normalized.includes('xai') || normalized.includes('grok')) {
return 'xai';
}
+ /*
+ FNXC:UsageIndicator 2026-07-10-00:00:
+ Cursor usage cards are rendered by the generic ProviderCard path, so provider-name mapping is the only frontend-specific requirement: route "Cursor" to the existing cursor-cli icon token and SVG.
+ */
+ if (normalized.includes('cursor')) {
+ return 'cursor-cli';
+ }
if (normalized.includes('opencode')) {
return 'opencode';
}
diff --git a/packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx b/packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx
index 15080ecf13..93789230aa 100644
--- a/packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx
+++ b/packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx
@@ -1536,6 +1536,32 @@ describe("UsageIndicator", () => {
expect(screen.getByText("Moonshot")).toBeInTheDocument();
});
+ it("maps Cursor provider to the cursor-cli icon and renders usage windows", () => {
+ mockUseUsageData.mockReturnValue(createUsageDataState({
+ providers: [
+ {
+ name: "Cursor",
+ icon: "🟣",
+ status: "ok",
+ windows: [
+ { label: "Monthly spend", percentUsed: 25, percentLeft: 75, resetText: "resets in 15d" },
+ ],
+ },
+ ],
+ loading: false,
+ error: null,
+ lastUpdated: new Date(),
+ refresh: mockRefresh,
+ }));
+
+ render();
+
+ expect(screen.getByTestId("cursor-cli-icon")).toBeInTheDocument();
+ expect(document.querySelector('[data-provider="cursor-cli"]')).toBeInTheDocument();
+ expect(screen.getByText("Monthly spend")).toBeInTheDocument();
+ expect(screen.getByText("25% used")).toBeInTheDocument();
+ });
+
// Pace indicator tests
it("renders pace marker for weekly windows with timing data", () => {
mockUseUsageData.mockReturnValue(createUsageDataState({
diff --git a/packages/dashboard/src/__tests__/usage.test.ts b/packages/dashboard/src/__tests__/usage.test.ts
index 6faca807bb..3c2a6a1ad8 100644
--- a/packages/dashboard/src/__tests__/usage.test.ts
+++ b/packages/dashboard/src/__tests__/usage.test.ts
@@ -92,6 +92,8 @@ describe("usage", () => {
vi.stubEnv("HOME", "/home/testuser");
vi.stubEnv("CODEX_HOME", "");
vi.stubEnv("GROK_API_KEY", "");
+ vi.stubEnv("CURSOR_ADMIN_API_KEY", "");
+ vi.stubEnv("CURSOR_API_KEY", "");
});
afterEach(() => {
@@ -3253,6 +3255,161 @@ describe("usage", () => {
});
});
+ describe("fetchCursorUsage (via fetchAllProviderUsage)", () => {
+ const cursorAuthStorage = (apiKey: string) => ({
+ reload: vi.fn(),
+ hasAuth: vi.fn((provider: string) => provider === "cursor"),
+ getApiKey: vi.fn((provider: string) => provider === "cursor" ? apiKey : null),
+ });
+
+ const mockCursorAccount = (account: { email?: string; plan?: string } = {}) => {
+ mockExecFileSync.mockImplementation((cmd: string) => {
+ if (cmd === "cursor-agent") {
+ return JSON.stringify({
+ userEmail: account.email ?? "developer@company.com",
+ subscriptionTier: account.plan ?? "Pro",
+ });
+ }
+ throw new Error("File not found");
+ });
+ };
+
+ const mockCursorSpendResponse = (statusCode: number, body: unknown) => {
+ const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
+ mockRequest.mockImplementation((options: any, callback: any) => {
+ expect(options.hostname).toBe("api.cursor.com");
+ expect(options.path).toBe("/teams/spend");
+ expect(options.method).toBe("POST");
+ const responseBody = typeof body === "string" ? body : JSON.stringify(body);
+ const mockRes = {
+ statusCode,
+ headers: {},
+ on: vi.fn((event: string, handler: any) => {
+ if (event === "data") handler(Buffer.from(responseBody));
+ if (event === "end") handler();
+ }),
+ };
+ callback(mockRes);
+ return mockReq;
+ });
+ return mockReq;
+ };
+
+ it("renders Cursor usage from the Admin API spend response", async () => {
+ const cycleStart = Date.now() - 15 * 24 * 60 * 60 * 1000;
+ mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
+ mockCursorAccount({ email: "developer@company.com", plan: "Pro" });
+ const mockReq = mockCursorSpendResponse(200, {
+ subscriptionCycleStart: cycleStart,
+ teamMemberSpend: [
+ {
+ email: "developer@company.com",
+ overallSpendCents: 2450,
+ spendCents: 2450,
+ hardLimitOverrideDollars: null,
+ monthlyLimitDollars: 100,
+ },
+ ],
+ });
+
+ vi.stubEnv("CURSOR_ADMIN_API_KEY", "cursor-admin-env-key");
+
+ const providers = await fetchAllProviderUsage();
+ const cursor = providers.find((p) => p.name === "Cursor")!;
+
+ expect(cursor.status).toBe("ok");
+ expect(cursor.plan).toBe("Pro");
+ expect(cursor.email).toBe("developer@company.com");
+ expect(cursor.windows).toHaveLength(1);
+ expect(cursor.windows[0].label).toBe("Monthly spend");
+ expect(cursor.windows[0].percentUsed).toBeCloseTo(24.5, 1);
+ expect(cursor.windows[0].percentLeft).toBeCloseTo(75.5, 1);
+ expect(cursor.windows[0].resetText).toContain("resets in");
+ expect(cursor.windows[0].resetAt).toBeDefined();
+ expect(cursor.windows[0].pace).toBeDefined();
+ expect(mockReq.write).toHaveBeenCalledWith(expect.stringContaining("developer@company.com"));
+ expect(mockRequest.mock.calls[0][0].headers.authorization).toBe(`Basic ${Buffer.from("cursor-admin-env-key:").toString("base64")}`);
+ });
+
+ it("keeps a zero-utilization Cursor window visible", async () => {
+ mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
+ mockCursorAccount({ email: "developer@company.com" });
+ mockCursorSpendResponse(200, {
+ subscriptionCycleStart: Date.now() - 24 * 60 * 60 * 1000,
+ teamMemberSpend: [
+ {
+ email: "developer@company.com",
+ overallSpendCents: 0,
+ monthlyLimitDollars: 100,
+ },
+ ],
+ });
+
+ const providers = await fetchAllProviderUsage(cursorAuthStorage("cursor-admin-key"));
+ const cursor = providers.find((p) => p.name === "Cursor")!;
+
+ expect(cursor.status).toBe("ok");
+ expect(cursor.windows).toHaveLength(1);
+ expect(cursor.windows[0].percentUsed).toBe(0);
+ expect(cursor.windows[0].percentLeft).toBe(100);
+ });
+
+ it("omits Cursor when no Cursor API key is configured", async () => {
+ mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
+ mockExecFileSync.mockImplementation(() => {
+ throw new Error("File not found");
+ });
+
+ const providers = await fetchAllProviderUsage();
+ const cursor = providers.find((p) => p.name === "Cursor");
+
+ expect(cursor).toBeUndefined();
+ });
+
+ it("omits Cursor when the spend response has no meterable row", async () => {
+ mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
+ mockCursorAccount({ email: "developer@company.com" });
+ mockCursorSpendResponse(200, { teamMemberSpend: [] });
+
+ const providers = await fetchAllProviderUsage(cursorAuthStorage("cursor-admin-key"));
+ const cursor = providers.find((p) => p.name === "Cursor");
+
+ expect(cursor).toBeUndefined();
+ });
+
+ it("keeps Cursor visible as error for expired API keys", async () => {
+ mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
+ mockCursorAccount({ email: "developer@company.com" });
+ mockCursorSpendResponse(401, { error: "unauthorized" });
+
+ const providers = await fetchAllProviderUsage(cursorAuthStorage("expired-cursor-key"));
+ const cursor = providers.find((p) => p.name === "Cursor")!;
+
+ expect(cursor.status).toBe("error");
+ expect(cursor.error).toContain("Auth expired");
+ });
+
+ it("keeps Cursor visible as error for non-200 and parse failures", async () => {
+ mockReadFile.mockImplementation(async () => Promise.reject(new Error("File not found")));
+ mockCursorAccount({ email: "developer@company.com" });
+ mockCursorSpendResponse(500, { error: "server error" });
+
+ let providers = await fetchAllProviderUsage(cursorAuthStorage("cursor-admin-key"));
+ let cursor = providers.find((p) => p.name === "Cursor")!;
+ expect(cursor.status).toBe("error");
+ expect(cursor.error).toContain("HTTP 500");
+
+ clearUsageCache();
+ mockRequest.mockClear();
+ mockCursorSpendResponse(200, "not json");
+
+ providers = await fetchAllProviderUsage(cursorAuthStorage("cursor-admin-key"));
+ cursor = providers.find((p) => p.name === "Cursor")!;
+ expect(cursor.status).toBe("error");
+ expect(cursor.error).toMatch(/JSON|Unexpected/i);
+ });
+ });
+
describe("Grok provider", () => {
const mockGrokApiKeyResponse = (statusCode: number, body: unknown) => {
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
diff --git a/packages/dashboard/src/usage.ts b/packages/dashboard/src/usage.ts
index ca3e2e080b..092907e818 100644
--- a/packages/dashboard/src/usage.ts
+++ b/packages/dashboard/src/usage.ts
@@ -1810,6 +1810,243 @@ async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise {
+ for (const envName of CURSOR_ADMIN_API_KEY_ENV_VARS) {
+ const envKey = process.env[envName];
+ if (typeof envKey === "string" && envKey.trim().length > 0) {
+ return envKey.trim();
+ }
+ }
+
+ try {
+ authStorage?.reload();
+ } catch {
+ // Reload may fail if no storage - ignore.
+ }
+
+ for (const providerId of CURSOR_API_KEY_PROVIDER_IDS) {
+ try {
+ const apiKey = await authStorage?.getApiKey?.(providerId);
+ if (apiKey) return apiKey;
+ } catch {
+ // Try the next provider id.
+ }
+
+ try {
+ const entry = authStorage?.get?.(providerId);
+ if (entry && (entry.type === "api_key" || entry.type === "key") && entry.key) {
+ return entry.key;
+ }
+ } catch {
+ // Try the next provider id.
+ }
+ }
+
+ return null;
+}
+
+async function readCursorAccountInfo(): Promise {
+ for (const command of ["cursor-agent", "cursor"]) {
+ try {
+ const { stdout } = await execFileAsync(command, ["about", "--format", "json"], {
+ encoding: "utf-8",
+ timeout: 5000,
+ });
+ const data = JSON.parse(stdout.trim()) as Record;
+ return {
+ email: typeof data.userEmail === "string" ? data.userEmail : undefined,
+ plan: typeof data.subscriptionTier === "string" ? data.subscriptionTier : undefined,
+ };
+ } catch {
+ // Try the next binary/status fallback.
+ }
+
+ try {
+ const { stdout } = await execFileAsync(command, ["status", "--format", "json"], {
+ encoding: "utf-8",
+ timeout: 5000,
+ });
+ const data = JSON.parse(stdout.trim()) as { userInfo?: { email?: unknown }; status?: unknown };
+ return {
+ email: typeof data.userInfo?.email === "string" ? data.userInfo.email : undefined,
+ plan: typeof data.status === "string" ? data.status : undefined,
+ };
+ } catch {
+ // Try the next binary.
+ }
+ }
+
+ return {};
+}
+
+function readNumber(value: unknown): number | undefined {
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ if (typeof value === "string" && value.trim().length > 0) {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return undefined;
+}
+
+function clampPercent(value: number): number {
+ return Math.min(100, Math.max(0, value));
+}
+
+function addOneMonth(value: Date): Date {
+ const next = new Date(value.getTime());
+ next.setUTCMonth(next.getUTCMonth() + 1);
+ return next;
+}
+
+function deriveCursorReset(cycleStart: unknown): { resetText: string | null; resetMs?: number; resetAt?: string; windowDurationMs: number } {
+ const startMs = readNumber(cycleStart);
+ if (!startMs) {
+ return { resetText: null, windowDurationMs: CURSOR_MONTHLY_WINDOW_MS };
+ }
+
+ let resetAtDate = addOneMonth(new Date(startMs >= 1e12 ? startMs : startMs * 1000));
+ const now = Date.now();
+ while (resetAtDate.getTime() <= now) {
+ resetAtDate = addOneMonth(resetAtDate);
+ }
+
+ const parsedReset = _parseResetTimestamp(resetAtDate.toISOString());
+ if (!parsedReset) {
+ return { resetText: null, windowDurationMs: CURSOR_MONTHLY_WINDOW_MS };
+ }
+
+ return {
+ resetText: `resets in ${formatDuration(parsedReset.msLeft)}`,
+ resetMs: parsedReset.msLeft,
+ resetAt: parsedReset.resetAt,
+ windowDurationMs: CURSOR_MONTHLY_WINDOW_MS,
+ };
+}
+
+function getCursorSpendRows(data: Record): Record[] {
+ const nested = typeof data.data === "object" && data.data !== null ? data.data as Record : undefined;
+ const rows = data.teamMemberSpend ?? nested?.teamMemberSpend ?? data.members ?? nested?.members;
+ return Array.isArray(rows) ? rows.filter((row): row is Record => typeof row === "object" && row !== null) : [];
+}
+
+function selectCursorSpendRow(rows: Record[], email?: string): Record | null {
+ if (rows.length === 0) return null;
+ if (email) {
+ const normalizedEmail = email.toLowerCase();
+ const match = rows.find((row) => typeof row.email === "string" && row.email.toLowerCase() === normalizedEmail);
+ if (match) return match;
+ }
+ return rows.length === 1 ? rows[0] : null;
+}
+
+async function fetchCursorUsage(authStorage?: AuthStorageLike): Promise {
+ const usage: ProviderUsage = {
+ name: "Cursor",
+ icon: "🟣",
+ status: "no-auth",
+ windows: [],
+ };
+
+ const apiKey = await readCursorApiKey(authStorage);
+ if (!apiKey) {
+ usage.error = "No Cursor Admin API key — set CURSOR_ADMIN_API_KEY (or CURSOR_API_KEY) in the Fusion dashboard environment";
+ return usage;
+ }
+
+ const account = await readCursorAccountInfo();
+ if (account.email) usage.email = account.email;
+ if (account.plan) usage.plan = account.plan;
+
+ try {
+ /*
+ FNXC:UsageProviders 2026-07-10-00:00:
+ Cursor exposes meterable team spend through the documented Admin API `POST https://api.cursor.com/teams/spend`, authenticated with Basic auth using the API key as the username (`-u YOUR_API_KEY:`). Fusion resolves that key from `CURSOR_ADMIN_API_KEY` (preferred) or the documented `CURSOR_API_KEY` compatibility alias; Cursor CLI OAuth/session auth is not an Admin API credential and cannot reach this endpoint by itself. The response documents `teamMemberSpend[].overallSpendCents`, `spendCents`, `hardLimitOverrideDollars`, `monthlyLimitDollars`, `email`, and `subscriptionCycleStart`; no personal CLI usage endpoint or direct reset timestamp is documented, so personal/session-only Cursor logins stay `no-auth` and the reset is derived from the monthly cycle start.
+ */
+ const body: Record = { page: 1, pageSize: 500 };
+ if (account.email) body.searchTerm = account.email;
+
+ const res = await httpsRequest(CURSOR_ADMIN_SPEND_ENDPOINT, {
+ method: "POST",
+ headers: {
+ authorization: `Basic ${Buffer.from(`${apiKey}:`).toString("base64")}`,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (res.status === 401 || res.status === 403) {
+ usage.status = "error";
+ usage.error = "Auth expired — check your Cursor Admin API key";
+ return usage;
+ }
+
+ if (res.status === 404) {
+ usage.status = "no-auth";
+ usage.error = "No Cursor usage entitlement found";
+ return usage;
+ }
+
+ if (res.status !== 200) {
+ usage.status = "error";
+ usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
+ return usage;
+ }
+
+ const data = JSON.parse(res.body) as Record;
+ const rows = getCursorSpendRows(data);
+ const row = selectCursorSpendRow(rows, account.email);
+ if (!row) {
+ usage.status = "no-auth";
+ usage.error = rows.length > 1
+ ? "Cursor usage response did not include a unique current user row"
+ : "Cursor usage response did not include meterable spend";
+ return usage;
+ }
+
+ const spentCents = readNumber(row.overallSpendCents) ?? readNumber(row.spendCents);
+ const hardLimitDollars = readNumber(row.hardLimitOverrideDollars);
+ const monthlyLimitDollars = readNumber(row.monthlyLimitDollars);
+ const limitDollars = hardLimitDollars && hardLimitDollars > 0 ? hardLimitDollars : monthlyLimitDollars;
+ if (spentCents === undefined || !limitDollars || limitDollars <= 0) {
+ usage.status = "no-auth";
+ usage.error = "Cursor usage response did not include a spend limit to meter";
+ return usage;
+ }
+
+ const percentUsed = clampPercent((spentCents / (limitDollars * 100)) * 100);
+ const reset = deriveCursorReset(data.subscriptionCycleStart ?? row.subscriptionCycleStart);
+ usage.status = "ok";
+ usage.email = typeof row.email === "string" ? row.email : usage.email;
+ usage.plan = usage.plan ?? (typeof row.plan === "string" ? row.plan : null);
+ usage.windows.push({
+ label: "Monthly spend",
+ percentUsed,
+ percentLeft: clampPercent(100 - percentUsed),
+ resetText: reset.resetText,
+ resetMs: reset.resetMs,
+ resetAt: reset.resetAt,
+ windowDurationMs: reset.windowDurationMs,
+ });
+ } catch (e: unknown) {
+ usage.status = "error";
+ usage.error = e instanceof Error ? e.message : "Failed to fetch";
+ }
+
+ return usage;
+}
+
// ── GitHub Copilot fetcher ──────────────────────────────────────────────────
type CopilotCredential = {
@@ -2027,7 +2264,7 @@ export async function fetchAllProviderUsage(authStorage?: AuthStorageLike): Prom
}
// Fetch all providers in parallel with per-provider timeout
- // Currently includes: Claude, Codex, Gemini, Minimax, Zai, Grok, GitHub Copilot
+ // Currently includes: Claude, Codex, Gemini, Minimax, Zai, Grok, Cursor, GitHub Copilot
const results = await Promise.allSettled([
withTimeout(fetchClaudeUsage(authStorage), "Claude", CLAUDE_FETCH_TIMEOUT_MS),
withTimeout(fetchCodexUsage(), "Codex"),
@@ -2035,6 +2272,7 @@ export async function fetchAllProviderUsage(authStorage?: AuthStorageLike): Prom
withTimeout(fetchMinimaxUsage(authStorage), "Minimax"),
withTimeout(fetchZaiUsage(authStorage), "Zai"),
withTimeout(fetchGrokUsage(authStorage), "Grok"),
+ withTimeout(fetchCursorUsage(authStorage), "Cursor"),
withTimeout(fetchGitHubCopilotUsage(), "GitHub Copilot"),
]);