1import type { ServerProviderUsageWindow } from "@t3tools/contracts";
2import type { CursorSettings } from "../settings.ts";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientRequest from "effect/http/HttpClientRequest";
13import * as HttpClientResponse from "effect/http/HttpClientResponse";
14import {
15 clampPercent,
16 makeUnavailableUsageLimits,
17 makeUsageLimits,
18} from "@t3tools/provider-core/server/usageLimits";
19import * as CursorKeychain from "./CursorKeychain.ts";
20
21const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) });
22const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh";
23const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials));
24const CursorUsageResponse = Schema.Struct({
25 billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
26 planUsage: Schema.optional(
27 Schema.Struct({
28 totalPercentUsed: Schema.optional(Schema.Number),
29 autoPercentUsed: Schema.optional(Schema.Number),
30 apiPercentUsed: Schema.optional(Schema.Number),
31 }),
32 ),
33});
34
35/** Cursor's dashboard percentages include bonus usage; spend / limit does not. */
36export function cursorUsageResponseToLimits(
37 response: typeof CursorUsageResponse.Type,
38 checkedAt: string,
39) {
40 const reset = DateTime.make(Number(response.billingCycleEnd));
41 const resetsAt =
42 Number(response.billingCycleEnd) > 0 && Option.isSome(reset)
43 ? DateTime.formatIso(reset.value)
44 : undefined;
45 const windows: ServerProviderUsageWindow[] = [];
46 if (response.planUsage) {
47 for (const { id, label } of CURSOR_USAGE_WINDOWS) {
48 const usedPercent = response.planUsage[id];
49 if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue;
50 windows.push({
51 id,
52 kind: "monthly",
53 label,
54 usedPercent: clampPercent(usedPercent),
55 ...(resetsAt ? { resetsAt } : {}),
56 });
57 }
58 }
59 return windows.length > 0
60 ? makeUsageLimits({ checkedAt, windows })
61 : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
62}
63
64export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* (
65 settings: Pick<CursorSettings, "apiEndpoint">,
66 environment: NodeJS.ProcessEnv = process.env,
67 allowKeychain = false,
68) {
69 const checkedAt = DateTime.formatIso(yield* DateTime.now);
70 return yield* Effect.gen(function* () {
71 const fs = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;
73 const platform = yield* HostProcess.Platform;
74 const endpoint = (
75 settings.apiEndpoint?.trim() ||
76 environment.CURSOR_API_ENDPOINT?.trim() ||
77 DEFAULT_CURSOR_API_ENDPOINT
78 ).replace(/\/$/, "");
79 let token = environment.CURSOR_AUTH_TOKEN?.trim();
80 // An explicit API key can name a different account from the stored login.
81 if (!token && environment.CURSOR_API_KEY?.trim()) {
82 return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
83 }
84 const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE;
85 if (!token && credentialStore === "memory") {
86 return makeUnavailableUsageLimits({
87 checkedAt,
88 reason: "unsupported",
89 message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.",
90 });
91 }
92 if (!token && platform === "darwin" && credentialStore !== "file") {
93 if (!allowKeychain) {
94 return makeUnavailableUsageLimits({
95 checkedAt,
96 reason: "unsupported",
97 message: "Enable Cursor account usage in T3 Code to read its Keychain login.",
98 });
99 }
100 if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) {
101 return makeUnavailableUsageLimits({
102 checkedAt,
103 reason: "unsupported",
104 message: "Cursor account usage requires the default Cursor endpoint when using Keychain.",
105 });
106 }
107 const keychain = yield* CursorKeychain.CursorKeychain;
108 token = (yield* keychain.accessToken)?.trim();
109 } else if (!token) {
110 const home =
111 (platform === "win32" ? environment.USERPROFILE : environment.HOME) ||
112 (yield* HostProcess.HomeDirectory);
113 const directory =
114 platform === "win32"
115 ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor")
116 : platform === "darwin"
117 ? path.join(home, ".cursor")
118 : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor");
119 const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe(
120 Effect.catchTags({
121 PlatformError: (error) =>
122 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
123 }),
124 Effect.flatMap(decodeCredentials),
125 );
126 token = credentials.accessToken?.trim();
127 }
128 if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
129 const client = yield* HttpClient.HttpClient;
130 const response = yield* client.execute(
131 HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe(
132 HttpClientRequest.bearerToken(token),
133 HttpClientRequest.setHeaders({
134 "connect-protocol-version": "1",
135 "x-cursor-client-type": "cli",
136 }),
137 HttpClientRequest.bodyJsonUnsafe({}),
138 ),
139 );
140 const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)(
141 yield* HttpClientResponse.filterStatusOk(response),
142 );
143 return cursorUsageResponseToLimits(body, checkedAt);
144 }).pipe(
145 Effect.timeout("10 seconds"),
146 Effect.orElseSucceed(() =>
147 makeUnavailableUsageLimits({
148 checkedAt,
149 reason: "probeFailed",
150 message: "Cursor could not read usage limits.",
151 }),
152 ),
153 );
154});