costhawk 1.5.14 → 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/README.md CHANGED
@@ -168,9 +168,13 @@ COSTHAWK_GIT_SHA=$(git rev-parse --short HEAD) npm run build
168
168
  | Tool | Description |
169
169
  |------|-------------|
170
170
  | `costhawk_get_usage_summary` | Get usage and costs over a time period (by provider/model) |
171
- | `costhawk_get_usage_by_tag` | Get usage grouped by custom tags (user_id, feature, etc.) |
171
+ | `costhawk_get_usage_by_tag` | Get usage grouped by attribution fields and custom metadata (`project`, `feature`, `team`, `environment`, `user_id`, etc.) |
172
172
  | `costhawk_detect_anomalies` | Check for cost anomalies and unusual usage patterns |
173
173
 
174
+ `costhawk_get_usage_by_tag` has two data sources:
175
+ - Standard attribution fields like `feature`, `project`, `team`, and `environment` can come from wrapped-key or scoped API key attribution.
176
+ - Arbitrary custom tag keys only appear when you send request metadata through the proxy, such as `costhawk_metadata.feature` or `costhawk_metadata.user_id`.
177
+
174
178
  ### Claude Code Local Tracking (Optional Auto-Sync)
175
179
 
176
180
  These tools parse your local Claude Code transcripts from `~/.claude/projects/` to track token usage - including the 4 token types Claude Code uses.
@@ -1,2 +1,2 @@
1
- export declare const BUILD_COMMIT_SHA = "5009d40";
1
+ export declare const BUILD_COMMIT_SHA = "7a15693";
2
2
  //# sourceMappingURL=build-info.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Auto-generated during release builds. Values may be empty in dev.
2
- export const BUILD_COMMIT_SHA = "5009d40";
2
+ export const BUILD_COMMIT_SHA = "7a15693";
3
3
  //# sourceMappingURL=build-info.js.map
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("");
@@ -4426,6 +4654,47 @@ async function performCodexAutoSync() {
4426
4654
  console.error(`[Codex Auto-sync] Error: ${error instanceof Error ? error.message : "Unknown error"}`);
4427
4655
  }
4428
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
+ }
4429
4698
  // Start auto-sync interval
4430
4699
  function startAutoSync() {
4431
4700
  if (!AUTO_SYNC_ENABLED) {
@@ -4440,11 +4709,13 @@ function startAutoSync() {
4440
4709
  setTimeout(() => {
4441
4710
  performAutoSync();
4442
4711
  performCodexAutoSync();
4712
+ performCursorAutoSync();
4443
4713
  }, 30 * 1000);
4444
4714
  // Then sync every 15 minutes
4445
4715
  autoSyncIntervalId = setInterval(() => {
4446
4716
  performAutoSync();
4447
4717
  performCodexAutoSync();
4718
+ performCursorAutoSync();
4448
4719
  }, AUTO_SYNC_INTERVAL_MS);
4449
4720
  console.error(`[Auto-sync] Enabled: syncing every ${AUTO_SYNC_INTERVAL_MS / 60000} minutes`);
4450
4721
  }
@@ -4480,11 +4751,13 @@ OPTIONS:
4480
4751
  --no-open With --login, skip auto-opening the browser
4481
4752
  --client-name Optional device/client label for login approval screen
4482
4753
  --scopes Optional comma-separated scopes for login token (default: mcp:read,mcp:write,otel:ingest)
4483
- --setup Configure MCP settings (Claude Code / OpenCode)
4754
+ --setup Configure MCP settings (Claude Code / Gemini CLI / OpenCode)
4484
4755
  --opencode Also write OpenCode config during setup
4485
4756
  --no-claude Skip writing Claude Code config during setup
4757
+ --no-gemini Skip writing Gemini CLI config during setup
4486
4758
  --auto-sync Enable Claude Code auto-sync (setup only)
4487
4759
  --codex-sync Enable OpenAI Codex CLI auto-sync (setup only)
4760
+ --cursor-sync Enable Cursor IDE auto-sync (setup only)
4488
4761
  --api-key Provide API key non-interactively (setup only)
4489
4762
  --non-interactive Skip prompts and use defaults (setup only)
4490
4763
 
@@ -4493,14 +4766,18 @@ ENVIRONMENT VARIABLES:
4493
4766
  COSTHAWK_DEBUG Set to "true" to log API requests
4494
4767
  COSTHAWK_AUTO_SYNC Set to "true" to enable automatic local sync
4495
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)
4496
4770
  COSTHAWK_CODEX_SESSIONS_DIR Override Codex sessions directory (optional)
4771
+ COSTHAWK_CURSOR_DB_PATH Override Cursor state.vscdb path (optional)
4497
4772
 
4498
4773
  SETUP:
4499
4774
  1. Recommended (browser login + auto-setup):
4500
4775
  npm exec --yes costhawk@latest -- --login
4501
- 2. Manual API key mode:
4776
+ 2. Manual API key mode (Claude Code):
4502
4777
  claude mcp add -s user -e COSTHAWK_API_KEY=your_key -e COSTHAWK_AUTO_SYNC=true costhawk -- npx --yes costhawk@latest
4503
- 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):
4504
4781
  npm exec --yes costhawk@latest -- --setup --opencode
4505
4782
 
4506
4783
  DOCUMENTATION:
@@ -4534,7 +4811,11 @@ DOCUMENTATION:
4534
4811
  exists: codexDirectoryExists(),
4535
4812
  sampleFiles: codexSamples,
4536
4813
  },
4537
- 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.",
4538
4819
  };
4539
4820
  console.log(JSON.stringify(payload, null, 2));
4540
4821
  process.exit(0);
@@ -4551,11 +4832,13 @@ DOCUMENTATION:
4551
4832
  hasApiKey: Boolean(DEFAULT_API_KEY),
4552
4833
  autoSyncEnabled: AUTO_SYNC_ENABLED,
4553
4834
  codexAutoSyncEnabled: CODEX_AUTO_SYNC_ENABLED,
4835
+ cursorAutoSyncEnabled: CURSOR_AUTO_SYNC_ENABLED,
4554
4836
  debugEnabled: DEBUG_ENABLED,
4555
4837
  },
4556
4838
  local: {
4557
4839
  claudeCodeDir: claudeCodeDirectoryExists(),
4558
4840
  codexDir: codexDirectoryExists(),
4841
+ cursorDb: cursorDbExists(),
4559
4842
  },
4560
4843
  };
4561
4844
  console.log(JSON.stringify(payload, null, 2));