costhawk 1.5.13 → 1.5.15

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/index.js CHANGED
@@ -19,7 +19,8 @@ const REQUEST_TIMEOUT_MS = 30000; // 30 seconds
19
19
  const PRICING_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
20
20
  const API_BASE_URL = process.env.COSTHAWK_API_URL || "https://costhawk.ai";
21
21
  const DEFAULT_BROWSER_LOGIN_HOSTS = ["costhawk.ai", "www.costhawk.ai"];
22
- const DEFAULT_API_KEY = process.env.COSTHAWK_API_KEY;
22
+ const AUTH_STATE_PATH = join(homedir(), ".costhawk", "auth.json");
23
+ const DEFAULT_API_KEY = process.env.COSTHAWK_API_KEY || readStoredApiKey();
23
24
  const CLIENT_VERSION = (() => {
24
25
  try {
25
26
  const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -48,6 +49,9 @@ const LOGIN_SYNC_MAX_AGE_HOURS = 720; // First-time login sync: 30 days of histo
48
49
  let autoSyncIntervalId = null;
49
50
  const AUTO_SYNC_ENABLED = process.env.COSTHAWK_AUTO_SYNC === "true";
50
51
  const CODEX_AUTO_SYNC_ENABLED = AUTO_SYNC_ENABLED && process.env.COSTHAWK_CODEX_AUTO_SYNC !== "false";
52
+ // Cursor auto-sync piggybacks on the same master COSTHAWK_AUTO_SYNC toggle
53
+ // as Codex, and can be individually opted out via COSTHAWK_CURSOR_AUTO_SYNC=false.
54
+ const CURSOR_AUTO_SYNC_ENABLED = AUTO_SYNC_ENABLED && process.env.COSTHAWK_CURSOR_AUTO_SYNC !== "false";
51
55
  // AlertType enum values from backend
52
56
  const ALERT_TYPE_VALUES = [
53
57
  "BUDGET_EXCEEDED",
@@ -737,6 +741,9 @@ function resolveOpenCodeConfigPath() {
737
741
  return jsoncPath;
738
742
  return jsonPath;
739
743
  }
744
+ function resolveGeminiConfigPath() {
745
+ return join(homedir(), ".gemini", "settings.json");
746
+ }
740
747
  function stripJsonComments(input) {
741
748
  let output = "";
742
749
  let i = 0;
@@ -880,6 +887,30 @@ function writeSecretJsonFile(filePath, data) {
880
887
  writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, { mode: SECRET_FILE_MODE });
881
888
  enforceSecretFilePermissions(filePath);
882
889
  }
890
+ function readStoredApiKey() {
891
+ if (!existsSync(AUTH_STATE_PATH))
892
+ return null;
893
+ try {
894
+ const raw = readFileSync(AUTH_STATE_PATH, "utf-8");
895
+ if (raw.trim().length === 0)
896
+ return null;
897
+ const parsed = JSON.parse(raw);
898
+ return typeof parsed.apiKey === "string" && parsed.apiKey.trim().length > 0 ? parsed.apiKey : null;
899
+ }
900
+ catch {
901
+ return null;
902
+ }
903
+ }
904
+ function persistApiKey(apiKey) {
905
+ const authDir = dirname(AUTH_STATE_PATH);
906
+ if (!existsSync(authDir)) {
907
+ mkdirSync(authDir, { recursive: true });
908
+ }
909
+ writeSecretJsonFile(AUTH_STATE_PATH, {
910
+ apiKey,
911
+ savedAt: new Date().toISOString(),
912
+ });
913
+ }
883
914
  async function promptInput(question) {
884
915
  const rl = createInterface({
885
916
  input: process.stdin,
@@ -1005,6 +1036,10 @@ async function runSetup(args) {
1005
1036
  if (writeClaude === undefined) {
1006
1037
  writeClaude = true;
1007
1038
  }
1039
+ let writeGemini = parseBooleanFlag(args, "gemini");
1040
+ if (writeGemini === undefined) {
1041
+ writeGemini = true;
1042
+ }
1008
1043
  let writeOpenCode = parseBooleanFlag(args, "opencode");
1009
1044
  let apiKey = apiKeyArg || DEFAULT_API_KEY || "";
1010
1045
  if (!apiKey) {
@@ -1030,6 +1065,7 @@ async function runSetup(args) {
1030
1065
  console.error("Error: API key is required to complete setup.");
1031
1066
  process.exit(1);
1032
1067
  }
1068
+ persistApiKey(apiKey);
1033
1069
  let autoSync = parseBooleanFlag(args, "auto-sync");
1034
1070
  if (autoSync === undefined) {
1035
1071
  if (!writeClaude) {
@@ -1056,6 +1092,20 @@ async function runSetup(args) {
1056
1092
  else {
1057
1093
  codexSync = false;
1058
1094
  }
1095
+ let cursorSync = parseBooleanFlag(args, "cursor-sync");
1096
+ if (autoSync) {
1097
+ if (cursorSync === undefined) {
1098
+ if (!process.stdin.isTTY || nonInteractive) {
1099
+ cursorSync = true;
1100
+ }
1101
+ else {
1102
+ cursorSync = await promptYesNo("Enable Cursor IDE auto-sync?", true);
1103
+ }
1104
+ }
1105
+ }
1106
+ else {
1107
+ cursorSync = false;
1108
+ }
1059
1109
  if (writeOpenCode === undefined) {
1060
1110
  if (!process.stdin.isTTY || nonInteractive) {
1061
1111
  writeOpenCode = false;
@@ -1064,8 +1114,8 @@ async function runSetup(args) {
1064
1114
  writeOpenCode = await promptYesNo("Also add OpenCode config?", false);
1065
1115
  }
1066
1116
  }
1067
- if (!writeClaude && !writeOpenCode) {
1068
- console.error("Error: No targets selected. Use --claude, --opencode, or both.");
1117
+ if (!writeClaude && !writeGemini && !writeOpenCode) {
1118
+ console.error("Error: No targets selected. Use --claude, --gemini, --opencode, or a combination.");
1069
1119
  process.exit(1);
1070
1120
  }
1071
1121
  const configPaths = writeClaude ? resolveClaudeConfigPaths() : [];
@@ -1096,17 +1146,19 @@ async function runSetup(args) {
1096
1146
  : {};
1097
1147
  const env = {
1098
1148
  ...existingEnv,
1099
- COSTHAWK_API_KEY: apiKey,
1100
1149
  };
1150
+ delete env.COSTHAWK_API_KEY;
1101
1151
  if (process.env.COSTHAWK_API_URL) {
1102
1152
  env.COSTHAWK_API_URL = process.env.COSTHAWK_API_URL;
1103
1153
  }
1104
1154
  env.COSTHAWK_AUTO_SYNC = autoSync ? "true" : "false";
1105
1155
  if (autoSync) {
1106
1156
  env.COSTHAWK_CODEX_AUTO_SYNC = codexSync ? "true" : "false";
1157
+ env.COSTHAWK_CURSOR_AUTO_SYNC = cursorSync ? "true" : "false";
1107
1158
  }
1108
1159
  else {
1109
1160
  env.COSTHAWK_CODEX_AUTO_SYNC = "false";
1161
+ env.COSTHAWK_CURSOR_AUTO_SYNC = "false";
1110
1162
  }
1111
1163
  mcpServers.costhawk = {
1112
1164
  type: "stdio",
@@ -1148,13 +1200,14 @@ async function runSetup(args) {
1148
1200
  : {};
1149
1201
  const environment = {
1150
1202
  ...existingEnv,
1151
- COSTHAWK_API_KEY: apiKey,
1152
1203
  };
1204
+ delete environment.COSTHAWK_API_KEY;
1153
1205
  if (process.env.COSTHAWK_API_URL) {
1154
1206
  environment.COSTHAWK_API_URL = process.env.COSTHAWK_API_URL;
1155
1207
  }
1156
1208
  environment.COSTHAWK_AUTO_SYNC = autoSync ? "true" : "false";
1157
1209
  environment.COSTHAWK_CODEX_AUTO_SYNC = autoSync && codexSync ? "true" : "false";
1210
+ environment.COSTHAWK_CURSOR_AUTO_SYNC = autoSync && cursorSync ? "true" : "false";
1158
1211
  mcp.costhawk = {
1159
1212
  type: "local",
1160
1213
  command: ["npx", "--yes", "costhawk@latest"],
@@ -1164,10 +1217,57 @@ async function runSetup(args) {
1164
1217
  config.mcp = mcp;
1165
1218
  writeSecretJsonFile(openCodePath, config);
1166
1219
  }
1220
+ let geminiPath = null;
1221
+ if (writeGemini) {
1222
+ geminiPath = resolveGeminiConfigPath();
1223
+ const geminiDir = dirname(geminiPath);
1224
+ if (!existsSync(geminiDir)) {
1225
+ mkdirSync(geminiDir, { recursive: true });
1226
+ }
1227
+ let config = {};
1228
+ if (existsSync(geminiPath)) {
1229
+ try {
1230
+ config = readJsonFile(geminiPath);
1231
+ }
1232
+ catch (error) {
1233
+ console.error(`Error: Failed to parse ${geminiPath}. Fix the JSON and try again.`);
1234
+ process.exit(1);
1235
+ }
1236
+ }
1237
+ const mcpServers = typeof config.mcpServers === "object" && config.mcpServers !== null
1238
+ ? config.mcpServers
1239
+ : {};
1240
+ const existingCosthawk = typeof mcpServers.costhawk === "object" && mcpServers.costhawk !== null
1241
+ ? mcpServers.costhawk
1242
+ : null;
1243
+ const existingEnv = existingCosthawk && typeof existingCosthawk.env === "object" && existingCosthawk.env !== null
1244
+ ? existingCosthawk.env
1245
+ : {};
1246
+ const env = {
1247
+ ...existingEnv,
1248
+ };
1249
+ delete env.COSTHAWK_API_KEY;
1250
+ if (process.env.COSTHAWK_API_URL) {
1251
+ env.COSTHAWK_API_URL = process.env.COSTHAWK_API_URL;
1252
+ }
1253
+ env.COSTHAWK_AUTO_SYNC = autoSync ? "true" : "false";
1254
+ env.COSTHAWK_CODEX_AUTO_SYNC = autoSync && codexSync ? "true" : "false";
1255
+ env.COSTHAWK_CURSOR_AUTO_SYNC = autoSync && cursorSync ? "true" : "false";
1256
+ mcpServers.costhawk = {
1257
+ command: "npx",
1258
+ args: ["-y", "costhawk@latest"],
1259
+ env,
1260
+ };
1261
+ config.mcpServers = mcpServers;
1262
+ writeSecretJsonFile(geminiPath, config);
1263
+ }
1167
1264
  console.log("");
1168
1265
  if (writeClaude) {
1169
1266
  console.log("✅ CostHawk MCP server added to Claude Code!");
1170
1267
  }
1268
+ if (writeGemini) {
1269
+ console.log("✅ CostHawk MCP server added to Gemini CLI!");
1270
+ }
1171
1271
  if (writeOpenCode) {
1172
1272
  console.log("✅ CostHawk MCP server added to OpenCode!");
1173
1273
  }
@@ -1178,14 +1278,22 @@ async function runSetup(args) {
1178
1278
  if (openCodePath) {
1179
1279
  console.log(` OpenCode config: ${openCodePath}`);
1180
1280
  }
1281
+ if (geminiPath) {
1282
+ console.log(` Gemini CLI config: ${geminiPath}`);
1283
+ }
1284
+ console.log(` Stored auth: ${AUTH_STATE_PATH}`);
1181
1285
  console.log(` Claude Code auto-sync: ${autoSync ? "enabled" : "disabled"}`);
1182
1286
  console.log(` OpenAI Codex CLI auto-sync: ${autoSync && codexSync ? "enabled" : "disabled"}`);
1183
- console.log(" Note: Auto-sync only applies to local Claude Code / Codex CLI logs.");
1287
+ console.log(" Note: Auto-sync only applies to local Claude Code / Codex CLI / Cursor logs. Gemini CLI gets MCP tools, but Gemini local transcripts are not auto-synced yet.");
1184
1288
  console.log("");
1185
1289
  if (writeClaude) {
1186
1290
  console.log("👉 Next step: Restart Claude Code, then ask:");
1187
1291
  console.log(' "What\'s my AI API usage this month?"');
1188
1292
  }
1293
+ if (writeGemini) {
1294
+ console.log("👉 Next step: Restart Gemini CLI, then run:");
1295
+ console.log(" gemini mcp list");
1296
+ }
1189
1297
  if (writeOpenCode) {
1190
1298
  console.log("👉 Next step: Restart OpenCode to load MCP servers.");
1191
1299
  }
@@ -1241,6 +1349,90 @@ async function syncSessionsToApi(sessions, options) {
1241
1349
  }
1242
1350
  return { sessionCount: allSessions.length, totalNew, totalUpdated, totalTokens };
1243
1351
  }
1352
+ // Cursor-specific sync helper. Cannot reuse `syncSessionsToApi` because the
1353
+ // Cursor parser emits a different shape than Claude Code / Codex:
1354
+ // - no `projectHash`, has `workspaceHash`/`workspaceName` (both nullable)
1355
+ // - nullable `startTime` / `endTime` (must be filtered out pre-send — the
1356
+ // server schema rejects "none" timestampSource as unpersistable)
1357
+ // - extra provenance fields: `timestampSource`, `timestampQuality`,
1358
+ // `dailyUsageSource` at session level, plus per-row `source` on
1359
+ // dailyUsage. These are required by the PR3/PR4 Zod schema on
1360
+ // costcanary's `/api/mcp/usage/cursor` route.
1361
+ // The transform logic here MUST stay in lockstep with the transform in
1362
+ // the `costhawk_sync_cursor_usage` tool registration (below) so both
1363
+ // code paths send an identical wire shape.
1364
+ async function syncCursorSessionsToApi(sessions, options) {
1365
+ // Client-side defense-in-depth: drop sessions the server would reject.
1366
+ // timestampSource === "none" means the parser couldn't resolve either
1367
+ // bubble-level or composer-level createdAt. CursorSession.startTime is
1368
+ // NOT NULL on the backend so these would 400. Same filter as the
1369
+ // costhawk_sync_cursor_usage tool.
1370
+ const persistable = sessions.filter((s) => s.timestampSource !== "none" &&
1371
+ s.startTime !== null &&
1372
+ s.endTime !== null);
1373
+ const totalRejected = sessions.length - persistable.length;
1374
+ if (persistable.length === 0) {
1375
+ return { sessionCount: 0, totalNew: 0, totalUpdated: 0, totalTokens: 0, totalRejected };
1376
+ }
1377
+ // Transform parser's Record<date, TokenUsage> into the wire
1378
+ // Array<{date, ..., source}> shape expected by the API route.
1379
+ const wireSessions = persistable.map((session) => {
1380
+ const dailyUsageArray = Object.entries(session.dailyUsage).map(([date, tokens]) => ({
1381
+ date,
1382
+ inputTokens: tokens.inputTokens,
1383
+ outputTokens: tokens.outputTokens,
1384
+ cacheCreationTokens: tokens.cacheCreationTokens,
1385
+ cacheReadTokens: tokens.cacheReadTokens,
1386
+ source: session.dailyUsageSource === "none"
1387
+ ? "bubble"
1388
+ : session.dailyUsageSource,
1389
+ }));
1390
+ return {
1391
+ sessionId: session.sessionId,
1392
+ workspaceHash: session.workspaceHash,
1393
+ workspaceName: session.workspaceName,
1394
+ model: session.model,
1395
+ inputTokens: session.tokens.inputTokens,
1396
+ outputTokens: session.tokens.outputTokens,
1397
+ cacheCreationTokens: session.tokens.cacheCreationTokens,
1398
+ cacheReadTokens: session.tokens.cacheReadTokens,
1399
+ messageCount: session.messageCount,
1400
+ startTime: session.startTime,
1401
+ endTime: session.endTime,
1402
+ timestampSource: session.timestampSource,
1403
+ timestampQuality: session.timestampQuality,
1404
+ dailyUsageSource: session.dailyUsageSource,
1405
+ dailyUsage: dailyUsageArray.length > 0 ? dailyUsageArray : undefined,
1406
+ source: "cursor",
1407
+ };
1408
+ });
1409
+ const BATCH_SIZE = 100;
1410
+ let totalNew = 0;
1411
+ let totalUpdated = 0;
1412
+ let totalTokens = 0;
1413
+ for (let i = 0; i < wireSessions.length; i += BATCH_SIZE) {
1414
+ const batch = wireSessions.slice(i, i + BATCH_SIZE);
1415
+ const result = await apiRequest("/api/mcp/usage/cursor", {
1416
+ method: "POST",
1417
+ apiKey: options.apiKey,
1418
+ body: {
1419
+ sessions: batch,
1420
+ clientVersion: CLIENT_VERSION,
1421
+ syncedAt: new Date().toISOString(),
1422
+ },
1423
+ });
1424
+ totalNew += result.sessionsNew;
1425
+ totalUpdated += result.sessionsUpdated;
1426
+ totalTokens += result.totalTokens;
1427
+ }
1428
+ return {
1429
+ sessionCount: wireSessions.length,
1430
+ totalNew,
1431
+ totalUpdated,
1432
+ totalTokens,
1433
+ totalRejected,
1434
+ };
1435
+ }
1244
1436
  // Perform a one-time sync during --login so data appears in the dashboard immediately.
1245
1437
  async function performLoginSync(apiKey) {
1246
1438
  let totalSynced = 0;
@@ -1279,6 +1471,36 @@ async function performLoginSync(apiKey) {
1279
1471
  console.error(` Warning: Codex sync failed: ${error instanceof Error ? error.message : "Unknown error"}`);
1280
1472
  }
1281
1473
  }
1474
+ if (cursorDbExists()) {
1475
+ try {
1476
+ // parseCursorUsage() reads the whole Cursor SQLite in one pass. The
1477
+ // LOGIN_SYNC_MAX_AGE_HOURS window is applied client-side below to
1478
+ // avoid syncing sessions older than 30 days on first login. The
1479
+ // parser itself doesn't accept a maxAgeHours param — we filter by
1480
+ // resolved startTime here the same way the sync tool does.
1481
+ const parseResult = parseCursorUsage();
1482
+ const cutoffMs = Date.now() - LOGIN_SYNC_MAX_AGE_HOURS * 60 * 60 * 1000;
1483
+ const recent = parseResult.sessions.filter((s) => {
1484
+ if (s.startTime === null)
1485
+ return true; // defer to "none" filter inside helper
1486
+ const startMs = Date.parse(s.startTime);
1487
+ return Number.isFinite(startMs) && startMs >= cutoffMs;
1488
+ });
1489
+ const result = await syncCursorSessionsToApi(recent, {
1490
+ apiKey,
1491
+ });
1492
+ totalSynced += result.sessionCount;
1493
+ if (result.sessionCount > 0) {
1494
+ console.log(` Synced ${result.sessionCount} Cursor session${result.sessionCount === 1 ? "" : "s"}`);
1495
+ }
1496
+ if (result.totalRejected > 0) {
1497
+ console.log(` Skipped ${result.totalRejected} Cursor session${result.totalRejected === 1 ? "" : "s"} (no resolvable timestamp)`);
1498
+ }
1499
+ }
1500
+ catch (error) {
1501
+ console.error(` Warning: Cursor sync failed: ${error instanceof Error ? error.message : "Unknown error"}`);
1502
+ }
1503
+ }
1282
1504
  if (totalSynced > 0) {
1283
1505
  console.log(`\n✅ ${totalSynced} session${totalSynced === 1 ? "" : "s"} synced to your CostHawk dashboard.`);
1284
1506
  }
@@ -1296,18 +1518,23 @@ async function runLoginCommand(args) {
1296
1518
  console.error(`Error: ${error instanceof Error ? error.message : "Browser login failed."}`);
1297
1519
  process.exit(1);
1298
1520
  }
1521
+ persistApiKey(apiKey);
1299
1522
  if (shouldRunSetup) {
1300
1523
  const setupArgs = ["--api-key", apiKey];
1301
1524
  const passthroughFlags = [
1302
1525
  "--non-interactive",
1303
1526
  "--claude",
1304
1527
  "--no-claude",
1528
+ "--gemini",
1529
+ "--no-gemini",
1305
1530
  "--opencode",
1306
1531
  "--no-opencode",
1307
1532
  "--auto-sync",
1308
1533
  "--no-auto-sync",
1309
1534
  "--codex-sync",
1310
1535
  "--no-codex-sync",
1536
+ "--cursor-sync",
1537
+ "--no-cursor-sync",
1311
1538
  ];
1312
1539
  for (const flag of passthroughFlags) {
1313
1540
  if (args.includes(flag)) {
@@ -1321,9 +1548,10 @@ async function runLoginCommand(args) {
1321
1548
  }
1322
1549
  console.log("");
1323
1550
  console.log("✅ Login successful.");
1324
- console.log(" API key issued (masked):");
1551
+ console.log(" Credentials saved locally (masked token):");
1325
1552
  console.log(` COSTHAWK_API_KEY=${maskSecret(apiKey)}`);
1326
1553
  console.log(" Full token is intentionally not printed for security.");
1554
+ console.log(` Stored auth file: ${AUTH_STATE_PATH}`);
1327
1555
  console.log("");
1328
1556
  console.log(" Or run: npm exec --yes costhawk@latest -- --setup");
1329
1557
  console.log("");
@@ -1983,6 +2211,46 @@ function formatCodexSyncResultMarkdown(data) {
1983
2211
  }
1984
2212
  return truncateResponse(output);
1985
2213
  }
2214
+ function formatCursorSyncResultMarkdown(data) {
2215
+ const statusEmoji = data.success ? "✅" : "❌";
2216
+ let output = `# Cursor Sync Result\n\n`;
2217
+ output += `${statusEmoji} ${data.message}\n\n`;
2218
+ output += `| Metric | Value |\n|--------|-------|\n`;
2219
+ if (data.dryRun) {
2220
+ output += `| Dry Run | Yes (no data uploaded) |\n`;
2221
+ }
2222
+ if (data.sessionsParsed !== undefined) {
2223
+ output += `| Sessions Parsed | ${data.sessionsParsed} |\n`;
2224
+ }
2225
+ output += `| New Sessions | ${data.sessionsNew} |\n`;
2226
+ output += `| Updated Sessions | ${data.sessionsUpdated} |\n`;
2227
+ if (data.sessionsRejected > 0) {
2228
+ output += `| Rejected (no timestamp) | ${data.sessionsRejected} |\n`;
2229
+ }
2230
+ if (data.sessionsWithDailyUsage !== undefined) {
2231
+ output += `| Sessions w/ Daily Usage | ${data.sessionsWithDailyUsage} |\n`;
2232
+ }
2233
+ if (data.dailyUsageRowsUpserted !== undefined) {
2234
+ output += `| Daily Usage Rows Upserted | ${data.dailyUsageRowsUpserted} |\n`;
2235
+ }
2236
+ output += `| Total Tokens | ${formatTokens(data.totalTokens)} |\n`;
2237
+ if (data.estimatedCost !== undefined) {
2238
+ output += `| Estimated Cost | ${formatCost(data.estimatedCost)} |\n`;
2239
+ }
2240
+ if (data.filesRead && data.filesRead.length > 0) {
2241
+ output += `\n## Files Read\n`;
2242
+ for (const file of data.filesRead) {
2243
+ output += `- ${file}\n`;
2244
+ }
2245
+ }
2246
+ if (data.payloadPreview && data.payloadPreview.length > 0) {
2247
+ output += `\n## Payload Preview (first ${data.payloadPreview.length})\n`;
2248
+ output += "```json\n";
2249
+ output += JSON.stringify(data.payloadPreview, null, 2);
2250
+ output += "\n```\n";
2251
+ }
2252
+ return truncateResponse(output);
2253
+ }
1986
2254
  function formatCodexSessionsMarkdown(sessions) {
1987
2255
  if (sessions.length === 0) {
1988
2256
  return "# Codex Sessions\n\nNo sessions found. Make sure you have used Codex recently.";
@@ -2579,6 +2847,35 @@ const GetLocalCodexUsageSchema = {
2579
2847
  .describe("Only include sessions modified within this many hours (1-720, default 24)"),
2580
2848
  format: z.enum(["markdown", "json"]).optional().default("markdown").describe("Response format"),
2581
2849
  };
2850
+ const SyncCursorUsageSchema = {
2851
+ apiKey: z
2852
+ .string()
2853
+ .optional()
2854
+ .describe("Your CostHawk API key. If not provided, uses COSTHAWK_API_KEY environment variable."),
2855
+ maxAgeHours: z
2856
+ .number()
2857
+ .min(1)
2858
+ .max(720)
2859
+ .optional()
2860
+ .default(720)
2861
+ .describe("Only sync sessions whose startTime falls within this many hours (1-720, default 720 = 30 days). Applied after parser output, before the 'none' timestamp filter."),
2862
+ dryRun: z
2863
+ .boolean()
2864
+ .optional()
2865
+ .describe("If true, do not upload. Returns a local preview of what would be sent."),
2866
+ previewLimit: z
2867
+ .number()
2868
+ .min(0)
2869
+ .max(20)
2870
+ .optional()
2871
+ .default(3)
2872
+ .describe("Number of sessions to include in the payload preview when dryRun is true"),
2873
+ includeFileList: z
2874
+ .boolean()
2875
+ .optional()
2876
+ .describe("Include the Cursor SQLite DB path in the response"),
2877
+ format: z.enum(["markdown", "json"]).optional().default("markdown").describe("Response format"),
2878
+ };
2582
2879
  // Cursor usage schema. Three modes:
2583
2880
  // - "usage" (default): per-session tokens with startTime/endTime/dailyUsage
2584
2881
  // and timestamp provenance fields. Backward-compatible superset of the
@@ -3763,6 +4060,223 @@ server.registerTool("costhawk_sync_codex_usage", {
3763
4060
  };
3764
4061
  }
3765
4062
  });
4063
+ // ─── Cursor sync tool ───
4064
+ //
4065
+ // Parses the local Cursor SQLite and pushes per-session token counts +
4066
+ // timestamps + per-day breakdowns to CostHawk. Mirrors the Codex sync
4067
+ // tool pattern with three Cursor-specific deviations:
4068
+ //
4069
+ // 1. Filters out sessions with timestampSource === "none" client-side
4070
+ // (the API route rejects these too as defense-in-depth; filtering
4071
+ // here keeps the network payload small and surfaces the skip count
4072
+ // in the user-facing result).
4073
+ // 2. Transforms the parser's dailyUsage Record<date, TokenUsage> into
4074
+ // Array<{date, ..., source}> and attaches the session-level
4075
+ // dailyUsageSource as the row-level source for every bucket.
4076
+ // 3. Applies maxAgeHours client-side (the Cursor parser reads the
4077
+ // whole SQLite in one pass; there's no per-session file mtime to
4078
+ // filter on, so we filter by parsed startTime >= cutoff).
4079
+ const CURSOR_DIR_NOT_FOUND_MESSAGE = "Error: Cursor SQLite database not found. Make sure Cursor is installed and you have used it at least once. Set COSTHAWK_CURSOR_DB_PATH to override the default path.";
4080
+ server.registerTool("costhawk_sync_cursor_usage", {
4081
+ description: `Sync your local Cursor IDE usage to CostHawk. Parses the local Cursor SQLite (state.vscdb) and uploads token counts, models, timestamps, and per-day breakdowns to your CostHawk dashboard. Requires API key.\n\nIMPORTANT: Always display the COMPLETE output in your response using markdown tables. Never summarize or truncate.`,
4082
+ inputSchema: SyncCursorUsageSchema,
4083
+ annotations: WRITE_ANNOTATIONS,
4084
+ }, async (args, _extra) => {
4085
+ const apiKey = getApiKey(args.apiKey);
4086
+ if (!apiKey) {
4087
+ return {
4088
+ content: [
4089
+ {
4090
+ type: "text",
4091
+ text: "Error: No API key provided. You must first sign up at https://costhawk.ai to get an API key. Once approved, go to Settings > Developer > Create Token, then set COSTHAWK_API_KEY in your MCP configuration.",
4092
+ },
4093
+ ],
4094
+ isError: true,
4095
+ };
4096
+ }
4097
+ if (!cursorDbExists()) {
4098
+ return {
4099
+ content: [
4100
+ {
4101
+ type: "text",
4102
+ text: CURSOR_DIR_NOT_FOUND_MESSAGE,
4103
+ },
4104
+ ],
4105
+ isError: true,
4106
+ };
4107
+ }
4108
+ try {
4109
+ const maxAgeHours = args.maxAgeHours ?? 720;
4110
+ const dryRun = args.dryRun === true;
4111
+ const previewLimit = args.previewLimit ?? 3;
4112
+ const includeFileList = args.includeFileList === true;
4113
+ const parseResult = parseCursorUsage();
4114
+ const parserSessionCount = parseResult.sessions.length;
4115
+ if (parserSessionCount === 0) {
4116
+ return {
4117
+ content: [
4118
+ {
4119
+ type: "text",
4120
+ text: `No Cursor sessions with token data found at ${parseResult.filePath}. The database exists but no token-bearing bubbles were detected. Try having a few real conversations in Cursor first.`,
4121
+ },
4122
+ ],
4123
+ };
4124
+ }
4125
+ // Age filter (after parse, before "none" filter). maxAgeHours is
4126
+ // applied against session.startTime when present. Sessions with
4127
+ // null startTime (timestampSource === "none") cannot be age-
4128
+ // filtered so they fall through to the "none" filter below and
4129
+ // get rejected there regardless.
4130
+ const cutoffMs = Date.now() - maxAgeHours * 60 * 60 * 1000;
4131
+ const ageFiltered = parseResult.sessions.filter((s) => {
4132
+ if (s.startTime === null)
4133
+ return true; // defer to "none" filter
4134
+ const startMs = Date.parse(s.startTime);
4135
+ return Number.isFinite(startMs) && startMs >= cutoffMs;
4136
+ });
4137
+ // "none" filter — drops sessions where the parser could not
4138
+ // resolve either bubble-level or composer-level timestamps. These
4139
+ // cannot be persisted (CursorSession.startTime is NOT NULL on the
4140
+ // backend) so we skip them locally and report the count.
4141
+ const persistable = ageFiltered.filter((s) => s.timestampSource !== "none" &&
4142
+ s.startTime !== null &&
4143
+ s.endTime !== null);
4144
+ const sessionsRejected = ageFiltered.length - persistable.length;
4145
+ if (persistable.length === 0) {
4146
+ const message = sessionsRejected > 0
4147
+ ? `All ${sessionsRejected} age-filtered sessions rejected (no resolvable startTime/endTime).`
4148
+ : `No Cursor sessions found within the last ${maxAgeHours} hours. Try increasing maxAgeHours or use Cursor first.`;
4149
+ return {
4150
+ content: [{ type: "text", text: message }],
4151
+ };
4152
+ }
4153
+ // Transform parser's Record<date, TokenUsage> into the wire
4154
+ // Array<{date, ..., source}> shape expected by the API route.
4155
+ // Session-level dailyUsageSource is applied to every row since
4156
+ // the parser doesn't track per-day provenance separately.
4157
+ const allSessions = persistable.map((session) => {
4158
+ const dailyUsageArray = Object.entries(session.dailyUsage).map(([date, tokens]) => ({
4159
+ date,
4160
+ inputTokens: tokens.inputTokens,
4161
+ outputTokens: tokens.outputTokens,
4162
+ cacheCreationTokens: tokens.cacheCreationTokens,
4163
+ cacheReadTokens: tokens.cacheReadTokens,
4164
+ source: session.dailyUsageSource === "none"
4165
+ ? "bubble"
4166
+ : session.dailyUsageSource,
4167
+ }));
4168
+ return {
4169
+ sessionId: session.sessionId,
4170
+ workspaceHash: session.workspaceHash,
4171
+ workspaceName: session.workspaceName,
4172
+ model: session.model,
4173
+ inputTokens: session.tokens.inputTokens,
4174
+ outputTokens: session.tokens.outputTokens,
4175
+ cacheCreationTokens: session.tokens.cacheCreationTokens,
4176
+ cacheReadTokens: session.tokens.cacheReadTokens,
4177
+ messageCount: session.messageCount,
4178
+ startTime: session.startTime,
4179
+ endTime: session.endTime,
4180
+ timestampSource: session.timestampSource,
4181
+ timestampQuality: session.timestampQuality,
4182
+ dailyUsageSource: session.dailyUsageSource,
4183
+ dailyUsage: dailyUsageArray.length > 0 ? dailyUsageArray : undefined,
4184
+ source: "cursor",
4185
+ };
4186
+ });
4187
+ const filesRead = includeFileList
4188
+ ? sanitizePathList([parseResult.filePath])
4189
+ : undefined;
4190
+ if (dryRun) {
4191
+ const sessionsWithDailyUsage = allSessions.filter((session) => session.dailyUsage && session.dailyUsage.length > 0).length;
4192
+ const totalTokens = allSessions.reduce((sum, session) => sum +
4193
+ session.inputTokens +
4194
+ session.outputTokens +
4195
+ session.cacheCreationTokens +
4196
+ session.cacheReadTokens, 0);
4197
+ const result = {
4198
+ success: true,
4199
+ sessionsNew: 0,
4200
+ sessionsUpdated: 0,
4201
+ sessionsRejected,
4202
+ sessionsParsed: allSessions.length,
4203
+ totalTokens,
4204
+ sessionsWithDailyUsage,
4205
+ dryRun: true,
4206
+ message: "Dry run - no data uploaded",
4207
+ filesRead,
4208
+ payloadPreview: previewLimit > 0 ? allSessions.slice(0, previewLimit) : [],
4209
+ };
4210
+ const text = args.format === "json"
4211
+ ? JSON.stringify(result, null, 2)
4212
+ : formatCursorSyncResultMarkdown(result);
4213
+ return {
4214
+ content: [{ type: "text", text }],
4215
+ };
4216
+ }
4217
+ const BATCH_SIZE = 100;
4218
+ const batches = [];
4219
+ for (let i = 0; i < allSessions.length; i += BATCH_SIZE) {
4220
+ batches.push(allSessions.slice(i, i + BATCH_SIZE));
4221
+ }
4222
+ let totalNew = 0;
4223
+ let totalUpdated = 0;
4224
+ let totalServerRejected = 0;
4225
+ let totalTokens = 0;
4226
+ let sessionsWithDailyUsage = 0;
4227
+ let dailyUsageRowsUpserted = 0;
4228
+ for (const batch of batches) {
4229
+ const payload = {
4230
+ sessions: batch,
4231
+ clientVersion: CLIENT_VERSION,
4232
+ syncedAt: new Date().toISOString(),
4233
+ };
4234
+ const result = await apiRequest("/api/mcp/usage/cursor", {
4235
+ method: "POST",
4236
+ apiKey,
4237
+ body: payload,
4238
+ });
4239
+ totalNew += result.sessionsNew;
4240
+ totalUpdated += result.sessionsUpdated;
4241
+ totalServerRejected += result.sessionsRejected || 0;
4242
+ totalTokens += result.totalTokens;
4243
+ sessionsWithDailyUsage += result.sessionsWithDailyUsage || 0;
4244
+ dailyUsageRowsUpserted += result.dailyUsageRowsUpserted || 0;
4245
+ }
4246
+ // Client-side and server-side rejected counts should agree in
4247
+ // normal flow (we filter first, server filters second as
4248
+ // defense-in-depth). If the server rejected more than we did,
4249
+ // surface the total so the user sees the real number.
4250
+ const combinedRejected = Math.max(sessionsRejected, sessionsRejected + totalServerRejected);
4251
+ const result = {
4252
+ success: true,
4253
+ sessionsNew: totalNew,
4254
+ sessionsUpdated: totalUpdated,
4255
+ sessionsRejected: combinedRejected,
4256
+ sessionsParsed: allSessions.length,
4257
+ totalTokens,
4258
+ estimatedCost: 0,
4259
+ sessionsWithDailyUsage: sessionsWithDailyUsage || undefined,
4260
+ dailyUsageRowsUpserted: dailyUsageRowsUpserted || undefined,
4261
+ message: combinedRejected > 0
4262
+ ? `Synced ${totalNew} new and ${totalUpdated} updated sessions (${combinedRejected} skipped — no resolvable timestamp)`
4263
+ : `Synced ${totalNew} new and ${totalUpdated} updated sessions`,
4264
+ filesRead: includeFileList ? filesRead : undefined,
4265
+ };
4266
+ const text = args.format === "json"
4267
+ ? JSON.stringify(result, null, 2)
4268
+ : formatCursorSyncResultMarkdown(result);
4269
+ return {
4270
+ content: [{ type: "text", text }],
4271
+ };
4272
+ }
4273
+ catch (error) {
4274
+ return {
4275
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }],
4276
+ isError: true,
4277
+ };
4278
+ }
4279
+ });
3766
4280
  server.registerTool("costhawk_get_local_codex_usage", {
3767
4281
  description: `Get Codex usage from local sessions WITHOUT uploading to CostHawk. Works offline. Shows token counts by type plus estimated retail cost.\n\nIMPORTANT: Always display the COMPLETE output in your response using markdown tables. Never summarize or truncate.`,
3768
4282
  inputSchema: GetLocalCodexUsageSchema,
@@ -4140,6 +4654,47 @@ async function performCodexAutoSync() {
4140
4654
  console.error(`[Codex Auto-sync] Error: ${error instanceof Error ? error.message : "Unknown error"}`);
4141
4655
  }
4142
4656
  }
4657
+ // Auto-sync function for Cursor usage (optional)
4658
+ async function performCursorAutoSync() {
4659
+ if (!CURSOR_AUTO_SYNC_ENABLED) {
4660
+ return;
4661
+ }
4662
+ const apiKey = DEFAULT_API_KEY;
4663
+ if (!apiKey) {
4664
+ console.error("[Cursor Auto-sync] Skipped: No COSTHAWK_API_KEY configured");
4665
+ return;
4666
+ }
4667
+ if (!cursorDbExists()) {
4668
+ console.error("[Cursor Auto-sync] Skipped: Cursor state.vscdb not found");
4669
+ return;
4670
+ }
4671
+ try {
4672
+ // parseCursorUsage() has no maxAgeHours param — filter client-side
4673
+ // by resolved startTime, same pattern as performLoginSync.
4674
+ const parseResult = parseCursorUsage();
4675
+ const cutoffMs = Date.now() - AUTO_SYNC_MAX_AGE_HOURS * 60 * 60 * 1000;
4676
+ const recent = parseResult.sessions.filter((s) => {
4677
+ if (s.startTime === null)
4678
+ return true; // deferred to "none" filter in helper
4679
+ const startMs = Date.parse(s.startTime);
4680
+ return Number.isFinite(startMs) && startMs >= cutoffMs;
4681
+ });
4682
+ if (recent.length === 0) {
4683
+ console.error("[Cursor Auto-sync] No sessions to sync");
4684
+ return;
4685
+ }
4686
+ const result = await syncCursorSessionsToApi(recent, {
4687
+ apiKey,
4688
+ });
4689
+ console.error(`[Cursor Auto-sync] Success: ${result.totalNew} new, ${result.totalUpdated} updated, ${formatTokens(result.totalTokens)} tokens`);
4690
+ if (result.totalRejected > 0) {
4691
+ console.error(`[Cursor Auto-sync] Skipped ${result.totalRejected} session${result.totalRejected === 1 ? "" : "s"} (no resolvable timestamp)`);
4692
+ }
4693
+ }
4694
+ catch (error) {
4695
+ console.error(`[Cursor Auto-sync] Error: ${error instanceof Error ? error.message : "Unknown error"}`);
4696
+ }
4697
+ }
4143
4698
  // Start auto-sync interval
4144
4699
  function startAutoSync() {
4145
4700
  if (!AUTO_SYNC_ENABLED) {
@@ -4154,11 +4709,13 @@ function startAutoSync() {
4154
4709
  setTimeout(() => {
4155
4710
  performAutoSync();
4156
4711
  performCodexAutoSync();
4712
+ performCursorAutoSync();
4157
4713
  }, 30 * 1000);
4158
4714
  // Then sync every 15 minutes
4159
4715
  autoSyncIntervalId = setInterval(() => {
4160
4716
  performAutoSync();
4161
4717
  performCodexAutoSync();
4718
+ performCursorAutoSync();
4162
4719
  }, AUTO_SYNC_INTERVAL_MS);
4163
4720
  console.error(`[Auto-sync] Enabled: syncing every ${AUTO_SYNC_INTERVAL_MS / 60000} minutes`);
4164
4721
  }
@@ -4194,11 +4751,13 @@ OPTIONS:
4194
4751
  --no-open With --login, skip auto-opening the browser
4195
4752
  --client-name Optional device/client label for login approval screen
4196
4753
  --scopes Optional comma-separated scopes for login token (default: mcp:read,mcp:write,otel:ingest)
4197
- --setup Configure MCP settings (Claude Code / OpenCode)
4754
+ --setup Configure MCP settings (Claude Code / Gemini CLI / OpenCode)
4198
4755
  --opencode Also write OpenCode config during setup
4199
4756
  --no-claude Skip writing Claude Code config during setup
4757
+ --no-gemini Skip writing Gemini CLI config during setup
4200
4758
  --auto-sync Enable Claude Code auto-sync (setup only)
4201
4759
  --codex-sync Enable OpenAI Codex CLI auto-sync (setup only)
4760
+ --cursor-sync Enable Cursor IDE auto-sync (setup only)
4202
4761
  --api-key Provide API key non-interactively (setup only)
4203
4762
  --non-interactive Skip prompts and use defaults (setup only)
4204
4763
 
@@ -4207,14 +4766,18 @@ ENVIRONMENT VARIABLES:
4207
4766
  COSTHAWK_DEBUG Set to "true" to log API requests
4208
4767
  COSTHAWK_AUTO_SYNC Set to "true" to enable automatic local sync
4209
4768
  COSTHAWK_CODEX_AUTO_SYNC Set to "false" to disable Codex auto-sync (requires COSTHAWK_AUTO_SYNC=true)
4769
+ COSTHAWK_CURSOR_AUTO_SYNC Set to "false" to disable Cursor auto-sync (requires COSTHAWK_AUTO_SYNC=true)
4210
4770
  COSTHAWK_CODEX_SESSIONS_DIR Override Codex sessions directory (optional)
4771
+ COSTHAWK_CURSOR_DB_PATH Override Cursor state.vscdb path (optional)
4211
4772
 
4212
4773
  SETUP:
4213
4774
  1. Recommended (browser login + auto-setup):
4214
4775
  npm exec --yes costhawk@latest -- --login
4215
- 2. Manual API key mode:
4776
+ 2. Manual API key mode (Claude Code):
4216
4777
  claude mcp add -s user -e COSTHAWK_API_KEY=your_key -e COSTHAWK_AUTO_SYNC=true costhawk -- npx --yes costhawk@latest
4217
- 3. Interactive setup (writes Claude Code config, optional OpenCode):
4778
+ Manual API key mode (Gemini CLI):
4779
+ gemini mcp add -s user -e COSTHAWK_API_KEY=your_key costhawk npx --yes costhawk@latest
4780
+ 3. Interactive setup (writes Claude Code + Gemini CLI config, optional OpenCode):
4218
4781
  npm exec --yes costhawk@latest -- --setup --opencode
4219
4782
 
4220
4783
  DOCUMENTATION:
@@ -4248,7 +4811,11 @@ DOCUMENTATION:
4248
4811
  exists: codexDirectoryExists(),
4249
4812
  sampleFiles: codexSamples,
4250
4813
  },
4251
- note: "Only file metadata is listed here; transcripts are parsed locally.",
4814
+ cursor: {
4815
+ databasePath: sanitizePathForOutput(getCursorDbPath()),
4816
+ exists: cursorDbExists(),
4817
+ },
4818
+ note: "Only file metadata is listed here; transcripts and Cursor state.vscdb are parsed locally.",
4252
4819
  };
4253
4820
  console.log(JSON.stringify(payload, null, 2));
4254
4821
  process.exit(0);
@@ -4265,11 +4832,13 @@ DOCUMENTATION:
4265
4832
  hasApiKey: Boolean(DEFAULT_API_KEY),
4266
4833
  autoSyncEnabled: AUTO_SYNC_ENABLED,
4267
4834
  codexAutoSyncEnabled: CODEX_AUTO_SYNC_ENABLED,
4835
+ cursorAutoSyncEnabled: CURSOR_AUTO_SYNC_ENABLED,
4268
4836
  debugEnabled: DEBUG_ENABLED,
4269
4837
  },
4270
4838
  local: {
4271
4839
  claudeCodeDir: claudeCodeDirectoryExists(),
4272
4840
  codexDir: codexDirectoryExists(),
4841
+ cursorDb: cursorDbExists(),
4273
4842
  },
4274
4843
  };
4275
4844
  console.log(JSON.stringify(payload, null, 2));