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/README.md +5 -1
- package/dist/build-info.d.ts +1 -1
- package/dist/build-info.js +1 -1
- package/dist/index.js +580 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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));
|