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 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 resolve4 } from "path";
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((row) => row.total > 0);
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 existsSync2 } from "fs";
7034
- import { copyFile, mkdtemp, rm } from "fs/promises";
7035
- import { homedir as homedir3, tmpdir } from "os";
7036
- import { join as join4, resolve as resolve3 } from "path";
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() ? resolve3(process.env.OPENCODE_DATA_DIR) : join4(homedir3(), ".local", "share", "opencode");
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 = join4(baseDir, "opencode.db");
7059
- if (existsSync2(databasePath)) {
7522
+ const databasePath = join5(baseDir, "opencode.db");
7523
+ if (existsSync3(databasePath)) {
7060
7524
  return { kind: "database", path: databasePath };
7061
7525
  }
7062
- const messagesDir = join4(baseDir, "storage", "message");
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 isSqliteLockedError(error) {
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 mkdtemp(join4(tmpdir(), "slopmeter-opencode-"));
7106
- const snapshotPath = join4(snapshotDir, "opencode.db");
7107
- await copyFile(databasePath, snapshotPath);
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 (!existsSync2(companionPath)) {
7574
+ if (!existsSync3(companionPath)) {
7111
7575
  continue;
7112
7576
  }
7113
- await copyFile(companionPath, `${snapshotPath}${suffix}`);
7577
+ await copyFile2(companionPath, `${snapshotPath}${suffix}`);
7114
7578
  }
7115
7579
  try {
7116
7580
  return await callback(snapshotPath);
7117
7581
  } finally {
7118
- await rm(snapshotDir, { recursive: true, force: true });
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 (!isSqliteLockedError(error)) {
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 = resolve4(
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
  }))