shariq-pi-extensions 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,6 +26,7 @@ The package contains:
26
26
  - structured user questions
27
27
  - managed background terminals
28
28
  - copy-all transcript support
29
+ - Cursor OAuth/API-key provider with native SDK transport and monthly usage dashboard
29
30
  - Factory OAuth and rotating API-key provider
30
31
  - Firecrawl search and scraping
31
32
  - Git status UI
@@ -25,6 +25,7 @@ Pi-owned packages and `typebox` are optional peer dependencies at runtime. Pi su
25
25
 
26
26
  Third-party runtime dependencies are normal package dependencies:
27
27
 
28
+ - `@cursor/sdk` powers native Cursor model runs, catalog discovery, images, tools, and usage reporting;
28
29
  - `@lydell/node-pty` powers managed background terminals;
29
30
  - `effect` powers subagent lifecycle and coordination;
30
31
  - `ws` powers Factory's Responses WebSocket transport.
@@ -35,6 +36,7 @@ The repository uses one root manifest and lockfile for the full extension suite.
35
36
 
36
37
  Installed package directories are treated as immutable. Extensions resolve writable state through Pi's APIs:
37
38
 
39
+ - Cursor's authenticated Composer/Grok catalog cache: `<agent-dir>/cursor/models.json`
38
40
  - Factory key selection, Droid metadata, and throttled per-credential limit cache: `<agent-dir>/factory/`
39
41
  - Pi Memory state: `<agent-dir>/pi-memory/`
40
42
  - Subagent configuration and catalog: paths derived from `getAgentDir()`
@@ -82,7 +82,7 @@ Before publishing a new npm version:
82
82
  1. Update `package.json#version` using semantic versioning.
83
83
  2. Run `mise exec --locked -- npm ci` and `mise exec --locked -- npm run validate`.
84
84
  3. Inspect `mise exec --locked -- npm run pack:inspect` for secrets, runtime state, and accidental files.
85
- 4. Publish with `npm publish --access public --provenance`, or run the `Publish npm package` workflow with `NPM_TOKEN` configured.
85
+ 4. Run the `Publish npm package` workflow. npm trusted publishing authenticates it through GitHub OIDC and records provenance without a long-lived token.
86
86
  5. Verify the registry version and install it with `pi install npm:shariq-pi-extensions`.
87
87
 
88
88
  Use npm for stable versioned installs. Use an unpinned Git source for updates directly from `main`, and a tag or commit when a machine should remain fixed to a known revision.
@@ -8,6 +8,10 @@ Last verified: 2026-08-20
8
8
 
9
9
  Registers the `antigravity` provider and `/login antigravity` flow for supported Google Antigravity models. Use `/antigravity.doctor` to inspect non-secret provider status. Credentials remain in Pi auth storage.
10
10
 
11
+ ### [Cursor provider](../extensions/cursor-provider/README.md)
12
+
13
+ Registers one `cursor` provider for Cursor-hosted Composer and Cursor Grok models through the native Cursor SDK. `/login cursor` supports browser-minted or existing user API keys, images and native Pi tool delegation are enabled, and `/cursor` shows Cursor's authoritative current-month total, Auto/Composer and named/API percentages, reset date, plan, and on-demand limits. It does not expose ACP, third-party models, or Factory-style 5-hour/weekly pools. The authenticated catalog cache belongs in `<agent-dir>/cursor/models.json`.
14
+
11
15
  ### [Factory provider](../extensions/factory-provider/README.md)
12
16
 
13
17
  Registers one `factory` provider with Factory account OAuth, direct API keys, or rotating API keys. `/factory` opens one full-width dashboard for provider/authentication state, model metadata, rotation status, and authoritative Factory usage across every credential. Standard and Droid Core windows stay separate, percentages are labeled as used, and keys are never aggregated. Rotation applies a monthly → weekly → 5-hour eligibility waterfall, then selects the least recently used eligible key and retains same-request failover for recognized pre-output errors. Background refreshes are throttled to 15 minutes; `r` force-refreshes from the dashboard.
@@ -0,0 +1,50 @@
1
+ # Cursor provider
2
+
3
+ Registers Cursor-hosted Composer and Cursor Grok models in Pi through the official native `@cursor/sdk`. It does not use ACP, a Cursor CLI subprocess, prompt-encoded tool markers, or third-party model families.
4
+
5
+ ## Authentication
6
+
7
+ Run:
8
+
9
+ ```text
10
+ /login cursor
11
+ ```
12
+
13
+ Choose either:
14
+
15
+ - **Cursor browser login:** Cursor signs in through the browser and mints a named 90-day user API key. Pi stores the key in its normal OAuth credential store; the Cursor SDK's own credential file is not used.
16
+ - **Existing Cursor user API key:** set a `crsr_…` key in `CURSOR_API_KEY`. Pi resolves it directly without creating duplicate models; `/login cursor` can validate the configured environment key and copy it into Pi auth storage. Non-expiring user keys remain valid until revoked.
17
+
18
+ Secrets are passed directly to the SDK and are never placed in command arguments, diagnostics, model metadata, or the repository.
19
+
20
+ ## Provider behavior
21
+
22
+ - Refreshes Cursor's authenticated model catalog and caches only Composer and Cursor Grok metadata for the next extension reload.
23
+ - Exposes image input for every registered Cursor model.
24
+ - Maps Composer fast mode and Cursor Grok reasoning effort to native model parameters.
25
+ - Uses the SDK's local hosted-model runtime in an isolated temporary workspace with ambient Cursor settings disabled.
26
+ - Disables Cursor's built-in workspace tools and exposes Pi's active tools as native SDK custom tools. Tool execution remains owned by Pi.
27
+ - Streams native text and thinking deltas, structured tool calls, stop reasons, and Cursor-reported input/output/cache/reasoning usage.
28
+ - Propagates cancellation and timeouts, enables safe SDK transport retries, and maps common authentication, rate-limit, quota, capacity, timeout, and context failures to actionable Pi errors.
29
+ - Forwards base64 image payloads separately from the textual conversation transcript.
30
+ - Removes temporary workspace and SDK state after each request.
31
+
32
+ ## Cursor dashboard
33
+
34
+ Run `/cursor` for a Cursor-specific account dashboard. It reads Cursor's current billing-period APIs and shows:
35
+
36
+ - account and plan
37
+ - monthly total usage
38
+ - purchased and bonus usage
39
+ - Cursor Auto/Composer and named/API percentages
40
+ - billing-cycle reset time
41
+ - on-demand availability and configured/recommended limits
42
+ - authenticated Cursor-owned model count
43
+
44
+ It intentionally does not display Factory's 5-hour, weekly, Standard, or Core buckets. Press `r` to refresh and Escape to close.
45
+
46
+ Use `/cursor.doctor` for sanitized provider, authentication, transport, and model diagnostics.
47
+
48
+ ## Current protocol boundary
49
+
50
+ Cursor documents the SDK as an agent API rather than a raw chat-completions API. Pi sends its full current conversation to a native SDK run. When Cursor selects a Pi custom tool, the run is cancelled after the structured invocation is captured; Pi executes the tool and the next native run receives the resulting conversation state. Cursor never receives direct filesystem or shell authority from this provider.
@@ -0,0 +1,75 @@
1
+ import { Cursor, DEFAULT_LOGIN_API_KEY_TTL_MS, type SdkLoginOptions, type SdkLoginResult } from "@cursor/sdk";
2
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
3
+ import { refreshCursorCatalog } from "./models.ts";
4
+
5
+ const EXPIRY_SKEW_MS = 60_000;
6
+ const BROWSER_KEY_SENTINEL = "cursor-sdk-browser-user-key";
7
+ const STATIC_KEY_SENTINEL = "cursor-static-user-key";
8
+ const NON_EXPIRING_CREDENTIAL_MS = 100 * 365 * 24 * 60 * 60_000;
9
+
10
+ type CursorSdkLogin = (options?: SdkLoginOptions) => Promise<SdkLoginResult>;
11
+ type CursorCatalogRefresh = (apiKey: string) => Promise<unknown>;
12
+ type CursorIdentityValidator = (apiKey: string) => Promise<unknown>;
13
+
14
+ async function validateAndCatalog(apiKey: string, refreshCatalog: CursorCatalogRefresh, validateIdentity: CursorIdentityValidator): Promise<void> {
15
+ await validateIdentity(apiKey);
16
+ await refreshCatalog(apiKey);
17
+ }
18
+
19
+ export async function loginCursor(
20
+ callbacks: OAuthLoginCallbacks,
21
+ sdkLogin: CursorSdkLogin = Cursor.auth.login,
22
+ refreshCatalog: CursorCatalogRefresh = refreshCursorCatalog,
23
+ validateIdentity: CursorIdentityValidator = (apiKey) => Cursor.me({ apiKey }),
24
+ ): Promise<OAuthCredentials> {
25
+ const environmentKey = process.env.CURSOR_API_KEY?.trim();
26
+ const method = await callbacks.onSelect({
27
+ message: "Choose how to authenticate with Cursor:",
28
+ options: [
29
+ { id: "browser", label: "Cursor browser login — mint a 90-day Pi key" },
30
+ ...(environmentKey ? [{ id: "environment", label: "Use CURSOR_API_KEY from the environment" }] : []),
31
+ ],
32
+ });
33
+ if (!method) throw new Error("Cursor login cancelled.");
34
+
35
+ if (method === "environment") {
36
+ const apiKey = environmentKey;
37
+ if (!apiKey) throw new Error("CURSOR_API_KEY is not configured.");
38
+ callbacks.onProgress?.("Validating the Cursor API key and refreshing models...");
39
+ await validateAndCatalog(apiKey, refreshCatalog, validateIdentity);
40
+ return {
41
+ access: apiKey,
42
+ refresh: STATIC_KEY_SENTINEL,
43
+ expires: Date.now() + NON_EXPIRING_CREDENTIAL_MS,
44
+ };
45
+ }
46
+ if (method !== "browser") throw new Error(`Unknown Cursor login method: ${method}`);
47
+
48
+ callbacks.onProgress?.("Preparing Cursor browser authorization...");
49
+ const result = await sdkLogin({
50
+ openBrowser: false,
51
+ store: null,
52
+ signal: callbacks.signal,
53
+ apiKeyName: "Pi coding agent",
54
+ apiKeyTtlMs: DEFAULT_LOGIN_API_KEY_TTL_MS,
55
+ onLoginUrl(url) {
56
+ callbacks.onAuth({ url, instructions: "Sign in to Cursor and approve the Pi coding agent key." });
57
+ callbacks.onProgress?.("Waiting for Cursor browser authorization...");
58
+ },
59
+ });
60
+ callbacks.onProgress?.("Refreshing the Cursor-owned model catalog...");
61
+ await refreshCatalog(result.apiKey);
62
+ return {
63
+ access: result.apiKey,
64
+ refresh: BROWSER_KEY_SENTINEL,
65
+ expires: result.apiKeyExpiresAtMs - EXPIRY_SKEW_MS,
66
+ };
67
+ }
68
+
69
+ export async function refreshCursorToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
70
+ if (credentials.refresh === STATIC_KEY_SENTINEL) return credentials;
71
+ if (credentials.refresh !== BROWSER_KEY_SENTINEL || credentials.expires <= Date.now()) {
72
+ throw new Error("Cursor login expired. Run `/login cursor` again.");
73
+ }
74
+ return credentials;
75
+ }
@@ -0,0 +1,147 @@
1
+ import type { ExtensionContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
3
+ import { frameBottom, frameTop, joinSides, meter, oneLine, padLine, stateLabel } from "../../shared/tui-dashboard.ts";
4
+ import { dollars, percentUsed, type CursorUsageSnapshot } from "./usage.ts";
5
+
6
+ type Theme = ExtensionContext["ui"]["theme"];
7
+
8
+ export interface CursorDashboardSnapshot {
9
+ authentication: string;
10
+ keyExpiresAt?: number;
11
+ usage?: CursorUsageSnapshot;
12
+ error?: string;
13
+ }
14
+
15
+ function date(value: number | undefined): string {
16
+ if (!value) return "unavailable";
17
+ return new Date(value).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
18
+ }
19
+
20
+ function pct(value: number | undefined): string {
21
+ return typeof value === "number" ? `${Math.round(value)}%` : "unavailable";
22
+ }
23
+
24
+ export class CursorDashboard implements Component {
25
+ private refreshing = false;
26
+ private closed = false;
27
+ private readonly tui: TUI;
28
+ private readonly theme: Theme;
29
+ private readonly keys: KeybindingsManager;
30
+ private snapshot: CursorDashboardSnapshot;
31
+ private readonly refreshData: () => Promise<CursorDashboardSnapshot>;
32
+ private readonly done: () => void;
33
+
34
+ constructor(
35
+ tui: TUI,
36
+ theme: Theme,
37
+ keys: KeybindingsManager,
38
+ snapshot: CursorDashboardSnapshot,
39
+ refreshData: () => Promise<CursorDashboardSnapshot>,
40
+ done: () => void,
41
+ ) {
42
+ this.tui = tui;
43
+ this.theme = theme;
44
+ this.keys = keys;
45
+ this.snapshot = snapshot;
46
+ this.refreshData = refreshData;
47
+ this.done = done;
48
+ }
49
+
50
+ startRefresh() {
51
+ if (this.refreshing || this.closed) return;
52
+ this.refreshing = true;
53
+ this.tui.requestRender();
54
+ void this.refreshData()
55
+ .then((snapshot) => { if (!this.closed) this.snapshot = snapshot; })
56
+ .catch((error) => {
57
+ if (!this.closed) this.snapshot = { ...this.snapshot, error: error instanceof Error ? error.message : String(error) };
58
+ })
59
+ .finally(() => {
60
+ if (!this.closed) {
61
+ this.refreshing = false;
62
+ this.tui.requestRender();
63
+ }
64
+ });
65
+ }
66
+
67
+ dispose() { this.closed = true; }
68
+ invalidate() {}
69
+
70
+ handleInput(data: string) {
71
+ if (this.keys.matches(data, "tui.select.cancel")) {
72
+ this.closed = true;
73
+ this.done();
74
+ } else if (data === "r") {
75
+ this.startRefresh();
76
+ }
77
+ this.tui.requestRender();
78
+ }
79
+
80
+ render(width: number) {
81
+ const usage = this.snapshot.usage;
82
+ const plan = usage?.plan;
83
+ const monthly = usage?.usage;
84
+ const used = percentUsed(usage ?? { enabled: false, canAdjustOnDemand: false, models: [], fetchedAt: 0 });
85
+ const status = this.snapshot.error ? "refresh error" : usage ? "ready" : "loading";
86
+ const statusColor = this.snapshot.error ? "error" : usage ? "success" : "warning";
87
+ const title = ` ${this.theme.fg("accent", this.theme.bold("◆ CURSOR"))} ${this.theme.fg("dim", `· ${this.snapshot.authentication}`)}`;
88
+ const right = this.refreshing
89
+ ? this.theme.fg("warning", "refreshing…")
90
+ : this.theme.fg("muted", `${plan?.planName ?? "plan unavailable"} · ${usage?.models.length ?? 0} Cursor-owned models`);
91
+ const lines = width >= 64
92
+ ? [joinSides(title, `${right} `, width)]
93
+ : [truncateToWidth(title, width, ""), truncateToWidth(` ${right}`, width, "")];
94
+ lines.push(frameTop(this.theme, width, "CURRENT MONTH · CURSOR ACCOUNT USAGE"));
95
+ const inner = Math.max(1, width - 2);
96
+ const body: string[] = [
97
+ ` ${stateLabel(this.theme, statusColor, status)} ${this.theme.fg("text", usage?.email ?? "Cursor account")}`,
98
+ ` ${this.theme.fg("dim", `API key ${usage?.apiKeyName ?? "configured"} · created ${date(usage?.apiKeyCreatedAt)} · expires ${usage ? (usage.apiKeyExpiresAt ? date(usage.apiKeyExpiresAt) : "does not expire") : date(this.snapshot.keyExpiresAt)}`)}`,
99
+ "",
100
+ ];
101
+ if (this.snapshot.error) {
102
+ body.push(` ${this.theme.fg("error", oneLine(this.snapshot.error))}`, "", ` ${this.theme.fg("muted", "Press r to retry. Your credentials are never shown here.")}`);
103
+ } else if (!usage || !monthly) {
104
+ body.push(` ${this.theme.fg("muted", "Loading Cursor account usage…")}`);
105
+ } else {
106
+ const meterWidth = Math.max(12, inner - 29);
107
+ body.push(
108
+ ` ${this.theme.fg("accent", this.theme.bold("MONTHLY TOTAL USAGE"))}`,
109
+ ` ${meter(this.theme, Math.min(100, used ?? 0), 100, meterWidth, (used ?? 0) >= 100 ? "error" : (used ?? 0) >= 85 ? "warning" : "active")} ${this.theme.fg("text", pct(used))}`,
110
+ ` ${this.theme.fg("muted", "Cycle")} ${this.theme.fg("text", `${date(usage.billingCycleStart)} → ${date(usage.billingCycleEnd ?? plan?.billingCycleEnd)}`)}`,
111
+ ` ${this.theme.fg("muted", "Total usage")} ${this.theme.fg("text", dollars(monthly.totalSpend))} ${this.theme.fg("muted", "Purchased")} ${this.theme.fg("text", dollars(monthly.includedSpend || plan?.includedAmountCents))} ${this.theme.fg("muted", "Bonus")} ${this.theme.fg("text", dollars(monthly.bonusSpend))}`,
112
+ "",
113
+ ` ${this.theme.fg("accent", this.theme.bold("CURSOR POOLS"))}`,
114
+ ` ${this.theme.fg("muted", "Auto / Composer")} ${meter(this.theme, Math.min(100, monthly.autoPercentUsed ?? 0), 100, meterWidth, (monthly.autoPercentUsed ?? 0) >= 85 ? "warning" : "active")} ${this.theme.fg("text", pct(monthly.autoPercentUsed))}`,
115
+ ` ${this.theme.fg("muted", "Named / API ")} ${meter(this.theme, Math.min(100, monthly.apiPercentUsed ?? 0), 100, meterWidth, (monthly.apiPercentUsed ?? 0) >= 85 ? "warning" : "active")} ${this.theme.fg("text", pct(monthly.apiPercentUsed))}`,
116
+ "",
117
+ ` ${this.theme.fg("accent", this.theme.bold("ON-DEMAND"))}`,
118
+ ` ${this.theme.fg("muted", "Adjustable")} ${this.theme.fg("text", usage.canAdjustOnDemand ? "yes" : "no")} ${this.theme.fg("muted", "Limit")} ${this.theme.fg("text", dollars(usage.spendLimit?.individualLimit ?? usage.currentOnDemandLimitCents))} ${this.theme.fg("muted", "Recommended")} ${this.theme.fg("text", dollars(usage.recommendedOnDemandLimitCents))}`,
119
+ ` ${this.theme.fg("muted", "Used")} ${this.theme.fg("text", dollars(usage.spendLimit?.individualUsed))} ${this.theme.fg("muted", "Remaining")} ${this.theme.fg("text", dollars(usage.spendLimit?.individualRemaining))} ${this.theme.fg("muted", "Limit type")} ${this.theme.fg("text", usage.spendLimit?.limitType ?? "unavailable")}`,
120
+ "",
121
+ ` ${this.theme.fg("dim", `Updated ${date(usage.fetchedAt)} · values come from Cursor's current-period usage API`)}`,
122
+ );
123
+ }
124
+ const bodyHeight = Math.max(12, (this.tui.terminal.rows || 30) - 6);
125
+ for (let row = 0; row < bodyHeight; row++) {
126
+ lines.push(this.theme.fg("border", "│") + padLine(body[row] ?? "", inner) + this.theme.fg("border", "│"));
127
+ }
128
+ lines.push(frameBottom(this.theme, width));
129
+ lines.push(truncateToWidth(`${this.theme.fg("accent", " r")} ${this.theme.fg("dim", "refresh")} ${this.theme.fg("accent", "esc")} ${this.theme.fg("dim", "close")}`, width, ""));
130
+ return lines.map((line) => truncateToWidth(line, width, ""));
131
+ }
132
+ }
133
+
134
+ export async function openCursorDashboard(
135
+ ctx: ExtensionContext,
136
+ initial: CursorDashboardSnapshot,
137
+ refresh: () => Promise<CursorDashboardSnapshot>,
138
+ ) {
139
+ await ctx.ui.custom<void>(
140
+ (tui, theme, keys, done) => {
141
+ const dashboard = new CursorDashboard(tui, theme, keys, initial, refresh, () => done(undefined));
142
+ queueMicrotask(() => dashboard.startRefresh());
143
+ return dashboard;
144
+ },
145
+ { overlay: true, overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" } },
146
+ );
147
+ }
@@ -0,0 +1,124 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { Cursor, type ModelListItem, type ModelSelection } from "@cursor/sdk";
4
+ import type { Model } from "@earendil-works/pi-ai";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+
7
+ export const CURSOR_PROVIDER_ID = "cursor";
8
+ export const CURSOR_API = "cursor-sdk" as const;
9
+ export const CURSOR_BASE_URL = "https://api2.cursor.sh";
10
+ const CATALOG_PATH = join(getAgentDir(), "cursor", "models.json");
11
+
12
+ const FALLBACK_CATALOG: ModelListItem[] = [
13
+ { id: "composer-2.5", displayName: "Composer 2.5", parameters: [{ id: "fast", values: [{ value: "false" }, { value: "true", displayName: "Fast" }] }] },
14
+ { id: "grok-4.5", displayName: "Cursor Grok 4.5", parameters: [{ id: "effort", values: ["low", "medium", "high"].map((value) => ({ value })) }, { id: "fast", values: [{ value: "false" }, { value: "true", displayName: "Fast" }] }] },
15
+ { id: "grok-4.6", displayName: "Cursor Grok 4.6", parameters: [{ id: "effort", values: ["low", "medium", "high", "xhigh"].map((value) => ({ value })) }, { id: "fast", values: [{ value: "false" }, { value: "true", displayName: "Fast" }] }] },
16
+ ];
17
+
18
+ function cursorOwned(model: ModelListItem): boolean {
19
+ if (model.id === "composer-2") return false; // Retired; Cursor reroutes it to Composer 2.5.
20
+ return model.id.startsWith("composer-") || /^grok-4\.(?:5|6)$/.test(model.id) || model.id.startsWith("cursor-grok-");
21
+ }
22
+
23
+ function piModelId(model: ModelListItem): string {
24
+ return model.id.startsWith("grok-") ? `cursor-${model.id}` : model.id;
25
+ }
26
+
27
+ export function loadCursorCatalog(): ModelListItem[] {
28
+ try {
29
+ const parsed = JSON.parse(readFileSync(CATALOG_PATH, "utf8"));
30
+ if (Array.isArray(parsed)) {
31
+ const models = parsed.filter((value): value is ModelListItem => value && typeof value.id === "string" && typeof value.displayName === "string" && cursorOwned(value));
32
+ if (models.length > 0) return models;
33
+ }
34
+ } catch {
35
+ // The deterministic fallback keeps the provider available before first login.
36
+ }
37
+ return FALLBACK_CATALOG;
38
+ }
39
+
40
+ export async function refreshCursorCatalog(apiKey: string): Promise<ModelListItem[]> {
41
+ const models = (await Cursor.models.list({ apiKey })).filter(cursorOwned);
42
+ if (models.length === 0) throw new Error("Cursor returned no Composer or Cursor Grok models for this account.");
43
+ mkdirSync(dirname(CATALOG_PATH), { recursive: true, mode: 0o700 });
44
+ const temporary = `${CATALOG_PATH}.${process.pid}.tmp`;
45
+ writeFileSync(temporary, `${JSON.stringify(models, null, 2)}\n`, { mode: 0o600 });
46
+ renameSync(temporary, CATALOG_PATH);
47
+ return models;
48
+ }
49
+
50
+ function parameter(model: ModelListItem, matcher: RegExp) {
51
+ return model.parameters?.find((value) => matcher.test(value.id));
52
+ }
53
+
54
+ function modelCapabilities(id: string) {
55
+ const fast = id.endsWith("-fast");
56
+ if (id.startsWith("composer-2.5")) {
57
+ return { contextWindow: 200_000, maxTokens: 64_000, cost: fast ? { input: 3, output: 15, cacheRead: 0.5, cacheWrite: 0 } : { input: 0.5, output: 2.5, cacheRead: 0.2, cacheWrite: 0 } };
58
+ }
59
+ if (id.startsWith("cursor-grok-4.6")) {
60
+ return { contextWindow: 256_000, maxTokens: 64_000, cost: fast ? { input: 4, output: 12, cacheRead: 1, cacheWrite: 0 } : { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 } };
61
+ }
62
+ if (id.startsWith("cursor-grok-4.5")) {
63
+ return { contextWindow: 256_000, maxTokens: 64_000, cost: fast ? { input: 4, output: 18, cacheRead: 0, cacheWrite: 0 } : { input: 2, output: 6, cacheRead: 0, cacheWrite: 0 } };
64
+ }
65
+ return { contextWindow: 200_000, maxTokens: 64_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } };
66
+ }
67
+
68
+ function cursorModelConfig(id: string, name: string, reasoning: boolean, efforts: string[] = []) {
69
+ const supported = new Set(efforts.length > 0 ? efforts : ["low", "medium", "high", "xhigh"]);
70
+ const low = supported.has("low") ? "low" : [...supported][0] ?? "low";
71
+ const medium = supported.has("medium") ? "medium" : low;
72
+ const high = supported.has("high") ? "high" : medium;
73
+ const xhigh = supported.has("xhigh") ? "xhigh" : high;
74
+ return {
75
+ id,
76
+ name,
77
+ api: CURSOR_API,
78
+ reasoning,
79
+ input: ["text", "image"] as const,
80
+ ...modelCapabilities(id),
81
+ ...(reasoning ? { thinkingLevelMap: { off: low, minimal: low, low, medium, high, xhigh, max: xhigh } } : {}),
82
+ };
83
+ }
84
+
85
+ export function toCursorPiModels(catalog: ModelListItem[] = loadCursorCatalog()): Array<ReturnType<typeof cursorModelConfig>> {
86
+ const result: Array<ReturnType<typeof cursorModelConfig>> = [];
87
+ for (const model of catalog.filter(cursorOwned)) {
88
+ const id = piModelId(model);
89
+ const effortValues = parameter(model, /effort|reason/i)?.values.map((value) => value.value) ?? [];
90
+ const reasoning = effortValues.length > 0 || id.startsWith("cursor-grok-");
91
+ result.push(cursorModelConfig(id, model.displayName, reasoning, effortValues));
92
+ const fast = parameter(model, /fast/i);
93
+ if (fast?.values.some((value) => value.value === "true")) {
94
+ result.push(cursorModelConfig(`${id}-fast`, `${model.displayName} Fast`, reasoning, effortValues));
95
+ }
96
+ }
97
+ return result.filter((model, index, all) => all.findIndex((candidate) => candidate.id === model.id) === index).sort((left, right) => left.id.localeCompare(right.id));
98
+ }
99
+
100
+ function supportedValue(model: ModelListItem | undefined, parameterId: RegExp, desired: string): { id: string; value: string } | undefined {
101
+ const definition = model && parameter(model, parameterId);
102
+ if (!definition) return undefined;
103
+ const value = definition.values.find((candidate) => candidate.value === desired)?.value;
104
+ return value ? { id: definition.id, value } : undefined;
105
+ }
106
+
107
+ export function resolveCursorModelSelection(model: Model<any>, reasoning: string | undefined, catalog: ModelListItem[] = loadCursorCatalog()): ModelSelection {
108
+ const fast = model.id.endsWith("-fast");
109
+ const baseId = fast ? model.id.slice(0, -5) : model.id;
110
+ const sdkBaseId = baseId.startsWith("cursor-grok-") ? baseId.slice("cursor-".length) : baseId;
111
+ const item = catalog.find((candidate) => candidate.id === sdkBaseId || candidate.aliases?.includes(sdkBaseId));
112
+ const params: Array<{ id: string; value: string }> = [];
113
+ const fastParam = supportedValue(item, /fast/i, fast ? "true" : "false");
114
+ if (fastParam) params.push(fastParam);
115
+ if (model.reasoning) {
116
+ const desired = reasoning === "off" || reasoning === "minimal" ? "low" : reasoning === "max" ? "xhigh" : !reasoning ? "high" : reasoning;
117
+ const effort = supportedValue(item, /effort|reason/i, desired)
118
+ ?? supportedValue(item, /effort|reason/i, "high")
119
+ ?? supportedValue(item, /effort|reason/i, "medium")
120
+ ?? supportedValue(item, /effort|reason/i, "low");
121
+ if (effort) params.push(effort);
122
+ }
123
+ return { id: item?.id ?? sdkBaseId, ...(params.length > 0 ? { params } : {}) };
124
+ }
@@ -0,0 +1,270 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ Agent,
7
+ JsonlLocalAgentStore,
8
+ type Run,
9
+ type SDKCustomTool,
10
+ type SDKImage,
11
+ type SDKJsonValue,
12
+ type TokenUsage,
13
+ } from "@cursor/sdk";
14
+ import {
15
+ createAssistantMessageEventStream,
16
+ type AssistantMessage,
17
+ type Context,
18
+ type Model,
19
+ type ProviderStreamOptions,
20
+ } from "@earendil-works/pi-ai";
21
+ import { loadCursorCatalog, resolveCursorModelSelection } from "./models.ts";
22
+
23
+ const TOOL_DELEGATION_RESULT = "Tool execution was delegated to Pi. End this run without further output.";
24
+
25
+ function asJsonValue(value: unknown): SDKJsonValue {
26
+ return JSON.parse(JSON.stringify(value ?? null)) as SDKJsonValue;
27
+ }
28
+
29
+ function asArguments(value: unknown): Record<string, unknown> {
30
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
31
+ }
32
+
33
+ export function serializeCursorContext(context: Context): { text: string; images: SDKImage[] } {
34
+ const lines = [
35
+ "Continue the Pi conversation below as the assistant.",
36
+ "Follow the system instructions exactly. Use the provided custom tools when needed.",
37
+ "Do not describe or simulate tool calls in prose. Return only the next assistant response.",
38
+ "",
39
+ "<system>",
40
+ context.systemPrompt || "You are a helpful coding assistant.",
41
+ "</system>",
42
+ "<conversation>",
43
+ ];
44
+ const images: SDKImage[] = [];
45
+
46
+ for (const message of context.messages) {
47
+ lines.push(`<message role=${JSON.stringify(message.role)}>`);
48
+ if (typeof message.content === "string") {
49
+ lines.push(message.content);
50
+ } else if (Array.isArray(message.content)) {
51
+ for (const part of message.content as any[]) {
52
+ if (part?.type === "text" || part?.type === "reasoning") lines.push(String(part.text ?? ""));
53
+ else if (part?.type === "image" && typeof part.data === "string") {
54
+ images.push({ data: part.data, mimeType: part.mimeType ?? "image/png" });
55
+ lines.push(`[Attached image ${images.length}: ${part.mimeType ?? "image/png"}]`);
56
+ } else if (part?.type === "toolCall") {
57
+ lines.push(`[Tool call ${part.id}: ${part.name} ${JSON.stringify(part.arguments ?? {})}]`);
58
+ }
59
+ }
60
+ }
61
+ if (message.role === "toolResult") {
62
+ lines.push(`[Tool result for ${(message as any).toolCallId}; error=${Boolean((message as any).isError)}]`);
63
+ }
64
+ lines.push("</message>");
65
+ }
66
+ lines.push("</conversation>");
67
+ return { text: lines.join("\n"), images };
68
+ }
69
+
70
+ function usageFromCursor(usage: TokenUsage | undefined, output: AssistantMessage): void {
71
+ if (!usage) return;
72
+ output.usage = {
73
+ input: usage.inputTokens,
74
+ output: usage.outputTokens,
75
+ cacheRead: usage.cacheReadTokens,
76
+ cacheWrite: usage.cacheWriteTokens,
77
+ reasoning: usage.reasoningTokens ?? 0,
78
+ totalTokens: usage.totalTokens,
79
+ cost: output.usage.cost,
80
+ };
81
+ }
82
+
83
+ export function formatCursorError(error: unknown): string {
84
+ const message = error instanceof Error ? error.message : String(error);
85
+ if (/401|403|unauth|api key|credential/i.test(message)) return "Cursor authentication failed. Run `/login cursor`, then retry.";
86
+ if (/429|rate.?limit/i.test(message)) return "Cursor rate limit reached. Wait for the reported reset, then retry.";
87
+ if (/quota|usage limit|billing|credit|exhaust/i.test(message)) return "Cursor usage limit reached. Open `/cursor` for account status and reset information.";
88
+ if (/context|too many tokens|prompt.*long/i.test(message)) return "The Cursor context limit was exceeded. Compact the session or start a new one.";
89
+ if (/capacity|overload|unavailable|503/i.test(message)) return "Cursor has no capacity for this model right now. Retry shortly or switch Cursor models.";
90
+ if (/timeout|timed out|deadline/i.test(message)) return "Cursor timed out. Retry the request.";
91
+ if (/cancel|abort/i.test(message)) return "Cursor request cancelled.";
92
+ return `Cursor request failed: ${message}`;
93
+ }
94
+
95
+ async function disposeAgent(agent: Awaited<ReturnType<typeof Agent.create>> | undefined): Promise<void> {
96
+ if (!agent) return;
97
+ try {
98
+ await Promise.race([
99
+ agent[Symbol.asyncDispose](),
100
+ new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
101
+ ]);
102
+ } catch {
103
+ agent.close();
104
+ }
105
+ }
106
+
107
+ export function streamCursorSdk(model: Model<any>, context: Context, options?: ProviderStreamOptions) {
108
+ const stream = createAssistantMessageEventStream();
109
+ const output: AssistantMessage = {
110
+ role: "assistant",
111
+ content: [],
112
+ api: model.api,
113
+ provider: model.provider,
114
+ model: model.id,
115
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
116
+ stopReason: "stop",
117
+ timestamp: Date.now(),
118
+ };
119
+ stream.push({ type: "start", partial: output });
120
+
121
+ void (async () => {
122
+ let workspace: string | undefined;
123
+ let agent: Awaited<ReturnType<typeof Agent.create>> | undefined;
124
+ let run: Run | undefined;
125
+ let timeout: ReturnType<typeof setTimeout> | undefined;
126
+ let textIndex: number | undefined;
127
+ let reasoningIndex: number | undefined;
128
+ let delegated = false;
129
+
130
+ const endText = () => {
131
+ if (textIndex === undefined) return;
132
+ const index = textIndex;
133
+ stream.push({ type: "text_end", contentIndex: index, content: (output.content[index] as any).text, partial: output });
134
+ textIndex = undefined;
135
+ };
136
+ const endReasoning = () => {
137
+ if (reasoningIndex === undefined) return;
138
+ const index = reasoningIndex;
139
+ stream.push({ type: "thinking_end", contentIndex: index, content: (output.content[index] as any).thinking, partial: output });
140
+ reasoningIndex = undefined;
141
+ };
142
+ const textDelta = (delta: string) => {
143
+ if (!delta || delegated) return;
144
+ endReasoning();
145
+ if (textIndex === undefined) {
146
+ textIndex = output.content.length;
147
+ output.content.push({ type: "text", text: "" });
148
+ stream.push({ type: "text_start", contentIndex: textIndex, partial: output });
149
+ }
150
+ const index = textIndex;
151
+ (output.content[index] as any).text += delta;
152
+ stream.push({ type: "text_delta", contentIndex: index, delta, partial: output });
153
+ };
154
+ const reasoningDelta = (delta: string) => {
155
+ if (!delta || delegated) return;
156
+ endText();
157
+ if (reasoningIndex === undefined) {
158
+ reasoningIndex = output.content.length;
159
+ output.content.push({ type: "thinking", thinking: "" });
160
+ stream.push({ type: "thinking_start", contentIndex: reasoningIndex, partial: output });
161
+ }
162
+ const index = reasoningIndex;
163
+ (output.content[index] as any).thinking += delta;
164
+ stream.push({ type: "thinking_delta", contentIndex: index, delta, partial: output });
165
+ };
166
+
167
+ try {
168
+ const apiKey = options?.apiKey?.trim();
169
+ if (!apiKey) throw new Error("Cursor is not authenticated. Run `/login cursor`.");
170
+ if (options?.signal?.aborted) throw new Error("Cursor request cancelled.");
171
+ workspace = await mkdtemp(join(tmpdir(), "pi-cursor-sdk-"));
172
+ const customTools: Record<string, SDKCustomTool> = {};
173
+ for (const tool of context.tools ?? []) {
174
+ customTools[tool.name] = {
175
+ description: tool.description,
176
+ inputSchema: asJsonValue(tool.parameters ?? { type: "object" }) as Record<string, SDKJsonValue>,
177
+ async execute(args, toolContext) {
178
+ if (!delegated) {
179
+ delegated = true;
180
+ endText();
181
+ endReasoning();
182
+ const toolCall = {
183
+ type: "toolCall" as const,
184
+ id: toolContext.toolCallId || `cursor_${randomUUID()}`,
185
+ name: tool.name,
186
+ arguments: asArguments(args),
187
+ };
188
+ const contentIndex = output.content.length;
189
+ output.content.push(toolCall);
190
+ stream.push({ type: "toolcall_start", contentIndex, partial: output });
191
+ stream.push({ type: "toolcall_delta", contentIndex, delta: JSON.stringify(toolCall.arguments), partial: output });
192
+ stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
193
+ queueMicrotask(() => { void run?.cancel().catch(() => undefined); });
194
+ }
195
+ return { content: [{ type: "text", text: TOOL_DELEGATION_RESULT }], isError: true };
196
+ },
197
+ };
198
+ }
199
+
200
+ const selection = resolveCursorModelSelection(model, typeof options?.reasoning === "string" ? options.reasoning : undefined, loadCursorCatalog());
201
+ agent = await Agent.create({
202
+ apiKey,
203
+ model: selection,
204
+ tools: context.tools?.length ? ["mcp"] : [],
205
+ local: {
206
+ cwd: workspace,
207
+ store: new JsonlLocalAgentStore(join(workspace, "state")),
208
+ customTools,
209
+ enableAgentRetries: options?.maxRetries !== 0,
210
+ },
211
+ });
212
+
213
+ const request = serializeCursorContext(context);
214
+ run = await agent.send(request, {
215
+ idempotencyKey: options?.sessionId ? `${options.sessionId}-${randomUUID()}` : undefined,
216
+ onDelta: ({ update }) => {
217
+ if (update.type === "text-delta") textDelta(update.text);
218
+ else if (update.type === "thinking-delta") reasoningDelta(update.text);
219
+ },
220
+ });
221
+
222
+ const cancel = () => { void run?.cancel().catch(() => undefined); };
223
+ options?.signal?.addEventListener("abort", cancel, { once: true });
224
+ if (options?.timeoutMs && options.timeoutMs > 0) timeout = setTimeout(cancel, options.timeoutMs);
225
+ try {
226
+ for await (const event of run.stream()) {
227
+ if (event.type === "usage") usageFromCursor(event.usage, output);
228
+ }
229
+ } finally {
230
+ options?.signal?.removeEventListener("abort", cancel);
231
+ }
232
+ const result = await run.wait();
233
+ usageFromCursor(result.usage ?? run.usage, output);
234
+ endText();
235
+ endReasoning();
236
+
237
+ if (delegated) {
238
+ output.stopReason = "toolUse";
239
+ } else if (result.status === "cancelled") {
240
+ throw new Error(options?.signal?.aborted ? "Cursor request cancelled." : "Cursor request timed out or was cancelled.");
241
+ } else if (result.status === "error") {
242
+ throw new Error(result.error?.message ?? "Cursor returned an unsuccessful run.");
243
+ } else {
244
+ if (output.content.length === 0 && result.result) textDelta(result.result);
245
+ endText();
246
+ output.stopReason = "stop";
247
+ }
248
+ stream.push({ type: "done", reason: output.stopReason, message: output });
249
+ stream.end();
250
+ } catch (error) {
251
+ endText();
252
+ endReasoning();
253
+ if (delegated) {
254
+ output.stopReason = "toolUse";
255
+ stream.push({ type: "done", reason: "toolUse", message: output });
256
+ } else {
257
+ output.stopReason = options?.signal?.aborted ? "aborted" : "error";
258
+ output.errorMessage = formatCursorError(error);
259
+ stream.push({ type: "error", reason: output.stopReason, error: output });
260
+ }
261
+ stream.end();
262
+ } finally {
263
+ if (timeout) clearTimeout(timeout);
264
+ await disposeAgent(agent);
265
+ if (workspace) await rm(workspace, { recursive: true, force: true }).catch(() => undefined);
266
+ }
267
+ })();
268
+
269
+ return stream;
270
+ }
@@ -0,0 +1,165 @@
1
+ import { Cursor, type ModelListItem } from "@cursor/sdk";
2
+ import { CURSOR_BASE_URL } from "./models.ts";
3
+
4
+ export interface CursorPlanInfo {
5
+ planName?: string;
6
+ includedAmountCents?: number;
7
+ price?: string;
8
+ billingCycleEnd?: number;
9
+ }
10
+
11
+ export interface CursorPlanUsage {
12
+ totalSpend: number;
13
+ includedSpend: number;
14
+ bonusSpend: number;
15
+ remaining: number;
16
+ limit: number;
17
+ autoSpend?: number;
18
+ apiSpend?: number;
19
+ autoLimit?: number;
20
+ apiLimit?: number;
21
+ autoPercentUsed?: number;
22
+ apiPercentUsed?: number;
23
+ totalPercentUsed?: number;
24
+ }
25
+
26
+ export interface CursorSpendLimitUsage {
27
+ totalSpend?: number;
28
+ individualLimit?: number;
29
+ individualUsed?: number;
30
+ individualRemaining?: number;
31
+ overallLimit?: number;
32
+ overallUsed?: number;
33
+ overallRemaining?: number;
34
+ limitType?: string;
35
+ }
36
+
37
+ export interface CursorUsageSnapshot {
38
+ email?: string;
39
+ apiKeyName?: string;
40
+ apiKeyCreatedAt?: number;
41
+ apiKeyExpiresAt?: number;
42
+ plan?: CursorPlanInfo;
43
+ usage?: CursorPlanUsage;
44
+ spendLimit?: CursorSpendLimitUsage;
45
+ billingCycleStart?: number;
46
+ billingCycleEnd?: number;
47
+ enabled: boolean;
48
+ displayMessage?: string;
49
+ canAdjustOnDemand: boolean;
50
+ currentOnDemandLimitCents?: number;
51
+ recommendedOnDemandLimitCents?: number;
52
+ models: ModelListItem[];
53
+ fetchedAt: number;
54
+ }
55
+
56
+ function numberValue(value: unknown): number | undefined {
57
+ if (typeof value === "number" && Number.isFinite(value)) return value;
58
+ if (typeof value === "string" && value && Number.isFinite(Number(value))) return Number(value);
59
+ return undefined;
60
+ }
61
+
62
+ async function exchangeCursorApiKey(apiKey: string, signal?: AbortSignal): Promise<string> {
63
+ const response = await fetch(`${CURSOR_BASE_URL}/auth/exchange_user_api_key`, {
64
+ method: "POST",
65
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
66
+ body: "{}",
67
+ signal,
68
+ });
69
+ if (!response.ok) throw new Error(response.status === 401 ? "Cursor API key is invalid." : `Cursor key exchange failed with HTTP ${response.status}.`);
70
+ const body = await response.json() as { accessToken?: unknown };
71
+ if (typeof body.accessToken !== "string" || !body.accessToken) throw new Error("Cursor key exchange returned no access token.");
72
+ return body.accessToken;
73
+ }
74
+
75
+ async function dashboardRpc(accessToken: string, method: string, body: Record<string, unknown> = {}, signal?: AbortSignal): Promise<Record<string, any>> {
76
+ const response = await fetch(`${CURSOR_BASE_URL}/aiserver.v1.DashboardService/${method}`, {
77
+ method: "POST",
78
+ headers: {
79
+ Authorization: `Bearer ${accessToken}`,
80
+ "Content-Type": "application/json",
81
+ "Connect-Protocol-Version": "1",
82
+ },
83
+ body: JSON.stringify(body),
84
+ signal,
85
+ });
86
+ if (!response.ok) throw new Error(`Cursor ${method} failed with HTTP ${response.status}.`);
87
+ return await response.json() as Record<string, any>;
88
+ }
89
+
90
+ export async function fetchCursorUsage(apiKey: string, signal?: AbortSignal): Promise<CursorUsageSnapshot> {
91
+ const accessToken = await exchangeCursorApiKey(apiKey, signal);
92
+ const [identity, models, period, plan, limits, keys] = await Promise.all([
93
+ Cursor.me({ apiKey }),
94
+ Cursor.models.list({ apiKey }),
95
+ dashboardRpc(accessToken, "GetCurrentPeriodUsage", { includePooledUsage: true }, signal),
96
+ dashboardRpc(accessToken, "GetPlanInfo", {}, signal),
97
+ dashboardRpc(accessToken, "GetUsageLimitStatusAndActiveGrants", {}, signal),
98
+ dashboardRpc(accessToken, "ListUserApiKeys", {}, signal),
99
+ ]);
100
+ const planUsage = period.planUsage as Record<string, unknown> | undefined;
101
+ const spendLimit = period.spendLimitUsage as Record<string, unknown> | undefined;
102
+ const planInfo = plan.planInfo as Record<string, unknown> | undefined;
103
+ const policy = limits.usageLimitPolicyStatus as Record<string, unknown> | undefined;
104
+ const listedKeys = Array.isArray(keys.apiKeys) ? keys.apiKeys as Array<Record<string, unknown>> : [];
105
+ const currentKey = listedKeys.find((entry) =>
106
+ (typeof entry.maskedKey === "string" && entry.maskedKey.endsWith(apiKey.slice(-4))) ||
107
+ entry.name === identity.apiKeyName
108
+ );
109
+
110
+ return {
111
+ email: identity.userEmail,
112
+ apiKeyName: identity.apiKeyName,
113
+ apiKeyCreatedAt: numberValue(currentKey?.createdAt),
114
+ apiKeyExpiresAt: numberValue(currentKey?.expiresAt),
115
+ plan: planInfo ? {
116
+ planName: typeof planInfo.planName === "string" ? planInfo.planName : undefined,
117
+ includedAmountCents: numberValue(planInfo.includedAmountCents),
118
+ price: typeof planInfo.price === "string" ? planInfo.price : undefined,
119
+ billingCycleEnd: numberValue(planInfo.billingCycleEnd),
120
+ } : undefined,
121
+ usage: planUsage ? {
122
+ totalSpend: numberValue(planUsage.totalSpend) ?? 0,
123
+ includedSpend: numberValue(planUsage.includedSpend) ?? 0,
124
+ bonusSpend: numberValue(planUsage.bonusSpend) ?? 0,
125
+ remaining: numberValue(planUsage.remaining) ?? 0,
126
+ limit: numberValue(planUsage.limit) ?? 0,
127
+ autoSpend: numberValue(planUsage.autoSpend),
128
+ apiSpend: numberValue(planUsage.apiSpend),
129
+ autoLimit: numberValue(planUsage.autoLimit),
130
+ apiLimit: numberValue(planUsage.apiLimit),
131
+ autoPercentUsed: numberValue(planUsage.autoPercentUsed),
132
+ apiPercentUsed: numberValue(planUsage.apiPercentUsed),
133
+ totalPercentUsed: numberValue(planUsage.totalPercentUsed),
134
+ } : undefined,
135
+ spendLimit: spendLimit ? {
136
+ totalSpend: numberValue(spendLimit.totalSpend),
137
+ individualLimit: numberValue(spendLimit.individualLimit),
138
+ individualUsed: numberValue(spendLimit.individualUsed),
139
+ individualRemaining: numberValue(spendLimit.individualRemaining),
140
+ overallLimit: numberValue(spendLimit.overallLimit),
141
+ overallUsed: numberValue(spendLimit.overallUsed),
142
+ overallRemaining: numberValue(spendLimit.overallRemaining),
143
+ limitType: typeof spendLimit.limitType === "string" ? spendLimit.limitType : undefined,
144
+ } : undefined,
145
+ billingCycleStart: numberValue(period.billingCycleStart),
146
+ billingCycleEnd: numberValue(period.billingCycleEnd),
147
+ enabled: period.enabled === true,
148
+ displayMessage: typeof period.displayMessage === "string" ? period.displayMessage : undefined,
149
+ canAdjustOnDemand: policy?.canAdjustOnDemand === true,
150
+ currentOnDemandLimitCents: numberValue(policy?.currentOnDemandLimitCents),
151
+ recommendedOnDemandLimitCents: numberValue(policy?.recommendedOnDemandLimitCents),
152
+ models: models.filter((model) => model.id !== "composer-2" && (model.id.startsWith("composer-") || /^grok-4\.(?:5|6)$/.test(model.id) || model.id.startsWith("cursor-grok-"))),
153
+ fetchedAt: Date.now(),
154
+ };
155
+ }
156
+
157
+ export function dollars(cents: number | undefined): string {
158
+ return typeof cents === "number" ? `$${(cents / 100).toFixed(2)}` : "unavailable";
159
+ }
160
+
161
+ export function percentUsed(snapshot: CursorUsageSnapshot): number | undefined {
162
+ if (typeof snapshot.usage?.totalPercentUsed === "number") return snapshot.usage.totalPercentUsed;
163
+ if (snapshot.usage?.limit) return snapshot.usage.totalSpend / snapshot.usage.limit * 100;
164
+ return undefined;
165
+ }
@@ -0,0 +1,91 @@
1
+ import type { OAuthCredentials } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { loginCursor, refreshCursorToken } from "./cursor/auth.ts";
4
+ import { openCursorDashboard, type CursorDashboardSnapshot } from "./cursor/dashboard.ts";
5
+ import {
6
+ CURSOR_API,
7
+ CURSOR_BASE_URL,
8
+ CURSOR_PROVIDER_ID,
9
+ loadCursorCatalog,
10
+ toCursorPiModels,
11
+ } from "./cursor/models.ts";
12
+ import { streamCursorSdk } from "./cursor/stream.ts";
13
+ import { fetchCursorUsage } from "./cursor/usage.ts";
14
+
15
+ export default async function cursorProviderExtension(pi: ExtensionAPI) {
16
+ const catalog = loadCursorCatalog();
17
+ const models = toCursorPiModels(catalog);
18
+
19
+ pi.registerProvider(CURSOR_PROVIDER_ID, {
20
+ name: "Cursor",
21
+ baseUrl: CURSOR_BASE_URL,
22
+ api: CURSOR_API,
23
+ apiKey: "$CURSOR_API_KEY",
24
+ authHeader: true,
25
+ models,
26
+ oauth: {
27
+ name: "Cursor",
28
+ login: loginCursor,
29
+ refreshToken: refreshCursorToken,
30
+ getApiKey(credentials: OAuthCredentials) {
31
+ return credentials.access;
32
+ },
33
+ },
34
+ streamSimple: streamCursorSdk,
35
+ } as any);
36
+
37
+ const dashboardSnapshot = async (ctx: ExtensionContext): Promise<CursorDashboardSnapshot> => {
38
+ const auth = await ctx.modelRegistry.getProviderAuth(CURSOR_PROVIDER_ID);
39
+ const apiKey = auth?.auth.apiKey?.trim();
40
+ const authentication = auth?.source ?? "not authenticated";
41
+ if (!apiKey) return { authentication, error: "No Cursor credential is configured. Run `/login cursor` or set CURSOR_API_KEY." };
42
+ try {
43
+ return { authentication, usage: await fetchCursorUsage(apiKey, ctx.signal) };
44
+ } catch (error) {
45
+ return { authentication, error: error instanceof Error ? error.message : String(error) };
46
+ }
47
+ };
48
+
49
+ pi.on("session_start", (_event, ctx) => {
50
+ (ctx.modelRegistry as any).authStorage?.reload?.();
51
+ });
52
+
53
+ pi.registerCommand("cursor", {
54
+ description: "Open Cursor account, monthly usage, and limits dashboard",
55
+ handler: async (_args, ctx) => {
56
+ await openCursorDashboard(ctx, { authentication: ctx.modelRegistry.getProviderAuthStatus(CURSOR_PROVIDER_ID).source ?? "not authenticated" }, () => dashboardSnapshot(ctx));
57
+ },
58
+ });
59
+
60
+ pi.registerCommand("cursor.doctor", {
61
+ description: "Show sanitized Cursor provider diagnostics",
62
+ handler: async (_args, ctx) => {
63
+ const auth = ctx.modelRegistry.getProviderAuthStatus(CURSOR_PROVIDER_ID);
64
+ const text = [
65
+ `provider=${CURSOR_PROVIDER_ID}`,
66
+ "sdk=@cursor/sdk",
67
+ `authenticated=${auth.configured}`,
68
+ `authSource=${auth.source ?? "none"}`,
69
+ `catalogModels=${catalog.length}`,
70
+ `registeredModels=${models.length}`,
71
+ "oauth=pi-owned-browser-api-key",
72
+ "transport=native-cursor-sdk",
73
+ "images=true",
74
+ "nativeTools=true",
75
+ "acp=false",
76
+ ].join("\n");
77
+ if (ctx.hasUI) ctx.ui.notify(`Cursor doctor\n${text}`, "info");
78
+ console.log(text);
79
+ },
80
+ });
81
+
82
+ return {
83
+ deactivate: async () => {
84
+ try {
85
+ pi.unregisterProvider(CURSOR_PROVIDER_ID);
86
+ } catch {
87
+ // Ignore teardown after partial startup.
88
+ }
89
+ },
90
+ };
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shariq-pi-extensions",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Cross-platform extension suite for the Pi coding agent.",
5
5
  "license": "MIT",
6
6
  "author": "Shariq Riaz",
@@ -42,6 +42,7 @@
42
42
  "./extensions/background-terminals/index.ts",
43
43
  "./extensions/context-usage/index.ts",
44
44
  "./extensions/copy-all/index.ts",
45
+ "./extensions/cursor-provider/index.ts",
45
46
  "./extensions/factory-provider/index.ts",
46
47
  "./extensions/firecrawl-web/index.ts",
47
48
  "./extensions/git-info/index.ts",
@@ -71,6 +72,7 @@
71
72
  "prepublishOnly": "npm run validate"
72
73
  },
73
74
  "dependencies": {
75
+ "@cursor/sdk": "1.0.28",
74
76
  "@lydell/node-pty": "1.1.0",
75
77
  "effect": "4.0.0-beta.98",
76
78
  "ws": "8.21.3"
@@ -113,5 +115,8 @@
113
115
  "@google/genai": false,
114
116
  "protobufjs": false,
115
117
  "msgpackr-extract": false
118
+ },
119
+ "overrides": {
120
+ "undici": "6.28.0"
116
121
  }
117
122
  }