pi-provider-cursor-ask 0.1.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +87 -0
  4. package/README.zh-CN.md +87 -0
  5. package/UPSTREAM_CHANGELOG.md +368 -0
  6. package/UPSTREAM_SOURCE.md +23 -0
  7. package/dist/index.js +54 -0
  8. package/package.json +97 -0
  9. package/src/auth/cli-credentials.ts +275 -0
  10. package/src/auth/consent.ts +25 -0
  11. package/src/auth/index.ts +23 -0
  12. package/src/auth/oauth.ts +282 -0
  13. package/src/auth/refresh-guard.ts +93 -0
  14. package/src/client/bridge.ts +673 -0
  15. package/src/client/cursor-wire.ts +213 -0
  16. package/src/client/h2-unary.ts +142 -0
  17. package/src/client/index.ts +18 -0
  18. package/src/config/index.ts +69 -0
  19. package/src/diagnostics/diagnostics.ts +116 -0
  20. package/src/diagnostics/index.ts +1 -0
  21. package/src/extension/auth.ts +99 -0
  22. package/src/extension/commands.ts +163 -0
  23. package/src/extension/compaction-guard.ts +86 -0
  24. package/src/extension/debug-hooks.ts +359 -0
  25. package/src/extension/index.ts +8 -0
  26. package/src/extension/provider.ts +277 -0
  27. package/src/extension/quota-adapter.ts +175 -0
  28. package/src/extension/report-dashboard.ts +133 -0
  29. package/src/identity.ts +16 -0
  30. package/src/index.ts +186 -0
  31. package/src/models/ask-catalog.ts +384 -0
  32. package/src/models/catalog.json +1163 -0
  33. package/src/models/cost.ts +126 -0
  34. package/src/models/index.ts +6 -0
  35. package/src/models/limits.ts +36 -0
  36. package/src/models/parameterized.ts +416 -0
  37. package/src/models/processing.ts +313 -0
  38. package/src/proto/agent_pb.ts +14577 -0
  39. package/src/stream/bridge-session.ts +215 -0
  40. package/src/stream/client-transcript.ts +51 -0
  41. package/src/stream/config.ts +5 -0
  42. package/src/stream/context-normalize.ts +308 -0
  43. package/src/stream/context-usage.ts +168 -0
  44. package/src/stream/debug-log.ts +316 -0
  45. package/src/stream/drift.ts +122 -0
  46. package/src/stream/images.ts +201 -0
  47. package/src/stream/index.ts +68 -0
  48. package/src/stream/interaction-query.ts +369 -0
  49. package/src/stream/message-parsing.ts +402 -0
  50. package/src/stream/model-cache.ts +100 -0
  51. package/src/stream/model-discovery.ts +242 -0
  52. package/src/stream/model-routing.ts +100 -0
  53. package/src/stream/native-core.ts +2121 -0
  54. package/src/stream/pi-adapter.ts +414 -0
  55. package/src/stream/protocol.ts +63 -0
  56. package/src/stream/recovery.ts +494 -0
  57. package/src/stream/request-build.ts +668 -0
  58. package/src/stream/root-prompt.ts +184 -0
  59. package/src/stream/run-journal.ts +474 -0
  60. package/src/stream/run-usage.ts +107 -0
  61. package/src/stream/server-messages.ts +777 -0
  62. package/src/stream/session-state.ts +499 -0
  63. package/src/stream/stream-writer.ts +211 -0
  64. package/src/stream/thinking-filter.ts +63 -0
  65. package/src/stream/tool-schema.ts +185 -0
  66. package/src/stream/transport-errors.ts +150 -0
  67. package/src/stream/tuning.ts +250 -0
  68. package/src/stream/types.ts +330 -0
  69. package/src/types/enums.ts +103 -0
  70. package/src/types/index.ts +4 -0
  71. package/src/usage.ts +262 -0
  72. package/src/utils/cache-dir.ts +39 -0
  73. package/src/utils/index.ts +2 -0
  74. package/src/utils/security.ts +68 -0
  75. package/src/utils/util.ts +43 -0
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Shared type surface for the Cursor stream runtime.
3
+ *
4
+ * These types are pure structure with no runtime behaviour, so every stream
5
+ * module can depend on this file without creating import cycles. Anything that
6
+ * used to be declared twice (native-core + recovery) now lives here once.
7
+ */
8
+ import type { Api, AssistantMessage, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
9
+ import type { BridgeHandle } from "../client/bridge.js";
10
+ import type { CursorModelParameter } from "../client/cursor-wire.js";
11
+ import type { McpToolDefinition } from "../proto/agent_pb.js";
12
+ import type { CursorNativeModelRouting } from "./model-routing.js";
13
+
14
+ // ── OpenAI-shaped request surface ──
15
+ //
16
+ // Pi hands the provider an OpenAI-style context. These types describe that
17
+ // intermediate shape before it is translated into Cursor's protobuf request.
18
+
19
+ export interface OpenAIToolCall {
20
+ id: string;
21
+ type: "function";
22
+ function: { name: string; arguments: string };
23
+ }
24
+
25
+ export interface ContentPart {
26
+ type: string;
27
+ text?: string;
28
+ data?: string;
29
+ mimeType?: string;
30
+ image_url?: { url?: string };
31
+ }
32
+
33
+ export interface OpenAIMessage {
34
+ role: "system" | "user" | "assistant" | "tool";
35
+ content: string | null | ContentPart[];
36
+ tool_call_id?: string;
37
+ tool_calls?: OpenAIToolCall[];
38
+ /** Propagated from Pi toolResult.isError into Cursor MCP results. */
39
+ is_error?: boolean;
40
+ /**
41
+ * Set on a historical assistant message whose turn did not run to completion
42
+ * (aborted, errored, truncated). Replayed into the turn as a trailing
43
+ * assistant step — see `interruptedAssistantNotice()` in ./pi-adapter.ts.
44
+ */
45
+ interrupted_notice?: string;
46
+ /** Replayed thinking from a prior assistant turn; see ./pi-adapter.ts. */
47
+ thinking?: string;
48
+ }
49
+
50
+ export interface OpenAIToolDef {
51
+ type: "function";
52
+ function: {
53
+ name: string;
54
+ description?: string;
55
+ parameters?: Record<string, unknown>;
56
+ };
57
+ }
58
+
59
+ export interface CursorToolResultImagePayload {
60
+ toolCallId: string;
61
+ images: Array<{ data: string; mimeType: string }>;
62
+ }
63
+
64
+ export interface ChatCompletionRequest {
65
+ model: string;
66
+ messages: OpenAIMessage[];
67
+ stream?: boolean;
68
+ temperature?: number;
69
+ max_tokens?: number;
70
+ max_completion_tokens?: number;
71
+ tools?: OpenAIToolDef[];
72
+ tool_choice?: unknown;
73
+ reasoning_effort?: string;
74
+ user?: string;
75
+ pi_session_id?: string;
76
+ cursor_model_id?: string;
77
+ cursor_model_parameters?: CursorModelParameter[];
78
+ cursor_tool_result_images?: CursorToolResultImagePayload[];
79
+ cursor_requires_max_mode?: boolean;
80
+ cursor_model_max_mode?: boolean;
81
+ }
82
+
83
+ // ── Parsed conversation history ──
84
+
85
+ export interface ParsedImageContent {
86
+ data: Uint8Array;
87
+ mimeType: string;
88
+ }
89
+
90
+ export interface ParsedToolResult {
91
+ content: string;
92
+ isError: boolean;
93
+ images?: ParsedImageContent[];
94
+ }
95
+
96
+ export interface ParsedAssistantTextStep {
97
+ kind: "assistantText";
98
+ text: string;
99
+ }
100
+
101
+ export interface ParsedThinkingStep {
102
+ kind: "thinking";
103
+ text: string;
104
+ }
105
+
106
+ export interface ParsedToolCallStep {
107
+ kind: "toolCall";
108
+ toolCallId: string;
109
+ toolName: string;
110
+ arguments: Record<string, unknown>;
111
+ result?: ParsedToolResult;
112
+ }
113
+
114
+ export type ParsedTurnStep = ParsedAssistantTextStep | ParsedThinkingStep | ParsedToolCallStep;
115
+
116
+ export interface ParsedTurn {
117
+ userText: string;
118
+ steps: ParsedTurnStep[];
119
+ userImages?: ParsedImageContent[];
120
+ }
121
+
122
+ export interface ToolResultInfo {
123
+ toolCallId: string;
124
+ content: string;
125
+ images?: ParsedImageContent[];
126
+ isError?: boolean;
127
+ }
128
+
129
+ export interface ParsedMessages {
130
+ systemPrompt: string;
131
+ userText: string;
132
+ userImages: ParsedImageContent[];
133
+ turns: ParsedTurn[];
134
+ toolResults: ToolResultInfo[];
135
+ inFlightTurn?: ParsedTurn;
136
+ }
137
+
138
+ // ── Wire request payloads ──
139
+
140
+ export interface CursorRequestPayload {
141
+ contextCheckpoint?: Uint8Array | null;
142
+ requestBytes: Uint8Array;
143
+ requestBody: Uint8Array;
144
+ blobStore: Map<string, Uint8Array>;
145
+ mcpTools: McpToolDefinition[];
146
+ }
147
+
148
+ export interface CursorRequestDebugSummary {
149
+ systemPrompt: string;
150
+ selectedImages: Array<{ byteLength: number; mimeType: string }>;
151
+ }
152
+
153
+ // ── Session / bridge state ──
154
+
155
+ export interface PendingExec {
156
+ execId: string;
157
+ execMsgId: number;
158
+ toolCallId: string;
159
+ toolName: string;
160
+ decodedArgs: string;
161
+ }
162
+
163
+ /**
164
+ * Latest upstream checkpoint for a bridge, held by reference so it survives a tool pause.
165
+ *
166
+ * Each resume re-enters the stream writer with fresh locals, but the bridge — and the frames
167
+ * still arriving on it — outlive that boundary. A checkpoint delivered during a pause would be
168
+ * stranded in the previous round's closure without a shared cell.
169
+ */
170
+ export interface CheckpointRef {
171
+ current: Uint8Array | null;
172
+ /** Last positive observation survives placeholder checkpoints and local tool pauses. */
173
+ contextTokens?: number;
174
+ /** One upstream Run's receipt survives Pi's local tool-response boundaries. */
175
+ usage?: CursorRunUsage;
176
+ }
177
+
178
+ /**
179
+ * Pi's view of the turn in flight, as opposed to the wire history sent upstream.
180
+ * Behaviour lives in ./client-transcript.ts.
181
+ */
182
+ export type ClientTranscript =
183
+ | { kind: "live"; completedTurns: ParsedTurn[] }
184
+ | { kind: "recovered"; completedTurns: ParsedTurn[]; inFlightTurn: ParsedTurn };
185
+
186
+ export interface ActiveBridge {
187
+ bridge: BridgeHandle;
188
+ heartbeatTimer: ReturnType<typeof setInterval>;
189
+ toolTimeoutTimer?: ReturnType<typeof setTimeout>;
190
+ blobStore: Map<string, Uint8Array>;
191
+ mcpTools: McpToolDefinition[];
192
+ pendingExecs: PendingExec[];
193
+ currentTurn: ParsedTurn;
194
+ checkpointRef: CheckpointRef;
195
+ state: StreamState;
196
+ /** Fingerprint of the completed turns this bridge was parked on; guards against key collisions. */
197
+ historyFingerprint: string;
198
+ /** Pi's completed-turn history when the wire current turn is synthetic after recovery. */
199
+ clientTranscript?: ClientTranscript;
200
+ }
201
+
202
+ export interface StoredConversation {
203
+ conversationId: string;
204
+ checkpoint: Uint8Array | null;
205
+ checkpointSource?: "upstream" | "absent";
206
+ checkpointTurnCount?: number;
207
+ checkpointHistoryFingerprint?: string;
208
+ midPausePendingToolCalls?: Array<{ toolCallId: string; toolName: string }>;
209
+ midPauseTurnCount?: number;
210
+ midPauseHistoryFingerprint?: string;
211
+ midPauseRecordedAtMs?: number;
212
+ /** In-memory receipts received after their Pi writer closed; claim on the next same-model reply. */
213
+ unreportedUsage?: CursorRunUsage[];
214
+ /** Hash of the system prompt last published to Cursor for this conversation. */
215
+ systemPromptHash?: string;
216
+ sessionScoped: boolean;
217
+ sessionId?: string;
218
+ blobStore: Map<string, Uint8Array>;
219
+ lastAccessMs: number;
220
+ }
221
+
222
+ /** Per-turn raw billed fields from `turnEnded`; `input` includes cache read and write. */
223
+ export interface CursorBilledUsage {
224
+ input: number;
225
+ output: number;
226
+ cacheRead: number;
227
+ cacheWrite: number;
228
+ }
229
+
230
+ export interface CursorRunUsage {
231
+ modelId?: string;
232
+ rates?: Model<Api>["cost"];
233
+ billedUsage?: CursorBilledUsage;
234
+ missingFields?: Array<keyof CursorBilledUsage>;
235
+ reported?: boolean;
236
+ boundaryLogged?: boolean;
237
+ }
238
+
239
+ export interface StreamState {
240
+ toolCallIndex: number;
241
+ pendingExecs: PendingExec[];
242
+ outputTokens: number;
243
+ /** Latest checkpoint `usedTokens`: a context snapshot, not turnEnded's cumulative bill. */
244
+ totalTokens: number;
245
+ /** Set once Cursor reported `turnEnded`; a connection close after it is a completed turn. */
246
+ turnEnded: boolean;
247
+ billedUsage?: CursorBilledUsage;
248
+ runUsage?: CursorRunUsage;
249
+ }
250
+
251
+ // ── Native streamSimple runtime ──
252
+
253
+ export interface CursorNativeStreamConfig {
254
+ /**
255
+ * Declared as a property rather than a method: the runtime passes this
256
+ * reference on to the idle-retry path, so it must not depend on `this`.
257
+ */
258
+ getAccessToken: (options?: { forceRefresh?: boolean }) => Promise<string>;
259
+ getNoReasoningEffortByModelId?(): Map<string, string>;
260
+ getRawModelRoutingByModelId?(): Map<string, Record<string, CursorNativeModelRouting>>;
261
+ }
262
+
263
+ export type CursorNativeStreamOptions = SimpleStreamOptions & {
264
+ toolChoice?: unknown;
265
+ };
266
+
267
+ export type NativeBlockKind = "text" | "thinking";
268
+
269
+ export interface NativeStreamWriter {
270
+ output: AssistantMessage;
271
+ closed: boolean;
272
+ start(): void;
273
+ contextSnapshot?(tokens: number, checkpoint?: Uint8Array): void;
274
+ contextMode?(
275
+ mode: "history" | "checkpoint" | "live",
276
+ tokens?: number,
277
+ checkpoint?: Uint8Array,
278
+ ): void;
279
+ carryUsage?(usage: CursorRunUsage): void;
280
+ text(delta: string): void;
281
+ thinking(delta: string): void;
282
+ toolCall(exec: PendingExec): void;
283
+ done(reason: "stop" | "length" | "toolUse", state?: StreamState): void;
284
+ error(message: string, reason: "error" | "aborted", state?: StreamState): void;
285
+ }
286
+
287
+ export interface IdleRestartContext {
288
+ /** True when text/thinking was already pushed to the Pi writer. */
289
+ emittedUserVisibleContent: boolean;
290
+ latestCheckpoint: Uint8Array | null;
291
+ blobStore: Map<string, Uint8Array>;
292
+ completedTurns: ParsedTurn[];
293
+ currentTurn: ParsedTurn;
294
+ }
295
+
296
+ export interface StreamIdleRetryController {
297
+ currentAttempt: number;
298
+ maxRetries: number;
299
+ recoverBeforeRetry?: boolean;
300
+ restart(nextAttempt: number, context: IdleRestartContext): boolean;
301
+ }
302
+
303
+ export interface NativeStreamAttemptInput {
304
+ contextCheckpoint?: Uint8Array | null;
305
+ accessToken: string;
306
+ requestBytes: Uint8Array;
307
+ blobStore: Map<string, Uint8Array>;
308
+ mcpTools: McpToolDefinition[];
309
+ model: Model<Api>;
310
+ modelId: string;
311
+ bridgeKey: string;
312
+ convKey: string;
313
+ completedTurns: ParsedTurn[];
314
+ currentTurn: ParsedTurn;
315
+ /** Pi's transcript when `completedTurns`/`currentTurn` are a recovered wire view. */
316
+ clientTranscript?: ClientTranscript;
317
+ writer: NativeStreamWriter;
318
+ options?: CursorNativeStreamOptions;
319
+ requestId?: string;
320
+ maxIdleRetries?: number;
321
+ streamIdleTimeoutMs?: number;
322
+ getAccessToken?: (options?: { forceRefresh?: boolean }) => Promise<string>;
323
+ /** When true, idle timeout tries recovery/rebuild before a blind restart. */
324
+ recoverBeforeRetry?: boolean;
325
+ /** Required for checkpoint-based continuation after transport loss. */
326
+ systemPrompt?: string;
327
+ conversationId?: string;
328
+ maxMode?: boolean;
329
+ cursorModelParameters?: CursorModelParameter[];
330
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Domain enums for the Cursor Ask provider.
3
+ *
4
+ * Strongly-typed TypeScript enums for provider constants, credentials,
5
+ * reasoning levels, transport states, and drift signals.
6
+ */
7
+
8
+ import { CURSOR_ASK_IDENTITY } from "../identity.js";
9
+
10
+ /** Credential origin in the resolution cascade. */
11
+ export enum CredentialSource {
12
+ Env = "env",
13
+ CliKeychain = "cli_keychain",
14
+ CliKeychainRefresh = "cli_keychain_refresh",
15
+ IdeVscdb = "ide_vscdb",
16
+ IdeVscdbRefresh = "ide_vscdb_refresh",
17
+ PiOAuth = "pi_oauth",
18
+ PiOAuthRefresh = "pi_oauth_refresh",
19
+ None = "none",
20
+ }
21
+
22
+ export type CredentialSourceType = `${CredentialSource}`;
23
+
24
+ /** Policy for system credential harvesting (Keychain, state.vscdb, WSL). */
25
+ export enum SystemCredentialPolicy {
26
+ Allow = "allow",
27
+ Deny = "deny",
28
+ }
29
+
30
+ export type SystemCredentialPolicyType = `${SystemCredentialPolicy}`;
31
+
32
+ /** Reasoning effort levels recognized by Pi AI. */
33
+ export enum ThinkingLevel {
34
+ Off = "off",
35
+ Minimal = "minimal",
36
+ Low = "low",
37
+ Medium = "medium",
38
+ High = "high",
39
+ XHigh = "xhigh",
40
+ Max = "max",
41
+ }
42
+
43
+ export type PiThinkingLevel = `${ThinkingLevel}`;
44
+
45
+ /** Stream output block types for native streaming. */
46
+ export enum StreamBlockKind {
47
+ Text = "text",
48
+ Thinking = "thinking",
49
+ }
50
+
51
+ export type NativeBlockKind = `${StreamBlockKind}`;
52
+
53
+ /** Recovery decisions when encountering bridge death or tool continuation. */
54
+ export enum RecoveryKind {
55
+ ActiveBridge = "active_bridge",
56
+ RebuildFullHistory = "rebuild_full_history",
57
+ Skip = "skip",
58
+ Error = "error",
59
+ }
60
+
61
+ export type RecoveryDecisionKind = `${RecoveryKind}`;
62
+
63
+ /** Wire protocol drift signal categories. */
64
+ export enum DriftKind {
65
+ ServerMessage = "server_message",
66
+ InteractionUpdate = "interaction_update",
67
+ KvMessage = "kv_message",
68
+ InteractionQuery = "interaction_query",
69
+ ExecMessage = "exec_message",
70
+ UnknownFields = "unknown_fields",
71
+ }
72
+
73
+ export type DriftKindType = `${DriftKind}`;
74
+
75
+ /** Classified bridge exit and transport failure types. */
76
+ export enum TransportFailureKind {
77
+ ConnectTimeout = "connect_timeout",
78
+ SocketTimeout = "socket_timeout",
79
+ ConnectionReset = "connection_reset",
80
+ Goaway = "goaway",
81
+ BridgeCrash = "bridge_crash",
82
+ UpstreamSilence = "upstream_silence",
83
+ Authentication = "authentication",
84
+ RateLimit = "rate_limit",
85
+ ProtocolDrift = "protocol_drift",
86
+ InvalidRequest = "invalid_request",
87
+ Unknown = "unknown",
88
+ }
89
+
90
+ export type TransportFailureKindType = `${TransportFailureKind}`;
91
+
92
+ /** Connect RPC framing flags. */
93
+ export enum ConnectFlag {
94
+ None = 0,
95
+ EndStream = 0b00000010,
96
+ }
97
+
98
+ /** Provider identifier and API source constants. */
99
+ export const ProviderConstant = {
100
+ ProviderId: CURSOR_ASK_IDENTITY.providerId,
101
+ NativeApi: CURSOR_ASK_IDENTITY.nativeApi,
102
+ Source: CURSOR_ASK_IDENTITY.apiSource,
103
+ } as const;
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Central type definitions and domain enums.
3
+ */
4
+ export * from "./enums.js";
package/src/usage.ts ADDED
@@ -0,0 +1,262 @@
1
+ import { cursorEnv, isRecord } from "./utils/util.js";
2
+
3
+ export interface CursorUsageSummary {
4
+ billingCycleStart?: string;
5
+ billingCycleEnd?: string;
6
+ membershipType?: string;
7
+ limitType?: string;
8
+ isUnlimited?: boolean;
9
+ individualUsage?: {
10
+ plan?: UsageBucket;
11
+ onDemand?: UsageBucket;
12
+ };
13
+ teamUsage?: {
14
+ onDemand?: UsageBucket;
15
+ };
16
+ }
17
+
18
+ function toIsoStringOrUndefined(value: unknown): string | undefined {
19
+ if (typeof value !== "string") return undefined;
20
+ const ms = Number(value);
21
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : undefined;
22
+ }
23
+
24
+ interface UsageBucket {
25
+ enabled?: boolean;
26
+ used?: number | null;
27
+ limit?: number | null;
28
+ remaining?: number | null;
29
+ breakdown?: {
30
+ included?: number | null;
31
+ bonus?: number | null;
32
+ total?: number | null;
33
+ };
34
+ totalPercentUsed?: number | null;
35
+ autoPercentUsed?: number | null;
36
+ apiPercentUsed?: number | null;
37
+ }
38
+
39
+ const USAGE_SUMMARY_URL = "https://cursor.com/api/usage-summary";
40
+
41
+ function asNumberOrNull(value: unknown): number | null | undefined {
42
+ return typeof value === "number" && Number.isFinite(value)
43
+ ? value
44
+ : value === null
45
+ ? null
46
+ : undefined;
47
+ }
48
+
49
+ function parseBucket(value: unknown): UsageBucket | undefined {
50
+ if (!isRecord(value)) return undefined;
51
+ const breakdown = isRecord(value.breakdown)
52
+ ? {
53
+ included: asNumberOrNull(value.breakdown.included),
54
+ bonus: asNumberOrNull(value.breakdown.bonus),
55
+ total: asNumberOrNull(value.breakdown.total),
56
+ }
57
+ : undefined;
58
+ return {
59
+ enabled: typeof value.enabled === "boolean" ? value.enabled : undefined,
60
+ used: asNumberOrNull(value.used),
61
+ limit: asNumberOrNull(value.limit),
62
+ remaining: asNumberOrNull(value.remaining),
63
+ breakdown,
64
+ totalPercentUsed: asNumberOrNull(value.totalPercentUsed),
65
+ autoPercentUsed: asNumberOrNull(value.autoPercentUsed),
66
+ apiPercentUsed: asNumberOrNull(value.apiPercentUsed),
67
+ };
68
+ }
69
+
70
+ export function parseCursorUsageSummary(value: unknown): CursorUsageSummary {
71
+ if (!isRecord(value)) throw new Error("Cursor usage endpoint returned an invalid response");
72
+ return {
73
+ billingCycleStart:
74
+ typeof value.billingCycleStart === "string" ? value.billingCycleStart : undefined,
75
+ billingCycleEnd: typeof value.billingCycleEnd === "string" ? value.billingCycleEnd : undefined,
76
+ membershipType: typeof value.membershipType === "string" ? value.membershipType : undefined,
77
+ limitType: typeof value.limitType === "string" ? value.limitType : undefined,
78
+ isUnlimited: typeof value.isUnlimited === "boolean" ? value.isUnlimited : undefined,
79
+ individualUsage: isRecord(value.individualUsage)
80
+ ? {
81
+ plan: parseBucket(value.individualUsage.plan),
82
+ onDemand: parseBucket(value.individualUsage.onDemand),
83
+ }
84
+ : undefined,
85
+ teamUsage: isRecord(value.teamUsage)
86
+ ? { onDemand: parseBucket(value.teamUsage.onDemand) }
87
+ : undefined,
88
+ };
89
+ }
90
+
91
+ export function parseConnectPeriodUsage(value: unknown): CursorUsageSummary {
92
+ if (!isRecord(value))
93
+ throw new Error("Cursor period usage endpoint returned an invalid response");
94
+
95
+ const billingCycleStart = toIsoStringOrUndefined(value.billingCycleStart);
96
+ const billingCycleEnd = toIsoStringOrUndefined(value.billingCycleEnd);
97
+
98
+ const planUsage = isRecord(value.planUsage) ? value.planUsage : undefined;
99
+ const spendLimitUsage = isRecord(value.spendLimitUsage) ? value.spendLimitUsage : undefined;
100
+
101
+ const limitType =
102
+ typeof spendLimitUsage?.limitType === "string" ? spendLimitUsage.limitType : undefined;
103
+ const totalPercentUsed = asNumberOrNull(planUsage?.totalPercentUsed);
104
+ const autoPercentUsed = asNumberOrNull(planUsage?.autoPercentUsed);
105
+ const apiPercentUsed = asNumberOrNull(planUsage?.apiPercentUsed);
106
+ const includedSpend = asNumberOrNull(planUsage?.includedSpend);
107
+ const limit = asNumberOrNull(planUsage?.limit);
108
+
109
+ // Infer membership type from limitType or displayMessage or fallback to Pro
110
+ let membershipType = "Pro";
111
+ if (limitType === "user") membershipType = "Pro";
112
+ else if (limitType === "team") membershipType = "Team";
113
+ else if (typeof value.membershipType === "string") membershipType = value.membershipType;
114
+
115
+ return {
116
+ billingCycleStart,
117
+ billingCycleEnd,
118
+ membershipType,
119
+ limitType,
120
+ individualUsage: {
121
+ plan: {
122
+ enabled: true,
123
+ used: includedSpend,
124
+ limit,
125
+ remaining:
126
+ limit !== null &&
127
+ limit !== undefined &&
128
+ includedSpend !== null &&
129
+ includedSpend !== undefined
130
+ ? Math.max(0, limit - includedSpend)
131
+ : undefined,
132
+ totalPercentUsed,
133
+ autoPercentUsed,
134
+ apiPercentUsed,
135
+ },
136
+ },
137
+ };
138
+ }
139
+
140
+ export async function getCursorUsageSummary(
141
+ getAccessToken?: () => Promise<string>,
142
+ sessionToken = cursorEnv("USAGE_SESSION_TOKEN"),
143
+ ): Promise<CursorUsageSummary> {
144
+ if (getAccessToken) {
145
+ try {
146
+ const accessToken = await getAccessToken();
147
+ if (accessToken) {
148
+ const response = await fetch(
149
+ "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
150
+ {
151
+ method: "POST",
152
+ headers: {
153
+ Authorization: `Bearer ${accessToken}`,
154
+ "Content-Type": "application/json",
155
+ },
156
+ body: "{}",
157
+ signal: AbortSignal.timeout(10_000),
158
+ },
159
+ );
160
+ if (response.ok) {
161
+ return parseConnectPeriodUsage(await response.json());
162
+ }
163
+ }
164
+ } catch {
165
+ // Connect usage call failed; fall back to session token
166
+ }
167
+ }
168
+
169
+ if (sessionToken) {
170
+ const response = await fetch(USAGE_SUMMARY_URL, {
171
+ headers: { Cookie: `WorkosCursorSessionToken=${sessionToken}` },
172
+ signal: AbortSignal.timeout(10_000),
173
+ });
174
+ if (response.ok) {
175
+ return parseCursorUsageSummary(await response.json());
176
+ }
177
+ }
178
+
179
+ throw new Error(
180
+ "Not logged in to Cursor. Please log in with Cursor CLI ('cursor' / 'agent'), run /login cursor, or set CURSOR_USAGE_SESSION_TOKEN to check usage.",
181
+ );
182
+ }
183
+
184
+ function formatDollars(cents: number | null | undefined): string {
185
+ return cents === null || cents === undefined ? "unlimited" : `$${(cents / 100).toFixed(2)}`;
186
+ }
187
+
188
+ function renderProgressBar(pct: number, width = 20): string {
189
+ const clamped = Math.max(0, Math.min(100, pct));
190
+ const filled = Math.round((clamped / 100) * width);
191
+ const empty = width - filled;
192
+ return "█".repeat(filled) + "░".repeat(empty);
193
+ }
194
+
195
+ function formatResetDate(dateStr: string | undefined): string {
196
+ if (!dateStr) return "";
197
+ const d = new Date(dateStr);
198
+ if (Number.isNaN(d.valueOf())) return "";
199
+ const day = d.getDate();
200
+ const month = d.toLocaleString("en-US", { month: "short" });
201
+ return `Resets ${day} ${month}`;
202
+ }
203
+
204
+ function formatPctLabel(pct: number | null | undefined): string {
205
+ if (pct === null || pct === undefined) return "0% used";
206
+ return `${Math.round(pct)}% used`;
207
+ }
208
+
209
+ function capitalize(str: string): string {
210
+ if (!str) return "Pro";
211
+ return str.charAt(0).toUpperCase() + str.slice(1);
212
+ }
213
+
214
+ export function formatCursorUsage(summary: CursorUsageSummary): string {
215
+ const plan = summary.individualUsage?.plan;
216
+ const onDemand = summary.individualUsage?.onDemand;
217
+ const resetStr = formatResetDate(summary.billingCycleEnd);
218
+
219
+ const planName = capitalize(summary.membershipType || "Pro");
220
+ const headerLeft = `Usage • ${planName}`;
221
+ const totalWidth = 60;
222
+ const headerRight = resetStr
223
+ ? resetStr.padStart(Math.max(1, totalWidth - headerLeft.length))
224
+ : "";
225
+
226
+ const lines = [
227
+ `${headerLeft}${headerRight}`,
228
+ "Monthly plan and on-demand usage",
229
+ "",
230
+ "Category Current Usage",
231
+ ];
232
+
233
+ const totalPct = plan?.totalPercentUsed ?? 0;
234
+ lines.push(
235
+ `Included ${formatPctLabel(totalPct).padEnd(16)}${renderProgressBar(totalPct)}`,
236
+ );
237
+
238
+ if (plan?.autoPercentUsed !== undefined && plan.autoPercentUsed !== null) {
239
+ lines.push(
240
+ ` Auto ${formatPctLabel(plan.autoPercentUsed).padEnd(16)}${renderProgressBar(plan.autoPercentUsed)}`,
241
+ );
242
+ }
243
+
244
+ if (plan?.apiPercentUsed !== undefined && plan.apiPercentUsed !== null) {
245
+ lines.push(
246
+ ` API ${formatPctLabel(plan.apiPercentUsed).padEnd(16)}${renderProgressBar(plan.apiPercentUsed)}`,
247
+ );
248
+ }
249
+
250
+ const isOnDemandActive = Boolean(onDemand?.enabled && (onDemand.used ?? 0) > 0);
251
+ lines.push(`On-Demand ${isOnDemandActive ? formatDollars(onDemand?.used) : "Disabled"}`);
252
+ lines.push("-".repeat(totalWidth));
253
+ lines.push(
254
+ isOnDemandActive
255
+ ? `On-demand spend: ${formatDollars(onDemand?.used)}`
256
+ : "On-demand usage is off",
257
+ );
258
+ lines.push("");
259
+ lines.push("View in dashboard: cursor.com/dashboard?tab=usage");
260
+
261
+ return lines.join("\n");
262
+ }