Adds Cursor as a supported quota provider Reads Cursor auth from env, token files, or local app data Improves Cursor usage labels in the UI
320 lines
9.4 KiB
JavaScript
320 lines
9.4 KiB
JavaScript
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
import { homedir } from 'os';
|
|
import { join } from 'path';
|
|
import { execFileSync } from 'child_process';
|
|
import {
|
|
buildResult,
|
|
formatMoney,
|
|
toNumber,
|
|
toTimestamp,
|
|
toUsageWindow
|
|
} from '../utils/index.js';
|
|
|
|
const BASE_URL = 'https://api2.cursor.sh';
|
|
const USAGE_URL = `${BASE_URL}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`;
|
|
const PLAN_URL = `${BASE_URL}/aiserver.v1.DashboardService/GetPlanInfo`;
|
|
const CREDITS_URL = `${BASE_URL}/aiserver.v1.DashboardService/GetCreditGrantsBalance`;
|
|
const REFRESH_URL = `${BASE_URL}/oauth/token`;
|
|
const CLIENT_ID = 'KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB';
|
|
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
|
const STATE_DB = join(homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
|
|
|
|
export const providerId = 'cursor';
|
|
export const providerName = 'Cursor';
|
|
export const aliases = ['cursor'];
|
|
|
|
const readJwtPayload = (token) => {
|
|
try {
|
|
const [, payload] = String(token).split('.');
|
|
if (!payload) return null;
|
|
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const readStateValue = (key) => {
|
|
if (!existsSync(STATE_DB)) return null;
|
|
try {
|
|
const escapedKey = String(key).replace(/'/g, "''");
|
|
const rows = execFileSync('sqlite3', [
|
|
'-json',
|
|
STATE_DB,
|
|
`SELECT value FROM ItemTable WHERE key = '${escapedKey}' LIMIT 1;`
|
|
], {
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'pipe', 'ignore']
|
|
});
|
|
const parsed = JSON.parse(rows || '[]');
|
|
const value = parsed?.[0]?.value;
|
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const writeStateValue = (key, value) => {
|
|
if (!existsSync(STATE_DB)) return false;
|
|
try {
|
|
const escaped = String(value).replace(/'/g, "''");
|
|
const escapedKey = String(key).replace(/'/g, "''");
|
|
execFileSync('sqlite3', [
|
|
STATE_DB,
|
|
`INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('${escapedKey}', '${escaped}');`
|
|
], {
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'ignore', 'ignore']
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const readFileToken = (path) => {
|
|
try {
|
|
if (!path || !existsSync(path)) return null;
|
|
const content = readFileSync(path, 'utf8').trim();
|
|
return content || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const writeFileToken = (path, value) => {
|
|
try {
|
|
if (!path) return false;
|
|
writeFileSync(path, `${value}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const loadAuthState = () => {
|
|
const envAccessToken = process.env.CURSOR_TOKEN || process.env.CURSOR_ACCESS_TOKEN || null;
|
|
const envRefreshToken = process.env.CURSOR_REFRESH_TOKEN || null;
|
|
const accessTokenPath = process.env.CURSOR_TOKEN_FILE || null;
|
|
const refreshTokenPath = process.env.CURSOR_REFRESH_TOKEN_FILE || null;
|
|
const fileAccessToken = readFileToken(accessTokenPath);
|
|
const fileRefreshToken = readFileToken(refreshTokenPath);
|
|
|
|
if (envAccessToken || envRefreshToken) {
|
|
return {
|
|
accessToken: envAccessToken,
|
|
refreshToken: envRefreshToken,
|
|
source: 'env'
|
|
};
|
|
}
|
|
|
|
if (fileAccessToken || fileRefreshToken) {
|
|
return {
|
|
accessToken: fileAccessToken,
|
|
refreshToken: fileRefreshToken,
|
|
source: 'file',
|
|
accessTokenPath
|
|
};
|
|
}
|
|
|
|
return {
|
|
accessToken: readStateValue('cursorAuth/accessToken'),
|
|
refreshToken: readStateValue('cursorAuth/refreshToken'),
|
|
source: 'sqlite'
|
|
};
|
|
};
|
|
|
|
const tokenNeedsRefresh = (token) => {
|
|
if (!token) return true;
|
|
const payload = readJwtPayload(token);
|
|
const expiresAt = typeof payload?.exp === 'number' ? payload.exp * 1000 : null;
|
|
return !expiresAt || expiresAt - Date.now() <= REFRESH_BUFFER_MS;
|
|
};
|
|
|
|
const persistAccessToken = (auth, accessToken) => {
|
|
if (auth.source === 'sqlite') writeStateValue('cursorAuth/accessToken', accessToken);
|
|
if (auth.source === 'file') writeFileToken(auth.accessTokenPath, accessToken);
|
|
};
|
|
|
|
const refreshAccessToken = async (auth) => {
|
|
if (!auth.refreshToken) return auth.accessToken;
|
|
|
|
const response = await fetch(REFRESH_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
grant_type: 'refresh_token',
|
|
client_id: CLIENT_ID,
|
|
refresh_token: auth.refreshToken
|
|
})
|
|
});
|
|
|
|
const body = await response.json().catch(() => null);
|
|
if (body?.shouldLogout === true) {
|
|
throw new Error('Session expired - please sign in to Cursor again');
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(response.status === 401 ? 'Cursor session expired' : `API error: ${response.status}`);
|
|
}
|
|
if (typeof body?.access_token !== 'string' || !body.access_token) {
|
|
throw new Error('Cursor refresh response did not include an access token');
|
|
}
|
|
|
|
persistAccessToken(auth, body.access_token);
|
|
return body.access_token;
|
|
};
|
|
|
|
const resolveAccessToken = async () => {
|
|
const auth = loadAuthState();
|
|
if (!auth.accessToken && !auth.refreshToken) return null;
|
|
if (!tokenNeedsRefresh(auth.accessToken)) return auth.accessToken;
|
|
return refreshAccessToken(auth);
|
|
};
|
|
|
|
const connectPost = async (url, accessToken) => {
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
'Connect-Protocol-Version': '1'
|
|
},
|
|
body: '{}'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(response.status === 401 ? 'Cursor session expired' : `API error: ${response.status}`);
|
|
}
|
|
|
|
return response.json();
|
|
};
|
|
|
|
const centsLabel = (cents) => {
|
|
const value = toNumber(cents);
|
|
return value === null ? null : `$${formatMoney(value / 100)}`;
|
|
};
|
|
|
|
const percentFromSpend = (planUsage) => {
|
|
const explicit = toNumber(planUsage?.totalPercentUsed);
|
|
if (explicit !== null) return explicit;
|
|
const limit = toNumber(planUsage?.limit);
|
|
const remaining = toNumber(planUsage?.remaining);
|
|
if (!limit || remaining === null) return null;
|
|
return Math.min(100, Math.max(0, ((limit - remaining) / limit) * 100));
|
|
};
|
|
|
|
const buildWindows = (usage, plan) => {
|
|
const planUsage = usage?.planUsage ?? {};
|
|
const spendLimitUsage = usage?.spendLimitUsage ?? {};
|
|
const resetAt = toTimestamp(usage?.billingCycleEnd ?? plan?.planInfo?.billingCycleEnd);
|
|
const windowSeconds = resetAt ? Math.max(0, Math.floor((resetAt - Date.now()) / 1000)) : null;
|
|
const windows = {};
|
|
|
|
windows.billing_cycle = toUsageWindow({
|
|
usedPercent: percentFromSpend(planUsage),
|
|
windowSeconds,
|
|
resetAt,
|
|
valueLabel: centsLabel(planUsage.totalSpend)
|
|
});
|
|
|
|
const autoPercent = toNumber(planUsage.autoPercentUsed);
|
|
if (autoPercent !== null) {
|
|
windows.auto = toUsageWindow({ usedPercent: autoPercent, windowSeconds, resetAt });
|
|
}
|
|
|
|
const apiPercent = toNumber(planUsage.apiPercentUsed);
|
|
if (apiPercent !== null) {
|
|
windows.api = toUsageWindow({ usedPercent: apiPercent, windowSeconds, resetAt });
|
|
}
|
|
|
|
const planLimit = centsLabel(planUsage.limit);
|
|
if (planLimit) {
|
|
windows.plan_limit = toUsageWindow({
|
|
usedPercent: null,
|
|
windowSeconds,
|
|
resetAt,
|
|
valueLabel: `${centsLabel(planUsage.remaining) ?? '$0.00'} remaining of ${planLimit}`
|
|
});
|
|
}
|
|
|
|
const onDemandLimit = toNumber(spendLimitUsage.individualLimit) ?? toNumber(spendLimitUsage.pooledLimit);
|
|
if (onDemandLimit && onDemandLimit > 0) {
|
|
const remaining = toNumber(spendLimitUsage.individualRemaining) ?? toNumber(spendLimitUsage.pooledRemaining) ?? 0;
|
|
windows.on_demand = toUsageWindow({
|
|
usedPercent: Math.min(100, Math.max(0, ((onDemandLimit - remaining) / onDemandLimit) * 100)),
|
|
windowSeconds,
|
|
resetAt,
|
|
valueLabel: `${centsLabel(remaining) ?? '$0.00'} remaining of ${centsLabel(onDemandLimit)}`
|
|
});
|
|
}
|
|
|
|
return windows;
|
|
};
|
|
|
|
const appendCreditsWindow = (windows, credits) => {
|
|
const balance = toNumber(credits?.balanceCents ?? credits?.totalBalanceCents ?? credits?.amountCents);
|
|
if (balance === null) return;
|
|
windows.credits = toUsageWindow({
|
|
usedPercent: null,
|
|
windowSeconds: null,
|
|
resetAt: null,
|
|
valueLabel: centsLabel(balance)
|
|
});
|
|
};
|
|
|
|
export const isConfigured = () => {
|
|
const auth = loadAuthState();
|
|
return Boolean(auth.accessToken || auth.refreshToken);
|
|
};
|
|
|
|
export const fetchQuota = async () => {
|
|
const accessToken = await resolveAccessToken();
|
|
if (!accessToken) {
|
|
return buildResult({
|
|
providerId,
|
|
providerName,
|
|
ok: false,
|
|
configured: false,
|
|
error: 'Not configured'
|
|
});
|
|
}
|
|
|
|
try {
|
|
const [usage, plan, credits] = await Promise.all([
|
|
connectPost(USAGE_URL, accessToken),
|
|
connectPost(PLAN_URL, accessToken).catch(() => null),
|
|
connectPost(CREDITS_URL, accessToken).catch(() => null)
|
|
]);
|
|
|
|
if (usage?.enabled === false || !usage?.planUsage) {
|
|
return buildResult({
|
|
providerId,
|
|
providerName,
|
|
ok: false,
|
|
configured: true,
|
|
error: 'No active Cursor subscription'
|
|
});
|
|
}
|
|
|
|
const windows = buildWindows(usage, plan);
|
|
appendCreditsWindow(windows, credits);
|
|
|
|
return buildResult({
|
|
providerId,
|
|
providerName: plan?.planInfo?.planName ? `Cursor ${plan.planInfo.planName}` : providerName,
|
|
ok: true,
|
|
configured: true,
|
|
usage: { windows }
|
|
});
|
|
} catch (error) {
|
|
return buildResult({
|
|
providerId,
|
|
providerName,
|
|
ok: false,
|
|
configured: true,
|
|
error: error instanceof Error ? error.message : 'Request failed'
|
|
});
|
|
}
|
|
};
|