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 +5 -1
- package/dist/build-info.d.ts +1 -1
- package/dist/build-info.js +1 -1
- package/dist/index.js +294 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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.
|
package/dist/build-info.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const BUILD_COMMIT_SHA = "
|
|
1
|
+
export declare const BUILD_COMMIT_SHA = "7a15693";
|
|
2
2
|
//# sourceMappingURL=build-info.d.ts.map
|
package/dist/build-info.js
CHANGED
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("");
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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));
|