slopmeter 0.4.1 → 0.5.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/dist/cli.js +506 -24
- package/dist/cli.js.map +1 -1
- package/package.json +5 -2
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { mkdirSync, writeFileSync } from "fs";
|
|
5
|
-
import { dirname, extname, resolve as
|
|
5
|
+
import { dirname, extname, resolve as resolve5 } from "path";
|
|
6
6
|
import { parseArgs } from "util";
|
|
7
7
|
import ora from "ora";
|
|
8
8
|
import ow from "ow";
|
|
@@ -5715,6 +5715,35 @@ var heatmapThemes = {
|
|
|
5715
5715
|
]
|
|
5716
5716
|
}
|
|
5717
5717
|
},
|
|
5718
|
+
cursor: {
|
|
5719
|
+
title: "Cursor",
|
|
5720
|
+
colors: {
|
|
5721
|
+
light: [
|
|
5722
|
+
"#fff7ed",
|
|
5723
|
+
// orange-50
|
|
5724
|
+
"#fed7aa",
|
|
5725
|
+
// orange-200
|
|
5726
|
+
"#fdba74",
|
|
5727
|
+
// orange-300
|
|
5728
|
+
"#f97316",
|
|
5729
|
+
// orange-500
|
|
5730
|
+
"#9a3412"
|
|
5731
|
+
// orange-800
|
|
5732
|
+
],
|
|
5733
|
+
dark: [
|
|
5734
|
+
"#431407",
|
|
5735
|
+
// orange-950
|
|
5736
|
+
"#9a3412",
|
|
5737
|
+
// orange-800
|
|
5738
|
+
"#c2410c",
|
|
5739
|
+
// orange-700
|
|
5740
|
+
"#f97316",
|
|
5741
|
+
// orange-500
|
|
5742
|
+
"#fdba74"
|
|
5743
|
+
// orange-300
|
|
5744
|
+
]
|
|
5745
|
+
}
|
|
5746
|
+
},
|
|
5718
5747
|
opencode: {
|
|
5719
5748
|
title: "Open Code",
|
|
5720
5749
|
colors: {
|
|
@@ -5745,7 +5774,7 @@ var heatmapThemes = {
|
|
|
5745
5774
|
}
|
|
5746
5775
|
},
|
|
5747
5776
|
all: {
|
|
5748
|
-
title: "Codex / Claude Code / Open Code",
|
|
5777
|
+
title: "Codex / Claude Code / Cursor / Open Code",
|
|
5749
5778
|
titleCaption: "Total usage from",
|
|
5750
5779
|
colors: {
|
|
5751
5780
|
light: [
|
|
@@ -7007,19 +7036,468 @@ async function loadCodexRows(start, end, warnings = []) {
|
|
|
7007
7036
|
return createUsageSummary("codex", totals, modelTotals, recentModelTotals, end);
|
|
7008
7037
|
}
|
|
7009
7038
|
|
|
7039
|
+
// src/lib/cursor.ts
|
|
7040
|
+
import { existsSync as existsSync2 } from "fs";
|
|
7041
|
+
import { copyFile, mkdtemp, rm } from "fs/promises";
|
|
7042
|
+
import { homedir as homedir3, tmpdir } from "os";
|
|
7043
|
+
import { join as join4, resolve as resolve3 } from "path";
|
|
7044
|
+
import Database from "better-sqlite3";
|
|
7045
|
+
var CURSOR_CONFIG_DIR_ENV = "CURSOR_CONFIG_DIR";
|
|
7046
|
+
var CURSOR_STATE_DB_PATH_ENV = "CURSOR_STATE_DB_PATH";
|
|
7047
|
+
var CURSOR_WEB_BASE_URL_ENV = "CURSOR_WEB_BASE_URL";
|
|
7048
|
+
var CURSOR_STATE_DB_RELATIVE_PATH = join4(
|
|
7049
|
+
"User",
|
|
7050
|
+
"globalStorage",
|
|
7051
|
+
"state.vscdb"
|
|
7052
|
+
);
|
|
7053
|
+
var CURSOR_SESSION_COOKIE_NAME = "WorkosCursorSessionToken";
|
|
7054
|
+
function getCursorDefaultStateDbPath() {
|
|
7055
|
+
if (process.platform === "darwin") {
|
|
7056
|
+
return join4(
|
|
7057
|
+
homedir3(),
|
|
7058
|
+
"Library",
|
|
7059
|
+
"Application Support",
|
|
7060
|
+
"Cursor",
|
|
7061
|
+
CURSOR_STATE_DB_RELATIVE_PATH
|
|
7062
|
+
);
|
|
7063
|
+
}
|
|
7064
|
+
if (process.platform === "win32") {
|
|
7065
|
+
const appData = process.env.APPDATA?.trim() || join4(homedir3(), "AppData", "Roaming");
|
|
7066
|
+
return join4(appData, "Cursor", CURSOR_STATE_DB_RELATIVE_PATH);
|
|
7067
|
+
}
|
|
7068
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() || join4(homedir3(), ".config");
|
|
7069
|
+
return join4(xdgConfigHome, "Cursor", CURSOR_STATE_DB_RELATIVE_PATH);
|
|
7070
|
+
}
|
|
7071
|
+
function getCursorStateDbCandidates() {
|
|
7072
|
+
const explicitDbPath = process.env[CURSOR_STATE_DB_PATH_ENV]?.trim();
|
|
7073
|
+
if (explicitDbPath) {
|
|
7074
|
+
return [resolve3(explicitDbPath)];
|
|
7075
|
+
}
|
|
7076
|
+
const configuredDirs = process.env[CURSOR_CONFIG_DIR_ENV]?.trim();
|
|
7077
|
+
if (!configuredDirs) {
|
|
7078
|
+
return [getCursorDefaultStateDbPath()];
|
|
7079
|
+
}
|
|
7080
|
+
return configuredDirs.split(",").map((value) => value.trim()).filter((value) => value !== "").map((value) => {
|
|
7081
|
+
const resolved = resolve3(value);
|
|
7082
|
+
return resolved.endsWith(".vscdb") ? resolved : join4(resolved, CURSOR_STATE_DB_RELATIVE_PATH);
|
|
7083
|
+
});
|
|
7084
|
+
}
|
|
7085
|
+
function getCursorStateDbPath() {
|
|
7086
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7087
|
+
for (const candidate of getCursorStateDbCandidates()) {
|
|
7088
|
+
if (!seen.has(candidate) && existsSync2(candidate)) {
|
|
7089
|
+
return candidate;
|
|
7090
|
+
}
|
|
7091
|
+
seen.add(candidate);
|
|
7092
|
+
}
|
|
7093
|
+
return null;
|
|
7094
|
+
}
|
|
7095
|
+
function normalizeCursorDbValue(value) {
|
|
7096
|
+
if (typeof value === "string") {
|
|
7097
|
+
const trimmed = value.trim();
|
|
7098
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
7099
|
+
}
|
|
7100
|
+
if (Buffer.isBuffer(value)) {
|
|
7101
|
+
const trimmed = value.toString("utf8").trim();
|
|
7102
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
7103
|
+
}
|
|
7104
|
+
return void 0;
|
|
7105
|
+
}
|
|
7106
|
+
function readCursorAuthStateFromDatabase(databasePath) {
|
|
7107
|
+
const database = new Database(databasePath, {
|
|
7108
|
+
readonly: true,
|
|
7109
|
+
fileMustExist: true
|
|
7110
|
+
});
|
|
7111
|
+
try {
|
|
7112
|
+
const query = database.prepare(
|
|
7113
|
+
"SELECT value FROM ItemTable WHERE key = ? LIMIT 1"
|
|
7114
|
+
);
|
|
7115
|
+
const accessRow = query.get("cursorAuth/accessToken");
|
|
7116
|
+
const refreshRow = query.get("cursorAuth/refreshToken");
|
|
7117
|
+
return {
|
|
7118
|
+
accessToken: normalizeCursorDbValue(accessRow?.value),
|
|
7119
|
+
refreshToken: normalizeCursorDbValue(refreshRow?.value)
|
|
7120
|
+
};
|
|
7121
|
+
} finally {
|
|
7122
|
+
database.close();
|
|
7123
|
+
}
|
|
7124
|
+
}
|
|
7125
|
+
function isSqliteLockedError(error) {
|
|
7126
|
+
return error instanceof Error && /database is locked/i.test(error.message);
|
|
7127
|
+
}
|
|
7128
|
+
async function withCursorStateSnapshot(databasePath, callback) {
|
|
7129
|
+
const snapshotDir = await mkdtemp(join4(tmpdir(), "slopmeter-cursor-"));
|
|
7130
|
+
const snapshotPath = join4(snapshotDir, "state.vscdb");
|
|
7131
|
+
await copyFile(databasePath, snapshotPath);
|
|
7132
|
+
for (const suffix of ["-shm", "-wal"]) {
|
|
7133
|
+
const companionPath = `${databasePath}${suffix}`;
|
|
7134
|
+
if (!existsSync2(companionPath)) {
|
|
7135
|
+
continue;
|
|
7136
|
+
}
|
|
7137
|
+
await copyFile(companionPath, `${snapshotPath}${suffix}`);
|
|
7138
|
+
}
|
|
7139
|
+
try {
|
|
7140
|
+
return await callback(snapshotPath);
|
|
7141
|
+
} finally {
|
|
7142
|
+
await rm(snapshotDir, { recursive: true, force: true });
|
|
7143
|
+
}
|
|
7144
|
+
}
|
|
7145
|
+
async function readCursorAuthState(databasePath) {
|
|
7146
|
+
try {
|
|
7147
|
+
return readCursorAuthStateFromDatabase(databasePath);
|
|
7148
|
+
} catch (error) {
|
|
7149
|
+
if (!isSqliteLockedError(error)) {
|
|
7150
|
+
throw error;
|
|
7151
|
+
}
|
|
7152
|
+
return withCursorStateSnapshot(
|
|
7153
|
+
databasePath,
|
|
7154
|
+
async (snapshotPath) => readCursorAuthStateFromDatabase(snapshotPath)
|
|
7155
|
+
);
|
|
7156
|
+
}
|
|
7157
|
+
}
|
|
7158
|
+
function decodeJwtPayload(token) {
|
|
7159
|
+
const encodedPayload = token.split(".")[1];
|
|
7160
|
+
if (!encodedPayload) {
|
|
7161
|
+
return null;
|
|
7162
|
+
}
|
|
7163
|
+
const base64 = encodedPayload.replace(/-/g, "+").replace(/_/g, "/");
|
|
7164
|
+
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
|
7165
|
+
try {
|
|
7166
|
+
return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
|
|
7167
|
+
} catch {
|
|
7168
|
+
return null;
|
|
7169
|
+
}
|
|
7170
|
+
}
|
|
7171
|
+
function getCursorWebBaseUrl() {
|
|
7172
|
+
return (process.env[CURSOR_WEB_BASE_URL_ENV]?.trim() || "https://cursor.com").replace(/\/+$/, "");
|
|
7173
|
+
}
|
|
7174
|
+
function buildCookieHeaderValue(cookieValue) {
|
|
7175
|
+
return `${CURSOR_SESSION_COOKIE_NAME}=${cookieValue}`;
|
|
7176
|
+
}
|
|
7177
|
+
function getCursorFetchAttempts(accessToken) {
|
|
7178
|
+
const attempts = [];
|
|
7179
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7180
|
+
const subject = decodeJwtPayload(accessToken)?.sub?.trim();
|
|
7181
|
+
const cookieValues = [accessToken];
|
|
7182
|
+
if (subject) {
|
|
7183
|
+
cookieValues.push(`${subject}::${accessToken}`);
|
|
7184
|
+
}
|
|
7185
|
+
const pushAttempt = (label, headers) => {
|
|
7186
|
+
const signature = JSON.stringify({
|
|
7187
|
+
label,
|
|
7188
|
+
headers: Object.entries(headers).sort(
|
|
7189
|
+
([left], [right]) => left.localeCompare(right)
|
|
7190
|
+
)
|
|
7191
|
+
});
|
|
7192
|
+
if (seen.has(signature)) {
|
|
7193
|
+
return;
|
|
7194
|
+
}
|
|
7195
|
+
seen.add(signature);
|
|
7196
|
+
attempts.push({ label, headers });
|
|
7197
|
+
};
|
|
7198
|
+
pushAttempt("bearer", {
|
|
7199
|
+
Authorization: `Bearer ${accessToken}`
|
|
7200
|
+
});
|
|
7201
|
+
for (const cookieValue of cookieValues) {
|
|
7202
|
+
pushAttempt("cookie", {
|
|
7203
|
+
Cookie: buildCookieHeaderValue(cookieValue)
|
|
7204
|
+
});
|
|
7205
|
+
pushAttempt("cookie-encoded", {
|
|
7206
|
+
Cookie: buildCookieHeaderValue(encodeURIComponent(cookieValue))
|
|
7207
|
+
});
|
|
7208
|
+
pushAttempt("bearer+cookie", {
|
|
7209
|
+
Authorization: `Bearer ${accessToken}`,
|
|
7210
|
+
Cookie: buildCookieHeaderValue(cookieValue)
|
|
7211
|
+
});
|
|
7212
|
+
pushAttempt("bearer+cookie-encoded", {
|
|
7213
|
+
Authorization: `Bearer ${accessToken}`,
|
|
7214
|
+
Cookie: buildCookieHeaderValue(encodeURIComponent(cookieValue))
|
|
7215
|
+
});
|
|
7216
|
+
}
|
|
7217
|
+
return attempts;
|
|
7218
|
+
}
|
|
7219
|
+
async function fetchCursorUsageCsv(accessToken) {
|
|
7220
|
+
const url = new URL(
|
|
7221
|
+
"/api/dashboard/export-usage-events-csv?strategy=tokens",
|
|
7222
|
+
getCursorWebBaseUrl()
|
|
7223
|
+
);
|
|
7224
|
+
const failures = [];
|
|
7225
|
+
for (const attempt of getCursorFetchAttempts(accessToken)) {
|
|
7226
|
+
const response = await fetch(url, {
|
|
7227
|
+
headers: {
|
|
7228
|
+
Accept: "text/csv,text/plain;q=0.9,*/*;q=0.8",
|
|
7229
|
+
...attempt.headers
|
|
7230
|
+
}
|
|
7231
|
+
});
|
|
7232
|
+
if (response.ok) {
|
|
7233
|
+
return response;
|
|
7234
|
+
}
|
|
7235
|
+
failures.push({
|
|
7236
|
+
label: attempt.label,
|
|
7237
|
+
status: response.status,
|
|
7238
|
+
statusText: response.statusText,
|
|
7239
|
+
body: (await response.text()).trim().slice(0, 200)
|
|
7240
|
+
});
|
|
7241
|
+
}
|
|
7242
|
+
const summary = failures.map((failure) => {
|
|
7243
|
+
const statusLine = `${failure.label}: ${failure.status} ${failure.statusText}`.trim();
|
|
7244
|
+
return failure.body ? `${statusLine} (${failure.body})` : statusLine;
|
|
7245
|
+
}).join("; ");
|
|
7246
|
+
throw new Error(
|
|
7247
|
+
`Failed to authenticate Cursor usage export with local auth state from ${url.origin}. ${summary}`
|
|
7248
|
+
);
|
|
7249
|
+
}
|
|
7250
|
+
function parseCsvLine(line) {
|
|
7251
|
+
const values = [];
|
|
7252
|
+
let current = "";
|
|
7253
|
+
let inQuotes = false;
|
|
7254
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
7255
|
+
const char = line[index];
|
|
7256
|
+
if (char === '"') {
|
|
7257
|
+
if (inQuotes && line[index + 1] === '"') {
|
|
7258
|
+
current += '"';
|
|
7259
|
+
index += 1;
|
|
7260
|
+
continue;
|
|
7261
|
+
}
|
|
7262
|
+
inQuotes = !inQuotes;
|
|
7263
|
+
continue;
|
|
7264
|
+
}
|
|
7265
|
+
if (char === "," && !inQuotes) {
|
|
7266
|
+
values.push(current);
|
|
7267
|
+
current = "";
|
|
7268
|
+
continue;
|
|
7269
|
+
}
|
|
7270
|
+
current += char;
|
|
7271
|
+
}
|
|
7272
|
+
values.push(current);
|
|
7273
|
+
return values;
|
|
7274
|
+
}
|
|
7275
|
+
function createCursorCsvRow(headers, values) {
|
|
7276
|
+
const row = {};
|
|
7277
|
+
headers.forEach((header, index) => {
|
|
7278
|
+
row[header] = values[index];
|
|
7279
|
+
});
|
|
7280
|
+
return row;
|
|
7281
|
+
}
|
|
7282
|
+
function processCursorCsvLines(lines, onRow) {
|
|
7283
|
+
let headers = null;
|
|
7284
|
+
for (const rawLine of lines) {
|
|
7285
|
+
const line = rawLine.trim();
|
|
7286
|
+
if (line === "") {
|
|
7287
|
+
continue;
|
|
7288
|
+
}
|
|
7289
|
+
const values = parseCsvLine(line);
|
|
7290
|
+
if (!headers) {
|
|
7291
|
+
headers = values;
|
|
7292
|
+
continue;
|
|
7293
|
+
}
|
|
7294
|
+
onRow(createCursorCsvRow(headers, values));
|
|
7295
|
+
}
|
|
7296
|
+
}
|
|
7297
|
+
function processCursorUsageCsvText(content, onRow) {
|
|
7298
|
+
processCursorCsvLines(content.split(/\r?\n/), onRow);
|
|
7299
|
+
}
|
|
7300
|
+
async function processCursorUsageCsvStream(response, onRow) {
|
|
7301
|
+
if (!response.body) {
|
|
7302
|
+
processCursorUsageCsvText(await response.text(), onRow);
|
|
7303
|
+
return;
|
|
7304
|
+
}
|
|
7305
|
+
let headers = null;
|
|
7306
|
+
let currentField = "";
|
|
7307
|
+
let currentRow = [];
|
|
7308
|
+
let inQuotes = false;
|
|
7309
|
+
let pendingQuote = false;
|
|
7310
|
+
let sawCarriageReturn = false;
|
|
7311
|
+
const decoder = new TextDecoder();
|
|
7312
|
+
const emitField = () => {
|
|
7313
|
+
currentRow.push(currentField);
|
|
7314
|
+
currentField = "";
|
|
7315
|
+
};
|
|
7316
|
+
const emitRow = () => {
|
|
7317
|
+
emitField();
|
|
7318
|
+
if (currentRow.every((value) => value.trim() === "")) {
|
|
7319
|
+
currentRow = [];
|
|
7320
|
+
return;
|
|
7321
|
+
}
|
|
7322
|
+
if (!headers) {
|
|
7323
|
+
headers = currentRow;
|
|
7324
|
+
currentRow = [];
|
|
7325
|
+
return;
|
|
7326
|
+
}
|
|
7327
|
+
onRow(createCursorCsvRow(headers, currentRow));
|
|
7328
|
+
currentRow = [];
|
|
7329
|
+
};
|
|
7330
|
+
const processChunk = (chunk) => {
|
|
7331
|
+
for (const char of chunk) {
|
|
7332
|
+
let shouldReprocess = true;
|
|
7333
|
+
while (shouldReprocess) {
|
|
7334
|
+
shouldReprocess = false;
|
|
7335
|
+
if (sawCarriageReturn) {
|
|
7336
|
+
sawCarriageReturn = false;
|
|
7337
|
+
if (char === "\n") {
|
|
7338
|
+
break;
|
|
7339
|
+
}
|
|
7340
|
+
}
|
|
7341
|
+
if (pendingQuote) {
|
|
7342
|
+
pendingQuote = false;
|
|
7343
|
+
if (char === '"') {
|
|
7344
|
+
currentField += '"';
|
|
7345
|
+
break;
|
|
7346
|
+
}
|
|
7347
|
+
inQuotes = false;
|
|
7348
|
+
shouldReprocess = true;
|
|
7349
|
+
continue;
|
|
7350
|
+
}
|
|
7351
|
+
if (inQuotes) {
|
|
7352
|
+
if (char === '"') {
|
|
7353
|
+
pendingQuote = true;
|
|
7354
|
+
} else {
|
|
7355
|
+
currentField += char;
|
|
7356
|
+
}
|
|
7357
|
+
break;
|
|
7358
|
+
}
|
|
7359
|
+
if (char === '"') {
|
|
7360
|
+
inQuotes = true;
|
|
7361
|
+
break;
|
|
7362
|
+
}
|
|
7363
|
+
if (char === ",") {
|
|
7364
|
+
emitField();
|
|
7365
|
+
break;
|
|
7366
|
+
}
|
|
7367
|
+
if (char === "\n") {
|
|
7368
|
+
emitRow();
|
|
7369
|
+
break;
|
|
7370
|
+
}
|
|
7371
|
+
if (char === "\r") {
|
|
7372
|
+
emitRow();
|
|
7373
|
+
sawCarriageReturn = true;
|
|
7374
|
+
break;
|
|
7375
|
+
}
|
|
7376
|
+
currentField += char;
|
|
7377
|
+
break;
|
|
7378
|
+
}
|
|
7379
|
+
}
|
|
7380
|
+
};
|
|
7381
|
+
const reader = response.body.getReader();
|
|
7382
|
+
try {
|
|
7383
|
+
for (; ; ) {
|
|
7384
|
+
const { done, value } = await reader.read();
|
|
7385
|
+
if (done) {
|
|
7386
|
+
break;
|
|
7387
|
+
}
|
|
7388
|
+
processChunk(decoder.decode(value, { stream: true }));
|
|
7389
|
+
}
|
|
7390
|
+
} finally {
|
|
7391
|
+
reader.releaseLock();
|
|
7392
|
+
}
|
|
7393
|
+
processChunk(decoder.decode());
|
|
7394
|
+
if (pendingQuote) {
|
|
7395
|
+
pendingQuote = false;
|
|
7396
|
+
inQuotes = false;
|
|
7397
|
+
}
|
|
7398
|
+
if (currentField !== "" || currentRow.length > 0) {
|
|
7399
|
+
emitRow();
|
|
7400
|
+
}
|
|
7401
|
+
}
|
|
7402
|
+
function addCursorUsageRow(row, start, end, recentStart, totals, modelTotals, recentModelTotals) {
|
|
7403
|
+
const date = parseCursorDate(row.Date);
|
|
7404
|
+
const rawModel = row.Model?.trim();
|
|
7405
|
+
const tokenTotals = createCursorTokenTotals(row);
|
|
7406
|
+
if (!date || !rawModel || !tokenTotals) {
|
|
7407
|
+
return;
|
|
7408
|
+
}
|
|
7409
|
+
if (date < start || date > end) {
|
|
7410
|
+
return;
|
|
7411
|
+
}
|
|
7412
|
+
const modelName = normalizeModelName(rawModel);
|
|
7413
|
+
addDailyTokenTotals(totals, date, tokenTotals, modelName);
|
|
7414
|
+
addModelTokenTotals(modelTotals, modelName, tokenTotals);
|
|
7415
|
+
if (date >= recentStart) {
|
|
7416
|
+
addModelTokenTotals(recentModelTotals, modelName, tokenTotals);
|
|
7417
|
+
}
|
|
7418
|
+
}
|
|
7419
|
+
function parseCursorDate(value) {
|
|
7420
|
+
const trimmed = value?.trim();
|
|
7421
|
+
if (!trimmed) {
|
|
7422
|
+
return null;
|
|
7423
|
+
}
|
|
7424
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
|
7425
|
+
return /* @__PURE__ */ new Date(`${trimmed}T00:00:00`);
|
|
7426
|
+
}
|
|
7427
|
+
const parsed = new Date(trimmed);
|
|
7428
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
7429
|
+
}
|
|
7430
|
+
function parseCursorNumber(value) {
|
|
7431
|
+
const numeric = Number(value?.replaceAll(",", "").trim() ?? "");
|
|
7432
|
+
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
7433
|
+
return null;
|
|
7434
|
+
}
|
|
7435
|
+
return Math.round(numeric);
|
|
7436
|
+
}
|
|
7437
|
+
function createCursorTokenTotals(row) {
|
|
7438
|
+
const total = parseCursorNumber(row["Total Tokens"]) ?? parseCursorNumber(row.Tokens);
|
|
7439
|
+
if (!total) {
|
|
7440
|
+
return null;
|
|
7441
|
+
}
|
|
7442
|
+
const inputWithCacheWrite = parseCursorNumber(row["Input (w/ Cache Write)"]) ?? 0;
|
|
7443
|
+
const inputWithoutCacheWrite = parseCursorNumber(row["Input (w/o Cache Write)"]) ?? 0;
|
|
7444
|
+
const cacheInput = parseCursorNumber(row["Cache Read"]) ?? 0;
|
|
7445
|
+
const outputTokens = parseCursorNumber(row["Output Tokens"]) ?? 0;
|
|
7446
|
+
return {
|
|
7447
|
+
input: inputWithCacheWrite + inputWithoutCacheWrite + cacheInput,
|
|
7448
|
+
output: outputTokens,
|
|
7449
|
+
cache: { input: cacheInput, output: inputWithCacheWrite },
|
|
7450
|
+
total
|
|
7451
|
+
};
|
|
7452
|
+
}
|
|
7453
|
+
async function loadCursorRows(start, end) {
|
|
7454
|
+
const databasePath = getCursorStateDbPath();
|
|
7455
|
+
const totals = /* @__PURE__ */ new Map();
|
|
7456
|
+
const modelTotals = /* @__PURE__ */ new Map();
|
|
7457
|
+
const recentModelTotals = /* @__PURE__ */ new Map();
|
|
7458
|
+
if (!databasePath) {
|
|
7459
|
+
return createUsageSummary("cursor", totals, modelTotals, recentModelTotals, end);
|
|
7460
|
+
}
|
|
7461
|
+
const authState = await readCursorAuthState(databasePath);
|
|
7462
|
+
if (!authState.accessToken) {
|
|
7463
|
+
return createUsageSummary("cursor", totals, modelTotals, recentModelTotals, end);
|
|
7464
|
+
}
|
|
7465
|
+
const recentStart = getRecentWindowStart(end, 30);
|
|
7466
|
+
const response = await fetchCursorUsageCsv(authState.accessToken);
|
|
7467
|
+
return summarizeCursorUsageCsv(response, start, end, recentStart);
|
|
7468
|
+
}
|
|
7469
|
+
async function summarizeCursorUsageCsv(response, start, end, recentStart = getRecentWindowStart(end, 30)) {
|
|
7470
|
+
const totals = /* @__PURE__ */ new Map();
|
|
7471
|
+
const modelTotals = /* @__PURE__ */ new Map();
|
|
7472
|
+
const recentModelTotals = /* @__PURE__ */ new Map();
|
|
7473
|
+
await processCursorUsageCsvStream(response, (row) => {
|
|
7474
|
+
addCursorUsageRow(
|
|
7475
|
+
row,
|
|
7476
|
+
start,
|
|
7477
|
+
end,
|
|
7478
|
+
recentStart,
|
|
7479
|
+
totals,
|
|
7480
|
+
modelTotals,
|
|
7481
|
+
recentModelTotals
|
|
7482
|
+
);
|
|
7483
|
+
});
|
|
7484
|
+
return createUsageSummary("cursor", totals, modelTotals, recentModelTotals, end);
|
|
7485
|
+
}
|
|
7486
|
+
|
|
7010
7487
|
// src/lib/interfaces.ts
|
|
7011
|
-
var providerIds = ["claude", "codex", "opencode"];
|
|
7488
|
+
var providerIds = ["claude", "codex", "cursor", "opencode"];
|
|
7012
7489
|
var providerStatusLabel = {
|
|
7013
7490
|
claude: "Claude code",
|
|
7014
7491
|
codex: "Codex",
|
|
7492
|
+
cursor: "Cursor",
|
|
7015
7493
|
opencode: "Open Code"
|
|
7016
7494
|
};
|
|
7017
7495
|
|
|
7018
7496
|
// src/lib/open-code.ts
|
|
7019
|
-
import { existsSync as
|
|
7020
|
-
import { copyFile, mkdtemp, rm } from "fs/promises";
|
|
7021
|
-
import { homedir as
|
|
7022
|
-
import { join as
|
|
7497
|
+
import { existsSync as existsSync3 } from "fs";
|
|
7498
|
+
import { copyFile as copyFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
|
|
7499
|
+
import { homedir as homedir4, tmpdir as tmpdir2 } from "os";
|
|
7500
|
+
import { join as join5, resolve as resolve4 } from "path";
|
|
7023
7501
|
function sumOpenCodeTokens(tokens) {
|
|
7024
7502
|
const cacheInput = tokens?.cache?.read ?? 0;
|
|
7025
7503
|
const cacheOutput = tokens?.cache?.write ?? 0;
|
|
@@ -7036,16 +7514,16 @@ async function parseOpenCodeFile(filePath) {
|
|
|
7036
7514
|
return readJsonDocument(filePath);
|
|
7037
7515
|
}
|
|
7038
7516
|
function getOpenCodeBaseDir() {
|
|
7039
|
-
const baseDir = process.env.OPENCODE_DATA_DIR?.trim() ?
|
|
7517
|
+
const baseDir = process.env.OPENCODE_DATA_DIR?.trim() ? resolve4(process.env.OPENCODE_DATA_DIR) : join5(homedir4(), ".local", "share", "opencode");
|
|
7040
7518
|
return baseDir;
|
|
7041
7519
|
}
|
|
7042
7520
|
async function getOpenCodeSource() {
|
|
7043
7521
|
const baseDir = getOpenCodeBaseDir();
|
|
7044
|
-
const databasePath =
|
|
7045
|
-
if (
|
|
7522
|
+
const databasePath = join5(baseDir, "opencode.db");
|
|
7523
|
+
if (existsSync3(databasePath)) {
|
|
7046
7524
|
return { kind: "database", path: databasePath };
|
|
7047
7525
|
}
|
|
7048
|
-
const messagesDir =
|
|
7526
|
+
const messagesDir = join5(baseDir, "storage", "message");
|
|
7049
7527
|
return { kind: "legacy", files: await listFilesRecursive(messagesDir, ".json") };
|
|
7050
7528
|
}
|
|
7051
7529
|
async function loadSqliteModule() {
|
|
@@ -7084,24 +7562,24 @@ function parseOpenCodeMessageData(rowId, sourceLabel, content) {
|
|
|
7084
7562
|
id: message.id || rowId
|
|
7085
7563
|
};
|
|
7086
7564
|
}
|
|
7087
|
-
function
|
|
7565
|
+
function isSqliteLockedError2(error) {
|
|
7088
7566
|
return error instanceof Error && /database is locked/i.test(error.message);
|
|
7089
7567
|
}
|
|
7090
7568
|
async function withDatabaseSnapshot(databasePath, callback) {
|
|
7091
|
-
const snapshotDir = await
|
|
7092
|
-
const snapshotPath =
|
|
7093
|
-
await
|
|
7569
|
+
const snapshotDir = await mkdtemp2(join5(tmpdir2(), "slopmeter-opencode-"));
|
|
7570
|
+
const snapshotPath = join5(snapshotDir, "opencode.db");
|
|
7571
|
+
await copyFile2(databasePath, snapshotPath);
|
|
7094
7572
|
for (const suffix of ["-shm", "-wal"]) {
|
|
7095
7573
|
const companionPath = `${databasePath}${suffix}`;
|
|
7096
|
-
if (!
|
|
7574
|
+
if (!existsSync3(companionPath)) {
|
|
7097
7575
|
continue;
|
|
7098
7576
|
}
|
|
7099
|
-
await
|
|
7577
|
+
await copyFile2(companionPath, `${snapshotPath}${suffix}`);
|
|
7100
7578
|
}
|
|
7101
7579
|
try {
|
|
7102
7580
|
return await callback(snapshotPath);
|
|
7103
7581
|
} finally {
|
|
7104
|
-
await
|
|
7582
|
+
await rm2(snapshotDir, { recursive: true, force: true });
|
|
7105
7583
|
}
|
|
7106
7584
|
}
|
|
7107
7585
|
async function iterateOpenCodeDatabaseMessages(databasePath, onMessage) {
|
|
@@ -7130,7 +7608,7 @@ async function loadOpenCodeDatabaseMessages(databasePath, onMessage) {
|
|
|
7130
7608
|
try {
|
|
7131
7609
|
await iterateOpenCodeDatabaseMessages(databasePath, onMessage);
|
|
7132
7610
|
} catch (error) {
|
|
7133
|
-
if (!
|
|
7611
|
+
if (!isSqliteLockedError2(error)) {
|
|
7134
7612
|
throw error;
|
|
7135
7613
|
}
|
|
7136
7614
|
await withDatabaseSnapshot(databasePath, async (snapshotPath) => {
|
|
@@ -7219,11 +7697,12 @@ async function aggregateUsage({
|
|
|
7219
7697
|
const rowsByProvider = {
|
|
7220
7698
|
claude: null,
|
|
7221
7699
|
codex: null,
|
|
7700
|
+
cursor: null,
|
|
7222
7701
|
opencode: null
|
|
7223
7702
|
};
|
|
7224
7703
|
const warnings = [];
|
|
7225
7704
|
for (const provider of providersToLoad) {
|
|
7226
|
-
const summary = provider === "claude" ? await loadClaudeRows(start, end) : provider === "codex" ? await loadCodexRows(start, end, warnings) : await loadOpenCodeRows(start, end);
|
|
7705
|
+
const summary = provider === "claude" ? await loadClaudeRows(start, end) : provider === "codex" ? await loadCodexRows(start, end, warnings) : provider === "cursor" ? await loadCursorRows(start, end) : await loadOpenCodeRows(start, end);
|
|
7227
7706
|
rowsByProvider[provider] = hasUsage(summary) ? summary : null;
|
|
7228
7707
|
}
|
|
7229
7708
|
return { rowsByProvider, warnings };
|
|
@@ -7239,12 +7718,13 @@ var HELP_TEXT = `slopmeter
|
|
|
7239
7718
|
Generate rolling 1-year usage heatmap image(s) (today is the latest day).
|
|
7240
7719
|
|
|
7241
7720
|
Usage:
|
|
7242
|
-
slopmeter [--all] [--claude] [--codex] [--opencode] [--dark] [--format png|svg|json] [--output ./heatmap-last-year.png]
|
|
7721
|
+
slopmeter [--all] [--claude] [--codex] [--cursor] [--opencode] [--dark] [--format png|svg|json] [--output ./heatmap-last-year.png]
|
|
7243
7722
|
|
|
7244
7723
|
Options:
|
|
7245
7724
|
--all Render one merged graph for all providers
|
|
7246
7725
|
--claude Render Claude Code graph
|
|
7247
7726
|
--codex Render Codex graph
|
|
7727
|
+
--cursor Render Cursor graph
|
|
7248
7728
|
--opencode Render Open Code graph
|
|
7249
7729
|
--dark Render with the dark theme
|
|
7250
7730
|
-f, --format Output format: png, svg, or json (default: png)
|
|
@@ -7265,6 +7745,7 @@ function validateArgs(values) {
|
|
|
7265
7745
|
all: ow.boolean,
|
|
7266
7746
|
claude: ow.boolean,
|
|
7267
7747
|
codex: ow.boolean,
|
|
7748
|
+
cursor: ow.boolean,
|
|
7268
7749
|
opencode: ow.boolean
|
|
7269
7750
|
})
|
|
7270
7751
|
);
|
|
@@ -7337,7 +7818,7 @@ function getOutputProviders(values, rowsByProvider, end) {
|
|
|
7337
7818
|
const merged = mergeProviderUsage(rowsByProvider, end);
|
|
7338
7819
|
if (!merged) {
|
|
7339
7820
|
throw new Error(
|
|
7340
|
-
"No usage data found for Claude code, Codex, or Open code."
|
|
7821
|
+
"No usage data found for Claude code, Codex, Cursor, or Open code."
|
|
7341
7822
|
);
|
|
7342
7823
|
}
|
|
7343
7824
|
return [merged];
|
|
@@ -7355,7 +7836,7 @@ function selectProvidersToRender(rowsByProvider, requested) {
|
|
|
7355
7836
|
}
|
|
7356
7837
|
if (providersToRender.length === 0) {
|
|
7357
7838
|
throw new Error(
|
|
7358
|
-
"No usage data found for Claude code, Codex, or Open code."
|
|
7839
|
+
"No usage data found for Claude code, Codex, Cursor, or Open code."
|
|
7359
7840
|
);
|
|
7360
7841
|
}
|
|
7361
7842
|
return providersToRender.map((provider) => rowsByProvider[provider]);
|
|
@@ -7388,6 +7869,7 @@ async function main() {
|
|
|
7388
7869
|
all: { type: "boolean", default: false },
|
|
7389
7870
|
claude: { type: "boolean", default: false },
|
|
7390
7871
|
codex: { type: "boolean", default: false },
|
|
7872
|
+
cursor: { type: "boolean", default: false },
|
|
7391
7873
|
opencode: { type: "boolean", default: false }
|
|
7392
7874
|
},
|
|
7393
7875
|
allowPositionals: false
|
|
@@ -7424,7 +7906,7 @@ async function main() {
|
|
|
7424
7906
|
rowsByProvider,
|
|
7425
7907
|
end
|
|
7426
7908
|
);
|
|
7427
|
-
const outputPath =
|
|
7909
|
+
const outputPath = resolve5(
|
|
7428
7910
|
values.output ?? `./heatmap-last-year.${format}`
|
|
7429
7911
|
);
|
|
7430
7912
|
mkdirSync(dirname(outputPath), { recursive: true });
|