openusage 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.
- package/bin/openusage +91 -0
- package/package.json +33 -0
- package/plugins/amp/icon.svg +6 -0
- package/plugins/amp/plugin.js +175 -0
- package/plugins/amp/plugin.json +20 -0
- package/plugins/amp/plugin.test.js +365 -0
- package/plugins/antigravity/icon.svg +3 -0
- package/plugins/antigravity/plugin.js +484 -0
- package/plugins/antigravity/plugin.json +17 -0
- package/plugins/antigravity/plugin.test.js +1356 -0
- package/plugins/claude/icon.svg +3 -0
- package/plugins/claude/plugin.js +565 -0
- package/plugins/claude/plugin.json +28 -0
- package/plugins/claude/plugin.test.js +1012 -0
- package/plugins/codex/icon.svg +3 -0
- package/plugins/codex/plugin.js +673 -0
- package/plugins/codex/plugin.json +30 -0
- package/plugins/codex/plugin.test.js +1071 -0
- package/plugins/copilot/icon.svg +3 -0
- package/plugins/copilot/plugin.js +264 -0
- package/plugins/copilot/plugin.json +20 -0
- package/plugins/copilot/plugin.test.js +529 -0
- package/plugins/cursor/icon.svg +3 -0
- package/plugins/cursor/plugin.js +526 -0
- package/plugins/cursor/plugin.json +24 -0
- package/plugins/cursor/plugin.test.js +1168 -0
- package/plugins/factory/icon.svg +1 -0
- package/plugins/factory/plugin.js +407 -0
- package/plugins/factory/plugin.json +19 -0
- package/plugins/factory/plugin.test.js +833 -0
- package/plugins/gemini/icon.svg +4 -0
- package/plugins/gemini/plugin.js +413 -0
- package/plugins/gemini/plugin.json +20 -0
- package/plugins/gemini/plugin.test.js +735 -0
- package/plugins/jetbrains-ai-assistant/icon.svg +3 -0
- package/plugins/jetbrains-ai-assistant/plugin.js +357 -0
- package/plugins/jetbrains-ai-assistant/plugin.json +17 -0
- package/plugins/jetbrains-ai-assistant/plugin.test.js +338 -0
- package/plugins/kimi/icon.svg +3 -0
- package/plugins/kimi/plugin.js +358 -0
- package/plugins/kimi/plugin.json +19 -0
- package/plugins/kimi/plugin.test.js +619 -0
- package/plugins/minimax/icon.svg +4 -0
- package/plugins/minimax/plugin.js +388 -0
- package/plugins/minimax/plugin.json +17 -0
- package/plugins/minimax/plugin.test.js +943 -0
- package/plugins/perplexity/icon.svg +1 -0
- package/plugins/perplexity/plugin.js +378 -0
- package/plugins/perplexity/plugin.json +15 -0
- package/plugins/perplexity/plugin.test.js +602 -0
- package/plugins/windsurf/icon.svg +3 -0
- package/plugins/windsurf/plugin.js +218 -0
- package/plugins/windsurf/plugin.json +16 -0
- package/plugins/windsurf/plugin.test.js +455 -0
- package/plugins/zai/icon.svg +5 -0
- package/plugins/zai/plugin.js +156 -0
- package/plugins/zai/plugin.json +18 -0
- package/plugins/zai/plugin.test.js +396 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
const STATE_DB =
|
|
3
|
+
"~/Library/Application Support/Cursor/User/globalStorage/state.vscdb"
|
|
4
|
+
const KEYCHAIN_ACCESS_TOKEN_SERVICE = "cursor-access-token"
|
|
5
|
+
const KEYCHAIN_REFRESH_TOKEN_SERVICE = "cursor-refresh-token"
|
|
6
|
+
const BASE_URL = "https://api2.cursor.sh"
|
|
7
|
+
const USAGE_URL = BASE_URL + "/aiserver.v1.DashboardService/GetCurrentPeriodUsage"
|
|
8
|
+
const PLAN_URL = BASE_URL + "/aiserver.v1.DashboardService/GetPlanInfo"
|
|
9
|
+
const REFRESH_URL = BASE_URL + "/oauth/token"
|
|
10
|
+
const CREDITS_URL = BASE_URL + "/aiserver.v1.DashboardService/GetCreditGrantsBalance"
|
|
11
|
+
const REST_USAGE_URL = "https://cursor.com/api/usage"
|
|
12
|
+
const CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"
|
|
13
|
+
const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration
|
|
14
|
+
const LOGIN_HINT = "Sign in via Cursor app or run `agent login`."
|
|
15
|
+
|
|
16
|
+
function readStateValue(ctx, key) {
|
|
17
|
+
try {
|
|
18
|
+
const sql =
|
|
19
|
+
"SELECT value FROM ItemTable WHERE key = '" + key + "' LIMIT 1;"
|
|
20
|
+
const json = ctx.host.sqlite.query(STATE_DB, sql)
|
|
21
|
+
const rows = ctx.util.tryParseJson(json)
|
|
22
|
+
if (!Array.isArray(rows)) {
|
|
23
|
+
throw new Error("sqlite returned invalid json")
|
|
24
|
+
}
|
|
25
|
+
if (rows.length > 0 && rows[0].value) {
|
|
26
|
+
return rows[0].value
|
|
27
|
+
}
|
|
28
|
+
} catch (e) {
|
|
29
|
+
ctx.host.log.warn("sqlite read failed for " + key + ": " + String(e))
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeStateValue(ctx, key, value) {
|
|
35
|
+
try {
|
|
36
|
+
// Escape single quotes in value for SQL
|
|
37
|
+
const escaped = String(value).replace(/'/g, "''")
|
|
38
|
+
const sql =
|
|
39
|
+
"INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('" +
|
|
40
|
+
key +
|
|
41
|
+
"', '" +
|
|
42
|
+
escaped +
|
|
43
|
+
"');"
|
|
44
|
+
ctx.host.sqlite.exec(STATE_DB, sql)
|
|
45
|
+
return true
|
|
46
|
+
} catch (e) {
|
|
47
|
+
ctx.host.log.warn("sqlite write failed for " + key + ": " + String(e))
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readKeychainValue(ctx, service) {
|
|
53
|
+
if (!ctx.host.keychain || typeof ctx.host.keychain.readGenericPassword !== "function") {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const value = ctx.host.keychain.readGenericPassword(service)
|
|
58
|
+
if (typeof value !== "string") return null
|
|
59
|
+
const trimmed = value.trim()
|
|
60
|
+
return trimmed || null
|
|
61
|
+
} catch (e) {
|
|
62
|
+
ctx.host.log.info("keychain read failed for " + service + ": " + String(e))
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeKeychainValue(ctx, service, value) {
|
|
68
|
+
if (!ctx.host.keychain || typeof ctx.host.keychain.writeGenericPassword !== "function") {
|
|
69
|
+
ctx.host.log.warn("keychain write unsupported")
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
ctx.host.keychain.writeGenericPassword(service, String(value))
|
|
74
|
+
return true
|
|
75
|
+
} catch (e) {
|
|
76
|
+
ctx.host.log.warn("keychain write failed for " + service + ": " + String(e))
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function loadAuthState(ctx) {
|
|
82
|
+
const sqliteAccessToken = readStateValue(ctx, "cursorAuth/accessToken")
|
|
83
|
+
const sqliteRefreshToken = readStateValue(ctx, "cursorAuth/refreshToken")
|
|
84
|
+
if (sqliteAccessToken || sqliteRefreshToken) {
|
|
85
|
+
return {
|
|
86
|
+
accessToken: sqliteAccessToken,
|
|
87
|
+
refreshToken: sqliteRefreshToken,
|
|
88
|
+
source: "sqlite",
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const keychainAccessToken = readKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE)
|
|
93
|
+
const keychainRefreshToken = readKeychainValue(ctx, KEYCHAIN_REFRESH_TOKEN_SERVICE)
|
|
94
|
+
if (keychainAccessToken || keychainRefreshToken) {
|
|
95
|
+
return {
|
|
96
|
+
accessToken: keychainAccessToken,
|
|
97
|
+
refreshToken: keychainRefreshToken,
|
|
98
|
+
source: "keychain",
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
accessToken: null,
|
|
104
|
+
refreshToken: null,
|
|
105
|
+
source: null,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function persistAccessToken(ctx, source, accessToken) {
|
|
110
|
+
if (source === "keychain") {
|
|
111
|
+
return writeKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE, accessToken)
|
|
112
|
+
}
|
|
113
|
+
return writeStateValue(ctx, "cursorAuth/accessToken", accessToken)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getTokenExpiration(ctx, token) {
|
|
117
|
+
const payload = ctx.jwt.decodePayload(token)
|
|
118
|
+
if (!payload || typeof payload.exp !== "number") return null
|
|
119
|
+
return payload.exp * 1000 // Convert to milliseconds
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function needsRefresh(ctx, accessToken, nowMs) {
|
|
123
|
+
if (!accessToken) return true
|
|
124
|
+
const expiresAt = getTokenExpiration(ctx, accessToken)
|
|
125
|
+
return ctx.util.needsRefreshByExpiry({
|
|
126
|
+
nowMs,
|
|
127
|
+
expiresAtMs: expiresAt,
|
|
128
|
+
bufferMs: REFRESH_BUFFER_MS,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function refreshToken(ctx, refreshTokenValue, source) {
|
|
133
|
+
if (!refreshTokenValue) {
|
|
134
|
+
ctx.host.log.warn("refresh skipped: no refresh token")
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
ctx.host.log.info("attempting token refresh")
|
|
139
|
+
try {
|
|
140
|
+
const resp = ctx.util.request({
|
|
141
|
+
method: "POST",
|
|
142
|
+
url: REFRESH_URL,
|
|
143
|
+
headers: { "Content-Type": "application/json" },
|
|
144
|
+
bodyText: JSON.stringify({
|
|
145
|
+
grant_type: "refresh_token",
|
|
146
|
+
client_id: CLIENT_ID,
|
|
147
|
+
refresh_token: refreshTokenValue,
|
|
148
|
+
}),
|
|
149
|
+
timeoutMs: 15000,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
if (resp.status === 400 || resp.status === 401) {
|
|
153
|
+
let errorInfo = null
|
|
154
|
+
errorInfo = ctx.util.tryParseJson(resp.bodyText)
|
|
155
|
+
const shouldLogout = errorInfo && errorInfo.shouldLogout === true
|
|
156
|
+
ctx.host.log.error("refresh failed: status=" + resp.status + " shouldLogout=" + shouldLogout)
|
|
157
|
+
if (shouldLogout) {
|
|
158
|
+
throw "Session expired. " + LOGIN_HINT
|
|
159
|
+
}
|
|
160
|
+
throw "Token expired. " + LOGIN_HINT
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
164
|
+
ctx.host.log.warn("refresh returned unexpected status: " + resp.status)
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const body = ctx.util.tryParseJson(resp.bodyText)
|
|
169
|
+
if (!body) {
|
|
170
|
+
ctx.host.log.warn("refresh response not valid JSON")
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Check if server wants us to logout
|
|
175
|
+
if (body.shouldLogout === true) {
|
|
176
|
+
ctx.host.log.error("refresh response indicates shouldLogout=true")
|
|
177
|
+
throw "Session expired. " + LOGIN_HINT
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const newAccessToken = body.access_token
|
|
181
|
+
if (!newAccessToken) {
|
|
182
|
+
ctx.host.log.warn("refresh response missing access_token")
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Persist updated access token to source where auth was loaded from.
|
|
187
|
+
persistAccessToken(ctx, source, newAccessToken)
|
|
188
|
+
ctx.host.log.info("refresh succeeded, token persisted")
|
|
189
|
+
|
|
190
|
+
// Note: Cursor refresh returns access_token which is used as both
|
|
191
|
+
// access and refresh token in some flows
|
|
192
|
+
return newAccessToken
|
|
193
|
+
} catch (e) {
|
|
194
|
+
if (typeof e === "string") throw e
|
|
195
|
+
ctx.host.log.error("refresh exception: " + String(e))
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function connectPost(ctx, url, token) {
|
|
201
|
+
return ctx.util.request({
|
|
202
|
+
method: "POST",
|
|
203
|
+
url: url,
|
|
204
|
+
headers: {
|
|
205
|
+
Authorization: "Bearer " + token,
|
|
206
|
+
"Content-Type": "application/json",
|
|
207
|
+
"Connect-Protocol-Version": "1",
|
|
208
|
+
},
|
|
209
|
+
bodyText: "{}",
|
|
210
|
+
timeoutMs: 10000,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildSessionToken(ctx, accessToken) {
|
|
215
|
+
var payload = ctx.jwt.decodePayload(accessToken)
|
|
216
|
+
if (!payload || !payload.sub) return null
|
|
217
|
+
var parts = String(payload.sub).split("|")
|
|
218
|
+
var userId = parts.length > 1 ? parts[1] : parts[0]
|
|
219
|
+
if (!userId) return null
|
|
220
|
+
return { userId: userId, sessionToken: userId + "%3A%3A" + accessToken }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function fetchEnterpriseUsage(ctx, accessToken) {
|
|
224
|
+
var session = buildSessionToken(ctx, accessToken)
|
|
225
|
+
if (!session) {
|
|
226
|
+
ctx.host.log.warn("enterprise: cannot build session token")
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
var resp = ctx.util.request({
|
|
231
|
+
method: "GET",
|
|
232
|
+
url: REST_USAGE_URL + "?user=" + encodeURIComponent(session.userId),
|
|
233
|
+
headers: {
|
|
234
|
+
Cookie: "WorkosCursorSessionToken=" + session.sessionToken,
|
|
235
|
+
},
|
|
236
|
+
timeoutMs: 10000,
|
|
237
|
+
})
|
|
238
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
239
|
+
ctx.host.log.warn("enterprise usage returned status=" + resp.status)
|
|
240
|
+
return null
|
|
241
|
+
}
|
|
242
|
+
return ctx.util.tryParseJson(resp.bodyText)
|
|
243
|
+
} catch (e) {
|
|
244
|
+
ctx.host.log.warn("enterprise usage fetch failed: " + String(e))
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function buildEnterpriseResult(ctx, accessToken, planName, usage) {
|
|
250
|
+
var requestUsage = fetchEnterpriseUsage(ctx, accessToken)
|
|
251
|
+
var lines = []
|
|
252
|
+
|
|
253
|
+
if (requestUsage) {
|
|
254
|
+
var gpt4 = requestUsage["gpt-4"]
|
|
255
|
+
if (gpt4 && typeof gpt4.maxRequestUsage === "number" && gpt4.maxRequestUsage > 0) {
|
|
256
|
+
var used = gpt4.numRequests || 0
|
|
257
|
+
var limit = gpt4.maxRequestUsage
|
|
258
|
+
|
|
259
|
+
var billingPeriodMs = 30 * 24 * 60 * 60 * 1000
|
|
260
|
+
var cycleStart = requestUsage.startOfMonth
|
|
261
|
+
? ctx.util.parseDateMs(requestUsage.startOfMonth)
|
|
262
|
+
: null
|
|
263
|
+
var cycleEndMs = cycleStart ? cycleStart + billingPeriodMs : null
|
|
264
|
+
|
|
265
|
+
lines.push(ctx.line.progress({
|
|
266
|
+
label: "Requests",
|
|
267
|
+
used: used,
|
|
268
|
+
limit: limit,
|
|
269
|
+
format: { kind: "count", suffix: "requests" },
|
|
270
|
+
resetsAt: ctx.util.toIso(cycleEndMs),
|
|
271
|
+
periodDurationMs: billingPeriodMs,
|
|
272
|
+
}))
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (lines.length === 0) {
|
|
277
|
+
ctx.host.log.warn("enterprise: no usage data available")
|
|
278
|
+
throw "Enterprise usage data unavailable. Try again later."
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
var plan = null
|
|
282
|
+
if (planName) {
|
|
283
|
+
var planLabel = ctx.fmt.planLabel(planName)
|
|
284
|
+
if (planLabel) plan = planLabel
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { plan: plan, lines: lines }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function probe(ctx) {
|
|
291
|
+
const authState = loadAuthState(ctx)
|
|
292
|
+
let accessToken = authState.accessToken
|
|
293
|
+
const refreshTokenValue = authState.refreshToken
|
|
294
|
+
const authSource = authState.source
|
|
295
|
+
|
|
296
|
+
if (!accessToken && !refreshTokenValue) {
|
|
297
|
+
ctx.host.log.error("probe failed: no access or refresh token in sqlite/keychain")
|
|
298
|
+
throw "Not logged in. " + LOGIN_HINT
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
ctx.host.log.info("tokens loaded from " + authSource + ": accessToken=" + (accessToken ? "yes" : "no") + " refreshToken=" + (refreshTokenValue ? "yes" : "no"))
|
|
302
|
+
|
|
303
|
+
const nowMs = Date.now()
|
|
304
|
+
|
|
305
|
+
// Proactively refresh if token is expired or about to expire
|
|
306
|
+
if (needsRefresh(ctx, accessToken, nowMs)) {
|
|
307
|
+
ctx.host.log.info("token needs refresh (expired or expiring soon)")
|
|
308
|
+
let refreshed = null
|
|
309
|
+
try {
|
|
310
|
+
refreshed = refreshToken(ctx, refreshTokenValue, authSource)
|
|
311
|
+
} catch (e) {
|
|
312
|
+
// If refresh fails but we have an access token, try it anyway
|
|
313
|
+
ctx.host.log.warn("refresh failed but have access token, will try: " + String(e))
|
|
314
|
+
if (!accessToken) throw e
|
|
315
|
+
}
|
|
316
|
+
if (refreshed) {
|
|
317
|
+
accessToken = refreshed
|
|
318
|
+
} else if (!accessToken) {
|
|
319
|
+
ctx.host.log.error("refresh failed and no access token available")
|
|
320
|
+
throw "Not logged in. " + LOGIN_HINT
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let usageResp
|
|
325
|
+
let didRefresh = false
|
|
326
|
+
try {
|
|
327
|
+
usageResp = ctx.util.retryOnceOnAuth({
|
|
328
|
+
request: (token) => {
|
|
329
|
+
try {
|
|
330
|
+
return connectPost(ctx, USAGE_URL, token || accessToken)
|
|
331
|
+
} catch (e) {
|
|
332
|
+
ctx.host.log.error("usage request exception: " + String(e))
|
|
333
|
+
if (didRefresh) {
|
|
334
|
+
throw "Usage request failed after refresh. Try again."
|
|
335
|
+
}
|
|
336
|
+
throw "Usage request failed. Check your connection."
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
refresh: () => {
|
|
340
|
+
ctx.host.log.info("usage returned 401, attempting refresh")
|
|
341
|
+
didRefresh = true
|
|
342
|
+
const refreshed = refreshToken(ctx, refreshTokenValue, authSource)
|
|
343
|
+
if (refreshed) accessToken = refreshed
|
|
344
|
+
return refreshed
|
|
345
|
+
},
|
|
346
|
+
})
|
|
347
|
+
} catch (e) {
|
|
348
|
+
if (typeof e === "string") throw e
|
|
349
|
+
ctx.host.log.error("usage request failed: " + String(e))
|
|
350
|
+
throw "Usage request failed. Check your connection."
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (ctx.util.isAuthStatus(usageResp.status)) {
|
|
354
|
+
ctx.host.log.error("usage returned auth error after all retries: status=" + usageResp.status)
|
|
355
|
+
throw "Token expired. " + LOGIN_HINT
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (usageResp.status < 200 || usageResp.status >= 300) {
|
|
359
|
+
ctx.host.log.error("usage returned error: status=" + usageResp.status)
|
|
360
|
+
throw "Usage request failed (HTTP " + String(usageResp.status) + "). Try again later."
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
ctx.host.log.info("usage fetch succeeded")
|
|
364
|
+
|
|
365
|
+
const usage = ctx.util.tryParseJson(usageResp.bodyText)
|
|
366
|
+
if (usage === null) {
|
|
367
|
+
throw "Usage response invalid. Try again later."
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Fetch plan info early (needed for Enterprise detection)
|
|
371
|
+
let planName = ""
|
|
372
|
+
try {
|
|
373
|
+
const planResp = connectPost(ctx, PLAN_URL, accessToken)
|
|
374
|
+
if (planResp.status >= 200 && planResp.status < 300) {
|
|
375
|
+
const plan = ctx.util.tryParseJson(planResp.bodyText)
|
|
376
|
+
if (plan && plan.planInfo && plan.planInfo.planName) {
|
|
377
|
+
planName = plan.planInfo.planName
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
} catch (e) {
|
|
381
|
+
ctx.host.log.warn("plan info fetch failed: " + String(e))
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Enterprise accounts return no planUsage from the Connect API.
|
|
385
|
+
// Detect Enterprise and use the REST usage API instead.
|
|
386
|
+
const isEnterprise = !usage.planUsage && planName.toLowerCase() === "enterprise"
|
|
387
|
+
if (isEnterprise) {
|
|
388
|
+
ctx.host.log.info("detected enterprise account, using REST usage API")
|
|
389
|
+
return buildEnterpriseResult(ctx, accessToken, planName, usage)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Team plans may omit `enabled` even with valid plan usage data.
|
|
393
|
+
if (usage.enabled === false || !usage.planUsage) {
|
|
394
|
+
throw "No active Cursor subscription."
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let creditGrants = null
|
|
398
|
+
try {
|
|
399
|
+
const creditsResp = connectPost(ctx, CREDITS_URL, accessToken)
|
|
400
|
+
if (creditsResp.status >= 200 && creditsResp.status < 300) {
|
|
401
|
+
creditGrants = ctx.util.tryParseJson(creditsResp.bodyText)
|
|
402
|
+
}
|
|
403
|
+
} catch (e) {
|
|
404
|
+
ctx.host.log.warn("credit grants fetch failed: " + String(e))
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let plan = null
|
|
408
|
+
if (planName) {
|
|
409
|
+
const planLabel = ctx.fmt.planLabel(planName)
|
|
410
|
+
if (planLabel) {
|
|
411
|
+
plan = planLabel
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const lines = []
|
|
416
|
+
const pu = usage.planUsage
|
|
417
|
+
|
|
418
|
+
// Credits first (if available) - highest priority primary metric
|
|
419
|
+
if (creditGrants && creditGrants.hasCreditGrants === true) {
|
|
420
|
+
const total = parseInt(creditGrants.totalCents, 10)
|
|
421
|
+
const used = parseInt(creditGrants.usedCents, 10)
|
|
422
|
+
if (total > 0 && !isNaN(total) && !isNaN(used)) {
|
|
423
|
+
lines.push(ctx.line.progress({
|
|
424
|
+
label: "Credits",
|
|
425
|
+
used: ctx.fmt.dollars(used),
|
|
426
|
+
limit: ctx.fmt.dollars(total),
|
|
427
|
+
format: { kind: "dollars" },
|
|
428
|
+
}))
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Total usage (always present) - fallback primary metric
|
|
433
|
+
if (typeof pu.limit !== "number") {
|
|
434
|
+
throw "Total usage limit missing from API response."
|
|
435
|
+
}
|
|
436
|
+
const planUsed = typeof pu.totalSpend === "number"
|
|
437
|
+
? pu.totalSpend
|
|
438
|
+
: pu.limit - (pu.remaining ?? 0)
|
|
439
|
+
const computedPercentUsed = pu.limit > 0
|
|
440
|
+
? (planUsed / pu.limit) * 100
|
|
441
|
+
: 0
|
|
442
|
+
const totalUsagePercent = Number.isFinite(pu.totalPercentUsed)
|
|
443
|
+
? pu.totalPercentUsed
|
|
444
|
+
: computedPercentUsed
|
|
445
|
+
|
|
446
|
+
// Calculate billing cycle period duration
|
|
447
|
+
var billingPeriodMs = 30 * 24 * 60 * 60 * 1000 // 30 days default
|
|
448
|
+
var cycleStart = Number(usage.billingCycleStart)
|
|
449
|
+
var cycleEnd = Number(usage.billingCycleEnd)
|
|
450
|
+
if (Number.isFinite(cycleStart) && Number.isFinite(cycleEnd) && cycleEnd > cycleStart) {
|
|
451
|
+
billingPeriodMs = cycleEnd - cycleStart // already in ms
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const su = usage.spendLimitUsage
|
|
455
|
+
const isTeamAccount = (
|
|
456
|
+
(typeof planName === "string" && planName.toLowerCase() === "team") ||
|
|
457
|
+
(su && su.limitType === "team") ||
|
|
458
|
+
(su && typeof su.pooledLimit === "number")
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
if (isTeamAccount) {
|
|
462
|
+
lines.push(ctx.line.progress({
|
|
463
|
+
label: "Total usage",
|
|
464
|
+
used: ctx.fmt.dollars(planUsed),
|
|
465
|
+
limit: ctx.fmt.dollars(pu.limit),
|
|
466
|
+
format: { kind: "dollars" },
|
|
467
|
+
resetsAt: ctx.util.toIso(usage.billingCycleEnd),
|
|
468
|
+
periodDurationMs: billingPeriodMs
|
|
469
|
+
}))
|
|
470
|
+
|
|
471
|
+
if (typeof pu.bonusSpend === "number" && pu.bonusSpend > 0) {
|
|
472
|
+
lines.push(ctx.line.text({ label: "Bonus spend", value: "$" + String(ctx.fmt.dollars(pu.bonusSpend)) }))
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
lines.push(ctx.line.progress({
|
|
476
|
+
label: "Total usage",
|
|
477
|
+
used: totalUsagePercent,
|
|
478
|
+
limit: 100,
|
|
479
|
+
format: { kind: "percent" },
|
|
480
|
+
resetsAt: ctx.util.toIso(usage.billingCycleEnd),
|
|
481
|
+
periodDurationMs: billingPeriodMs
|
|
482
|
+
}))
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (typeof pu.autoPercentUsed === "number" && Number.isFinite(pu.autoPercentUsed)) {
|
|
486
|
+
lines.push(ctx.line.progress({
|
|
487
|
+
label: "Auto usage",
|
|
488
|
+
used: pu.autoPercentUsed,
|
|
489
|
+
limit: 100,
|
|
490
|
+
format: { kind: "percent" },
|
|
491
|
+
resetsAt: ctx.util.toIso(usage.billingCycleEnd),
|
|
492
|
+
periodDurationMs: billingPeriodMs
|
|
493
|
+
}))
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (typeof pu.apiPercentUsed === "number" && Number.isFinite(pu.apiPercentUsed)) {
|
|
497
|
+
lines.push(ctx.line.progress({
|
|
498
|
+
label: "API usage",
|
|
499
|
+
used: pu.apiPercentUsed,
|
|
500
|
+
limit: 100,
|
|
501
|
+
format: { kind: "percent" },
|
|
502
|
+
resetsAt: ctx.util.toIso(usage.billingCycleEnd),
|
|
503
|
+
periodDurationMs: billingPeriodMs
|
|
504
|
+
}))
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// On-demand (if available) - not a primary candidate
|
|
508
|
+
if (su) {
|
|
509
|
+
const limit = su.individualLimit ?? su.pooledLimit ?? 0
|
|
510
|
+
const remaining = su.individualRemaining ?? su.pooledRemaining ?? 0
|
|
511
|
+
if (limit > 0) {
|
|
512
|
+
const used = limit - remaining
|
|
513
|
+
lines.push(ctx.line.progress({
|
|
514
|
+
label: "On-demand",
|
|
515
|
+
used: ctx.fmt.dollars(used),
|
|
516
|
+
limit: ctx.fmt.dollars(limit),
|
|
517
|
+
format: { kind: "dollars" },
|
|
518
|
+
}))
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return { plan: plan, lines: lines }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
globalThis.__openusage_plugin = { id: "cursor", probe }
|
|
526
|
+
})()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "cursor",
|
|
4
|
+
"name": "Cursor",
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"entry": "plugin.js",
|
|
7
|
+
"icon": "icon.svg",
|
|
8
|
+
"brandColor": "#000000",
|
|
9
|
+
"cli": {
|
|
10
|
+
"category": "ide"
|
|
11
|
+
},
|
|
12
|
+
"links": [
|
|
13
|
+
{ "label": "Status", "url": "https://status.cursor.com/" },
|
|
14
|
+
{ "label": "Dashboard", "url": "https://www.cursor.com/dashboard" }
|
|
15
|
+
],
|
|
16
|
+
"lines": [
|
|
17
|
+
{ "type": "progress", "label": "Credits", "scope": "overview", "primaryOrder": 1 },
|
|
18
|
+
{ "type": "progress", "label": "Total usage", "scope": "overview", "primaryOrder": 2 },
|
|
19
|
+
{ "type": "progress", "label": "Requests", "scope": "overview", "primaryOrder": 3 },
|
|
20
|
+
{ "type": "progress", "label": "Auto usage", "scope": "detail" },
|
|
21
|
+
{ "type": "progress", "label": "API usage", "scope": "detail" },
|
|
22
|
+
{ "type": "progress", "label": "On-demand", "scope": "detail" }
|
|
23
|
+
]
|
|
24
|
+
}
|