slopmeter 0.4.0 → 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 +513 -42
- 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";
|
|
@@ -5578,7 +5578,9 @@ function computeCurrentStreak(daily, end) {
|
|
|
5578
5578
|
function getProviderInsights(modelTotals, recentModelTotals, daily, end) {
|
|
5579
5579
|
const mostUsedModel = getTopModel(modelTotals);
|
|
5580
5580
|
const recentMostUsedModel = getTopModel(recentModelTotals);
|
|
5581
|
-
const measuredDaily = daily.filter(
|
|
5581
|
+
const measuredDaily = daily.filter(
|
|
5582
|
+
(row) => (row.displayValue ?? row.total) > 0
|
|
5583
|
+
);
|
|
5582
5584
|
return {
|
|
5583
5585
|
mostUsedModel,
|
|
5584
5586
|
recentMostUsedModel,
|
|
@@ -5713,6 +5715,35 @@ var heatmapThemes = {
|
|
|
5713
5715
|
]
|
|
5714
5716
|
}
|
|
5715
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
|
+
},
|
|
5716
5747
|
opencode: {
|
|
5717
5748
|
title: "Open Code",
|
|
5718
5749
|
colors: {
|
|
@@ -5743,7 +5774,7 @@ var heatmapThemes = {
|
|
|
5743
5774
|
}
|
|
5744
5775
|
},
|
|
5745
5776
|
all: {
|
|
5746
|
-
title: "Codex / Claude Code / Open Code",
|
|
5777
|
+
title: "Codex / Claude Code / Cursor / Open Code",
|
|
5747
5778
|
titleCaption: "Total usage from",
|
|
5748
5779
|
colors: {
|
|
5749
5780
|
light: [
|
|
@@ -6152,22 +6183,6 @@ function drawHeatmapSection(svg, {
|
|
|
6152
6183
|
svg = svg.rect(rectAttributes);
|
|
6153
6184
|
}
|
|
6154
6185
|
}
|
|
6155
|
-
const transitionWeekIndex = firstActivityOnlyDate && firstMeasuredDate ? grid.weeks.findIndex((week) => week.includes(firstMeasuredDate)) : -1;
|
|
6156
|
-
if (transitionWeekIndex > 0) {
|
|
6157
|
-
const lineX = x + layout.leftLabelWidth + transitionWeekIndex * (layout.cellSize + layout.gap) - Math.max(layout.gap, 2);
|
|
6158
|
-
const lineTop = y + layout.monthLabelY - 2;
|
|
6159
|
-
const lineBottom = y + layout.gridTop + 7 * layout.cellSize + 6 * layout.gap + 2;
|
|
6160
|
-
svg = svg.line({
|
|
6161
|
-
x1: lineX,
|
|
6162
|
-
y1: lineTop,
|
|
6163
|
-
x2: lineX,
|
|
6164
|
-
y2: lineBottom,
|
|
6165
|
-
stroke: palette.muted,
|
|
6166
|
-
"stroke-width": 1,
|
|
6167
|
-
"stroke-dasharray": "4 4",
|
|
6168
|
-
"stroke-opacity": 0.65
|
|
6169
|
-
});
|
|
6170
|
-
}
|
|
6171
6186
|
const legendStartX = x + layout.leftLabelWidth;
|
|
6172
6187
|
const legendY = y + layout.legendY;
|
|
6173
6188
|
svg = svg.text(
|
|
@@ -7021,19 +7036,468 @@ async function loadCodexRows(start, end, warnings = []) {
|
|
|
7021
7036
|
return createUsageSummary("codex", totals, modelTotals, recentModelTotals, end);
|
|
7022
7037
|
}
|
|
7023
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
|
+
|
|
7024
7487
|
// src/lib/interfaces.ts
|
|
7025
|
-
var providerIds = ["claude", "codex", "opencode"];
|
|
7488
|
+
var providerIds = ["claude", "codex", "cursor", "opencode"];
|
|
7026
7489
|
var providerStatusLabel = {
|
|
7027
7490
|
claude: "Claude code",
|
|
7028
7491
|
codex: "Codex",
|
|
7492
|
+
cursor: "Cursor",
|
|
7029
7493
|
opencode: "Open Code"
|
|
7030
7494
|
};
|
|
7031
7495
|
|
|
7032
7496
|
// src/lib/open-code.ts
|
|
7033
|
-
import { existsSync as
|
|
7034
|
-
import { copyFile, mkdtemp, rm } from "fs/promises";
|
|
7035
|
-
import { homedir as
|
|
7036
|
-
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";
|
|
7037
7501
|
function sumOpenCodeTokens(tokens) {
|
|
7038
7502
|
const cacheInput = tokens?.cache?.read ?? 0;
|
|
7039
7503
|
const cacheOutput = tokens?.cache?.write ?? 0;
|
|
@@ -7050,16 +7514,16 @@ async function parseOpenCodeFile(filePath) {
|
|
|
7050
7514
|
return readJsonDocument(filePath);
|
|
7051
7515
|
}
|
|
7052
7516
|
function getOpenCodeBaseDir() {
|
|
7053
|
-
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");
|
|
7054
7518
|
return baseDir;
|
|
7055
7519
|
}
|
|
7056
7520
|
async function getOpenCodeSource() {
|
|
7057
7521
|
const baseDir = getOpenCodeBaseDir();
|
|
7058
|
-
const databasePath =
|
|
7059
|
-
if (
|
|
7522
|
+
const databasePath = join5(baseDir, "opencode.db");
|
|
7523
|
+
if (existsSync3(databasePath)) {
|
|
7060
7524
|
return { kind: "database", path: databasePath };
|
|
7061
7525
|
}
|
|
7062
|
-
const messagesDir =
|
|
7526
|
+
const messagesDir = join5(baseDir, "storage", "message");
|
|
7063
7527
|
return { kind: "legacy", files: await listFilesRecursive(messagesDir, ".json") };
|
|
7064
7528
|
}
|
|
7065
7529
|
async function loadSqliteModule() {
|
|
@@ -7098,24 +7562,24 @@ function parseOpenCodeMessageData(rowId, sourceLabel, content) {
|
|
|
7098
7562
|
id: message.id || rowId
|
|
7099
7563
|
};
|
|
7100
7564
|
}
|
|
7101
|
-
function
|
|
7565
|
+
function isSqliteLockedError2(error) {
|
|
7102
7566
|
return error instanceof Error && /database is locked/i.test(error.message);
|
|
7103
7567
|
}
|
|
7104
7568
|
async function withDatabaseSnapshot(databasePath, callback) {
|
|
7105
|
-
const snapshotDir = await
|
|
7106
|
-
const snapshotPath =
|
|
7107
|
-
await
|
|
7569
|
+
const snapshotDir = await mkdtemp2(join5(tmpdir2(), "slopmeter-opencode-"));
|
|
7570
|
+
const snapshotPath = join5(snapshotDir, "opencode.db");
|
|
7571
|
+
await copyFile2(databasePath, snapshotPath);
|
|
7108
7572
|
for (const suffix of ["-shm", "-wal"]) {
|
|
7109
7573
|
const companionPath = `${databasePath}${suffix}`;
|
|
7110
|
-
if (!
|
|
7574
|
+
if (!existsSync3(companionPath)) {
|
|
7111
7575
|
continue;
|
|
7112
7576
|
}
|
|
7113
|
-
await
|
|
7577
|
+
await copyFile2(companionPath, `${snapshotPath}${suffix}`);
|
|
7114
7578
|
}
|
|
7115
7579
|
try {
|
|
7116
7580
|
return await callback(snapshotPath);
|
|
7117
7581
|
} finally {
|
|
7118
|
-
await
|
|
7582
|
+
await rm2(snapshotDir, { recursive: true, force: true });
|
|
7119
7583
|
}
|
|
7120
7584
|
}
|
|
7121
7585
|
async function iterateOpenCodeDatabaseMessages(databasePath, onMessage) {
|
|
@@ -7144,7 +7608,7 @@ async function loadOpenCodeDatabaseMessages(databasePath, onMessage) {
|
|
|
7144
7608
|
try {
|
|
7145
7609
|
await iterateOpenCodeDatabaseMessages(databasePath, onMessage);
|
|
7146
7610
|
} catch (error) {
|
|
7147
|
-
if (!
|
|
7611
|
+
if (!isSqliteLockedError2(error)) {
|
|
7148
7612
|
throw error;
|
|
7149
7613
|
}
|
|
7150
7614
|
await withDatabaseSnapshot(databasePath, async (snapshotPath) => {
|
|
@@ -7233,11 +7697,12 @@ async function aggregateUsage({
|
|
|
7233
7697
|
const rowsByProvider = {
|
|
7234
7698
|
claude: null,
|
|
7235
7699
|
codex: null,
|
|
7700
|
+
cursor: null,
|
|
7236
7701
|
opencode: null
|
|
7237
7702
|
};
|
|
7238
7703
|
const warnings = [];
|
|
7239
7704
|
for (const provider of providersToLoad) {
|
|
7240
|
-
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);
|
|
7241
7706
|
rowsByProvider[provider] = hasUsage(summary) ? summary : null;
|
|
7242
7707
|
}
|
|
7243
7708
|
return { rowsByProvider, warnings };
|
|
@@ -7253,12 +7718,13 @@ var HELP_TEXT = `slopmeter
|
|
|
7253
7718
|
Generate rolling 1-year usage heatmap image(s) (today is the latest day).
|
|
7254
7719
|
|
|
7255
7720
|
Usage:
|
|
7256
|
-
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]
|
|
7257
7722
|
|
|
7258
7723
|
Options:
|
|
7259
7724
|
--all Render one merged graph for all providers
|
|
7260
7725
|
--claude Render Claude Code graph
|
|
7261
7726
|
--codex Render Codex graph
|
|
7727
|
+
--cursor Render Cursor graph
|
|
7262
7728
|
--opencode Render Open Code graph
|
|
7263
7729
|
--dark Render with the dark theme
|
|
7264
7730
|
-f, --format Output format: png, svg, or json (default: png)
|
|
@@ -7279,6 +7745,7 @@ function validateArgs(values) {
|
|
|
7279
7745
|
all: ow.boolean,
|
|
7280
7746
|
claude: ow.boolean,
|
|
7281
7747
|
codex: ow.boolean,
|
|
7748
|
+
cursor: ow.boolean,
|
|
7282
7749
|
opencode: ow.boolean
|
|
7283
7750
|
})
|
|
7284
7751
|
);
|
|
@@ -7351,11 +7818,14 @@ function getOutputProviders(values, rowsByProvider, end) {
|
|
|
7351
7818
|
const merged = mergeProviderUsage(rowsByProvider, end);
|
|
7352
7819
|
if (!merged) {
|
|
7353
7820
|
throw new Error(
|
|
7354
|
-
"No usage data found for Claude code, Codex, or Open code."
|
|
7821
|
+
"No usage data found for Claude code, Codex, Cursor, or Open code."
|
|
7355
7822
|
);
|
|
7356
7823
|
}
|
|
7357
7824
|
return [merged];
|
|
7358
7825
|
}
|
|
7826
|
+
function getMergedProviderTitle(rowsByProvider) {
|
|
7827
|
+
return providerIds.filter((provider) => rowsByProvider[provider] !== null).map((provider) => heatmapThemes[provider].title).join(" / ");
|
|
7828
|
+
}
|
|
7359
7829
|
function selectProvidersToRender(rowsByProvider, requested) {
|
|
7360
7830
|
const providersToRender = requested.length > 0 ? requested.filter((provider) => rowsByProvider[provider]) : providerIds.filter((provider) => rowsByProvider[provider]);
|
|
7361
7831
|
if (requested.length > 0 && providersToRender.length < requested.length) {
|
|
@@ -7366,7 +7836,7 @@ function selectProvidersToRender(rowsByProvider, requested) {
|
|
|
7366
7836
|
}
|
|
7367
7837
|
if (providersToRender.length === 0) {
|
|
7368
7838
|
throw new Error(
|
|
7369
|
-
"No usage data found for Claude code, Codex, or Open code."
|
|
7839
|
+
"No usage data found for Claude code, Codex, Cursor, or Open code."
|
|
7370
7840
|
);
|
|
7371
7841
|
}
|
|
7372
7842
|
return providersToRender.map((provider) => rowsByProvider[provider]);
|
|
@@ -7399,6 +7869,7 @@ async function main() {
|
|
|
7399
7869
|
all: { type: "boolean", default: false },
|
|
7400
7870
|
claude: { type: "boolean", default: false },
|
|
7401
7871
|
codex: { type: "boolean", default: false },
|
|
7872
|
+
cursor: { type: "boolean", default: false },
|
|
7402
7873
|
opencode: { type: "boolean", default: false }
|
|
7403
7874
|
},
|
|
7404
7875
|
allowPositionals: false
|
|
@@ -7435,7 +7906,7 @@ async function main() {
|
|
|
7435
7906
|
rowsByProvider,
|
|
7436
7907
|
end
|
|
7437
7908
|
);
|
|
7438
|
-
const outputPath =
|
|
7909
|
+
const outputPath = resolve5(
|
|
7439
7910
|
values.output ?? `./heatmap-last-year.${format}`
|
|
7440
7911
|
);
|
|
7441
7912
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
@@ -7460,7 +7931,7 @@ async function main() {
|
|
|
7460
7931
|
sections: exportProviders.map(({ provider, daily, insights }) => ({
|
|
7461
7932
|
daily,
|
|
7462
7933
|
insights,
|
|
7463
|
-
title: heatmapThemes[provider].title,
|
|
7934
|
+
title: provider === "all" ? getMergedProviderTitle(rowsByProvider) : heatmapThemes[provider].title,
|
|
7464
7935
|
titleCaption: heatmapThemes[provider].titleCaption,
|
|
7465
7936
|
colors: heatmapThemes[provider].colors
|
|
7466
7937
|
}))
|