costhawk 1.5.14 → 1.5.16
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 +338 -17
- 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",
|
|
@@ -74,6 +78,40 @@ const WEBHOOK_TYPES = ["SLACK", "DISCORD", "TEAMS", "PAGERDUTY", "CUSTOM"];
|
|
|
74
78
|
function getApiKey(providedKey) {
|
|
75
79
|
return providedKey || DEFAULT_API_KEY || null;
|
|
76
80
|
}
|
|
81
|
+
function extractApiErrorMessage(errorText) {
|
|
82
|
+
const trimmed = errorText.trim();
|
|
83
|
+
if (!trimmed)
|
|
84
|
+
return "Unknown API error";
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(trimmed);
|
|
87
|
+
if (typeof parsed.error === "string" && parsed.error.trim().length > 0) {
|
|
88
|
+
return parsed.error;
|
|
89
|
+
}
|
|
90
|
+
if (typeof parsed.error_description === "string" && parsed.error_description.trim().length > 0) {
|
|
91
|
+
return parsed.error_description;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Fall through to the raw response body.
|
|
96
|
+
}
|
|
97
|
+
return trimmed;
|
|
98
|
+
}
|
|
99
|
+
function formatApiErrorMessage(status, errorText) {
|
|
100
|
+
const message = extractApiErrorMessage(errorText);
|
|
101
|
+
const reconnect = "Reconnect CostHawk by running `npm exec --yes costhawk@latest -- --login`, then restart your MCP client.";
|
|
102
|
+
if (status === 401) {
|
|
103
|
+
if (/revoked/i.test(message)) {
|
|
104
|
+
return `Your CostHawk connection token was revoked. ${reconnect}`;
|
|
105
|
+
}
|
|
106
|
+
if (/expired/i.test(message)) {
|
|
107
|
+
return `Your CostHawk connection token expired. ${reconnect}`;
|
|
108
|
+
}
|
|
109
|
+
if (/invalid api key|invalid authorization|missing or invalid authorization/i.test(message)) {
|
|
110
|
+
return `Your CostHawk connection is not authorized. ${reconnect}`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return `CostHawk API error (${status}): ${message}`;
|
|
114
|
+
}
|
|
77
115
|
// Helper to resolve period to date range
|
|
78
116
|
function resolvePeriod(period, startDate, endDate) {
|
|
79
117
|
// Explicit dates override period
|
|
@@ -359,7 +397,7 @@ async function apiRequest(endpoint, options) {
|
|
|
359
397
|
});
|
|
360
398
|
if (!response.ok) {
|
|
361
399
|
const error = await response.text();
|
|
362
|
-
throw new Error(
|
|
400
|
+
throw new Error(formatApiErrorMessage(response.status, error));
|
|
363
401
|
}
|
|
364
402
|
return response.json();
|
|
365
403
|
}
|
|
@@ -737,6 +775,9 @@ function resolveOpenCodeConfigPath() {
|
|
|
737
775
|
return jsoncPath;
|
|
738
776
|
return jsonPath;
|
|
739
777
|
}
|
|
778
|
+
function resolveGeminiConfigPath() {
|
|
779
|
+
return join(homedir(), ".gemini", "settings.json");
|
|
780
|
+
}
|
|
740
781
|
function stripJsonComments(input) {
|
|
741
782
|
let output = "";
|
|
742
783
|
let i = 0;
|
|
@@ -880,6 +921,30 @@ function writeSecretJsonFile(filePath, data) {
|
|
|
880
921
|
writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, { mode: SECRET_FILE_MODE });
|
|
881
922
|
enforceSecretFilePermissions(filePath);
|
|
882
923
|
}
|
|
924
|
+
function readStoredApiKey() {
|
|
925
|
+
if (!existsSync(AUTH_STATE_PATH))
|
|
926
|
+
return null;
|
|
927
|
+
try {
|
|
928
|
+
const raw = readFileSync(AUTH_STATE_PATH, "utf-8");
|
|
929
|
+
if (raw.trim().length === 0)
|
|
930
|
+
return null;
|
|
931
|
+
const parsed = JSON.parse(raw);
|
|
932
|
+
return typeof parsed.apiKey === "string" && parsed.apiKey.trim().length > 0 ? parsed.apiKey : null;
|
|
933
|
+
}
|
|
934
|
+
catch {
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
function persistApiKey(apiKey) {
|
|
939
|
+
const authDir = dirname(AUTH_STATE_PATH);
|
|
940
|
+
if (!existsSync(authDir)) {
|
|
941
|
+
mkdirSync(authDir, { recursive: true });
|
|
942
|
+
}
|
|
943
|
+
writeSecretJsonFile(AUTH_STATE_PATH, {
|
|
944
|
+
apiKey,
|
|
945
|
+
savedAt: new Date().toISOString(),
|
|
946
|
+
});
|
|
947
|
+
}
|
|
883
948
|
async function promptInput(question) {
|
|
884
949
|
const rl = createInterface({
|
|
885
950
|
input: process.stdin,
|
|
@@ -1005,6 +1070,10 @@ async function runSetup(args) {
|
|
|
1005
1070
|
if (writeClaude === undefined) {
|
|
1006
1071
|
writeClaude = true;
|
|
1007
1072
|
}
|
|
1073
|
+
let writeGemini = parseBooleanFlag(args, "gemini");
|
|
1074
|
+
if (writeGemini === undefined) {
|
|
1075
|
+
writeGemini = true;
|
|
1076
|
+
}
|
|
1008
1077
|
let writeOpenCode = parseBooleanFlag(args, "opencode");
|
|
1009
1078
|
let apiKey = apiKeyArg || DEFAULT_API_KEY || "";
|
|
1010
1079
|
if (!apiKey) {
|
|
@@ -1030,6 +1099,7 @@ async function runSetup(args) {
|
|
|
1030
1099
|
console.error("Error: API key is required to complete setup.");
|
|
1031
1100
|
process.exit(1);
|
|
1032
1101
|
}
|
|
1102
|
+
persistApiKey(apiKey);
|
|
1033
1103
|
let autoSync = parseBooleanFlag(args, "auto-sync");
|
|
1034
1104
|
if (autoSync === undefined) {
|
|
1035
1105
|
if (!writeClaude) {
|
|
@@ -1056,6 +1126,20 @@ async function runSetup(args) {
|
|
|
1056
1126
|
else {
|
|
1057
1127
|
codexSync = false;
|
|
1058
1128
|
}
|
|
1129
|
+
let cursorSync = parseBooleanFlag(args, "cursor-sync");
|
|
1130
|
+
if (autoSync) {
|
|
1131
|
+
if (cursorSync === undefined) {
|
|
1132
|
+
if (!process.stdin.isTTY || nonInteractive) {
|
|
1133
|
+
cursorSync = true;
|
|
1134
|
+
}
|
|
1135
|
+
else {
|
|
1136
|
+
cursorSync = await promptYesNo("Enable Cursor IDE auto-sync?", true);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
else {
|
|
1141
|
+
cursorSync = false;
|
|
1142
|
+
}
|
|
1059
1143
|
if (writeOpenCode === undefined) {
|
|
1060
1144
|
if (!process.stdin.isTTY || nonInteractive) {
|
|
1061
1145
|
writeOpenCode = false;
|
|
@@ -1064,8 +1148,8 @@ async function runSetup(args) {
|
|
|
1064
1148
|
writeOpenCode = await promptYesNo("Also add OpenCode config?", false);
|
|
1065
1149
|
}
|
|
1066
1150
|
}
|
|
1067
|
-
if (!writeClaude && !writeOpenCode) {
|
|
1068
|
-
console.error("Error: No targets selected. Use --claude, --opencode, or
|
|
1151
|
+
if (!writeClaude && !writeGemini && !writeOpenCode) {
|
|
1152
|
+
console.error("Error: No targets selected. Use --claude, --gemini, --opencode, or a combination.");
|
|
1069
1153
|
process.exit(1);
|
|
1070
1154
|
}
|
|
1071
1155
|
const configPaths = writeClaude ? resolveClaudeConfigPaths() : [];
|
|
@@ -1096,17 +1180,19 @@ async function runSetup(args) {
|
|
|
1096
1180
|
: {};
|
|
1097
1181
|
const env = {
|
|
1098
1182
|
...existingEnv,
|
|
1099
|
-
COSTHAWK_API_KEY: apiKey,
|
|
1100
1183
|
};
|
|
1184
|
+
delete env.COSTHAWK_API_KEY;
|
|
1101
1185
|
if (process.env.COSTHAWK_API_URL) {
|
|
1102
1186
|
env.COSTHAWK_API_URL = process.env.COSTHAWK_API_URL;
|
|
1103
1187
|
}
|
|
1104
1188
|
env.COSTHAWK_AUTO_SYNC = autoSync ? "true" : "false";
|
|
1105
1189
|
if (autoSync) {
|
|
1106
1190
|
env.COSTHAWK_CODEX_AUTO_SYNC = codexSync ? "true" : "false";
|
|
1191
|
+
env.COSTHAWK_CURSOR_AUTO_SYNC = cursorSync ? "true" : "false";
|
|
1107
1192
|
}
|
|
1108
1193
|
else {
|
|
1109
1194
|
env.COSTHAWK_CODEX_AUTO_SYNC = "false";
|
|
1195
|
+
env.COSTHAWK_CURSOR_AUTO_SYNC = "false";
|
|
1110
1196
|
}
|
|
1111
1197
|
mcpServers.costhawk = {
|
|
1112
1198
|
type: "stdio",
|
|
@@ -1148,13 +1234,14 @@ async function runSetup(args) {
|
|
|
1148
1234
|
: {};
|
|
1149
1235
|
const environment = {
|
|
1150
1236
|
...existingEnv,
|
|
1151
|
-
COSTHAWK_API_KEY: apiKey,
|
|
1152
1237
|
};
|
|
1238
|
+
delete environment.COSTHAWK_API_KEY;
|
|
1153
1239
|
if (process.env.COSTHAWK_API_URL) {
|
|
1154
1240
|
environment.COSTHAWK_API_URL = process.env.COSTHAWK_API_URL;
|
|
1155
1241
|
}
|
|
1156
1242
|
environment.COSTHAWK_AUTO_SYNC = autoSync ? "true" : "false";
|
|
1157
1243
|
environment.COSTHAWK_CODEX_AUTO_SYNC = autoSync && codexSync ? "true" : "false";
|
|
1244
|
+
environment.COSTHAWK_CURSOR_AUTO_SYNC = autoSync && cursorSync ? "true" : "false";
|
|
1158
1245
|
mcp.costhawk = {
|
|
1159
1246
|
type: "local",
|
|
1160
1247
|
command: ["npx", "--yes", "costhawk@latest"],
|
|
@@ -1164,10 +1251,57 @@ async function runSetup(args) {
|
|
|
1164
1251
|
config.mcp = mcp;
|
|
1165
1252
|
writeSecretJsonFile(openCodePath, config);
|
|
1166
1253
|
}
|
|
1254
|
+
let geminiPath = null;
|
|
1255
|
+
if (writeGemini) {
|
|
1256
|
+
geminiPath = resolveGeminiConfigPath();
|
|
1257
|
+
const geminiDir = dirname(geminiPath);
|
|
1258
|
+
if (!existsSync(geminiDir)) {
|
|
1259
|
+
mkdirSync(geminiDir, { recursive: true });
|
|
1260
|
+
}
|
|
1261
|
+
let config = {};
|
|
1262
|
+
if (existsSync(geminiPath)) {
|
|
1263
|
+
try {
|
|
1264
|
+
config = readJsonFile(geminiPath);
|
|
1265
|
+
}
|
|
1266
|
+
catch (error) {
|
|
1267
|
+
console.error(`Error: Failed to parse ${geminiPath}. Fix the JSON and try again.`);
|
|
1268
|
+
process.exit(1);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
const mcpServers = typeof config.mcpServers === "object" && config.mcpServers !== null
|
|
1272
|
+
? config.mcpServers
|
|
1273
|
+
: {};
|
|
1274
|
+
const existingCosthawk = typeof mcpServers.costhawk === "object" && mcpServers.costhawk !== null
|
|
1275
|
+
? mcpServers.costhawk
|
|
1276
|
+
: null;
|
|
1277
|
+
const existingEnv = existingCosthawk && typeof existingCosthawk.env === "object" && existingCosthawk.env !== null
|
|
1278
|
+
? existingCosthawk.env
|
|
1279
|
+
: {};
|
|
1280
|
+
const env = {
|
|
1281
|
+
...existingEnv,
|
|
1282
|
+
};
|
|
1283
|
+
delete env.COSTHAWK_API_KEY;
|
|
1284
|
+
if (process.env.COSTHAWK_API_URL) {
|
|
1285
|
+
env.COSTHAWK_API_URL = process.env.COSTHAWK_API_URL;
|
|
1286
|
+
}
|
|
1287
|
+
env.COSTHAWK_AUTO_SYNC = autoSync ? "true" : "false";
|
|
1288
|
+
env.COSTHAWK_CODEX_AUTO_SYNC = autoSync && codexSync ? "true" : "false";
|
|
1289
|
+
env.COSTHAWK_CURSOR_AUTO_SYNC = autoSync && cursorSync ? "true" : "false";
|
|
1290
|
+
mcpServers.costhawk = {
|
|
1291
|
+
command: "npx",
|
|
1292
|
+
args: ["-y", "costhawk@latest"],
|
|
1293
|
+
env,
|
|
1294
|
+
};
|
|
1295
|
+
config.mcpServers = mcpServers;
|
|
1296
|
+
writeSecretJsonFile(geminiPath, config);
|
|
1297
|
+
}
|
|
1167
1298
|
console.log("");
|
|
1168
1299
|
if (writeClaude) {
|
|
1169
1300
|
console.log("✅ CostHawk MCP server added to Claude Code!");
|
|
1170
1301
|
}
|
|
1302
|
+
if (writeGemini) {
|
|
1303
|
+
console.log("✅ CostHawk MCP server added to Gemini CLI!");
|
|
1304
|
+
}
|
|
1171
1305
|
if (writeOpenCode) {
|
|
1172
1306
|
console.log("✅ CostHawk MCP server added to OpenCode!");
|
|
1173
1307
|
}
|
|
@@ -1178,14 +1312,22 @@ async function runSetup(args) {
|
|
|
1178
1312
|
if (openCodePath) {
|
|
1179
1313
|
console.log(` OpenCode config: ${openCodePath}`);
|
|
1180
1314
|
}
|
|
1315
|
+
if (geminiPath) {
|
|
1316
|
+
console.log(` Gemini CLI config: ${geminiPath}`);
|
|
1317
|
+
}
|
|
1318
|
+
console.log(` Stored auth: ${AUTH_STATE_PATH}`);
|
|
1181
1319
|
console.log(` Claude Code auto-sync: ${autoSync ? "enabled" : "disabled"}`);
|
|
1182
1320
|
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.");
|
|
1321
|
+
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
1322
|
console.log("");
|
|
1185
1323
|
if (writeClaude) {
|
|
1186
1324
|
console.log("👉 Next step: Restart Claude Code, then ask:");
|
|
1187
1325
|
console.log(' "What\'s my AI API usage this month?"');
|
|
1188
1326
|
}
|
|
1327
|
+
if (writeGemini) {
|
|
1328
|
+
console.log("👉 Next step: Restart Gemini CLI, then run:");
|
|
1329
|
+
console.log(" gemini mcp list");
|
|
1330
|
+
}
|
|
1189
1331
|
if (writeOpenCode) {
|
|
1190
1332
|
console.log("👉 Next step: Restart OpenCode to load MCP servers.");
|
|
1191
1333
|
}
|
|
@@ -1241,6 +1383,90 @@ async function syncSessionsToApi(sessions, options) {
|
|
|
1241
1383
|
}
|
|
1242
1384
|
return { sessionCount: allSessions.length, totalNew, totalUpdated, totalTokens };
|
|
1243
1385
|
}
|
|
1386
|
+
// Cursor-specific sync helper. Cannot reuse `syncSessionsToApi` because the
|
|
1387
|
+
// Cursor parser emits a different shape than Claude Code / Codex:
|
|
1388
|
+
// - no `projectHash`, has `workspaceHash`/`workspaceName` (both nullable)
|
|
1389
|
+
// - nullable `startTime` / `endTime` (must be filtered out pre-send — the
|
|
1390
|
+
// server schema rejects "none" timestampSource as unpersistable)
|
|
1391
|
+
// - extra provenance fields: `timestampSource`, `timestampQuality`,
|
|
1392
|
+
// `dailyUsageSource` at session level, plus per-row `source` on
|
|
1393
|
+
// dailyUsage. These are required by the PR3/PR4 Zod schema on
|
|
1394
|
+
// costcanary's `/api/mcp/usage/cursor` route.
|
|
1395
|
+
// The transform logic here MUST stay in lockstep with the transform in
|
|
1396
|
+
// the `costhawk_sync_cursor_usage` tool registration (below) so both
|
|
1397
|
+
// code paths send an identical wire shape.
|
|
1398
|
+
async function syncCursorSessionsToApi(sessions, options) {
|
|
1399
|
+
// Client-side defense-in-depth: drop sessions the server would reject.
|
|
1400
|
+
// timestampSource === "none" means the parser couldn't resolve either
|
|
1401
|
+
// bubble-level or composer-level createdAt. CursorSession.startTime is
|
|
1402
|
+
// NOT NULL on the backend so these would 400. Same filter as the
|
|
1403
|
+
// costhawk_sync_cursor_usage tool.
|
|
1404
|
+
const persistable = sessions.filter((s) => s.timestampSource !== "none" &&
|
|
1405
|
+
s.startTime !== null &&
|
|
1406
|
+
s.endTime !== null);
|
|
1407
|
+
const totalRejected = sessions.length - persistable.length;
|
|
1408
|
+
if (persistable.length === 0) {
|
|
1409
|
+
return { sessionCount: 0, totalNew: 0, totalUpdated: 0, totalTokens: 0, totalRejected };
|
|
1410
|
+
}
|
|
1411
|
+
// Transform parser's Record<date, TokenUsage> into the wire
|
|
1412
|
+
// Array<{date, ..., source}> shape expected by the API route.
|
|
1413
|
+
const wireSessions = persistable.map((session) => {
|
|
1414
|
+
const dailyUsageArray = Object.entries(session.dailyUsage).map(([date, tokens]) => ({
|
|
1415
|
+
date,
|
|
1416
|
+
inputTokens: tokens.inputTokens,
|
|
1417
|
+
outputTokens: tokens.outputTokens,
|
|
1418
|
+
cacheCreationTokens: tokens.cacheCreationTokens,
|
|
1419
|
+
cacheReadTokens: tokens.cacheReadTokens,
|
|
1420
|
+
source: session.dailyUsageSource === "none"
|
|
1421
|
+
? "bubble"
|
|
1422
|
+
: session.dailyUsageSource,
|
|
1423
|
+
}));
|
|
1424
|
+
return {
|
|
1425
|
+
sessionId: session.sessionId,
|
|
1426
|
+
workspaceHash: session.workspaceHash,
|
|
1427
|
+
workspaceName: session.workspaceName,
|
|
1428
|
+
model: session.model,
|
|
1429
|
+
inputTokens: session.tokens.inputTokens,
|
|
1430
|
+
outputTokens: session.tokens.outputTokens,
|
|
1431
|
+
cacheCreationTokens: session.tokens.cacheCreationTokens,
|
|
1432
|
+
cacheReadTokens: session.tokens.cacheReadTokens,
|
|
1433
|
+
messageCount: session.messageCount,
|
|
1434
|
+
startTime: session.startTime,
|
|
1435
|
+
endTime: session.endTime,
|
|
1436
|
+
timestampSource: session.timestampSource,
|
|
1437
|
+
timestampQuality: session.timestampQuality,
|
|
1438
|
+
dailyUsageSource: session.dailyUsageSource,
|
|
1439
|
+
dailyUsage: dailyUsageArray.length > 0 ? dailyUsageArray : undefined,
|
|
1440
|
+
source: "cursor",
|
|
1441
|
+
};
|
|
1442
|
+
});
|
|
1443
|
+
const BATCH_SIZE = 100;
|
|
1444
|
+
let totalNew = 0;
|
|
1445
|
+
let totalUpdated = 0;
|
|
1446
|
+
let totalTokens = 0;
|
|
1447
|
+
for (let i = 0; i < wireSessions.length; i += BATCH_SIZE) {
|
|
1448
|
+
const batch = wireSessions.slice(i, i + BATCH_SIZE);
|
|
1449
|
+
const result = await apiRequest("/api/mcp/usage/cursor", {
|
|
1450
|
+
method: "POST",
|
|
1451
|
+
apiKey: options.apiKey,
|
|
1452
|
+
body: {
|
|
1453
|
+
sessions: batch,
|
|
1454
|
+
clientVersion: CLIENT_VERSION,
|
|
1455
|
+
syncedAt: new Date().toISOString(),
|
|
1456
|
+
},
|
|
1457
|
+
});
|
|
1458
|
+
totalNew += result.sessionsNew;
|
|
1459
|
+
totalUpdated += result.sessionsUpdated;
|
|
1460
|
+
totalTokens += result.totalTokens;
|
|
1461
|
+
}
|
|
1462
|
+
return {
|
|
1463
|
+
sessionCount: wireSessions.length,
|
|
1464
|
+
totalNew,
|
|
1465
|
+
totalUpdated,
|
|
1466
|
+
totalTokens,
|
|
1467
|
+
totalRejected,
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1244
1470
|
// Perform a one-time sync during --login so data appears in the dashboard immediately.
|
|
1245
1471
|
async function performLoginSync(apiKey) {
|
|
1246
1472
|
let totalSynced = 0;
|
|
@@ -1279,6 +1505,36 @@ async function performLoginSync(apiKey) {
|
|
|
1279
1505
|
console.error(` Warning: Codex sync failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1280
1506
|
}
|
|
1281
1507
|
}
|
|
1508
|
+
if (cursorDbExists()) {
|
|
1509
|
+
try {
|
|
1510
|
+
// parseCursorUsage() reads the whole Cursor SQLite in one pass. The
|
|
1511
|
+
// LOGIN_SYNC_MAX_AGE_HOURS window is applied client-side below to
|
|
1512
|
+
// avoid syncing sessions older than 30 days on first login. The
|
|
1513
|
+
// parser itself doesn't accept a maxAgeHours param — we filter by
|
|
1514
|
+
// resolved startTime here the same way the sync tool does.
|
|
1515
|
+
const parseResult = parseCursorUsage();
|
|
1516
|
+
const cutoffMs = Date.now() - LOGIN_SYNC_MAX_AGE_HOURS * 60 * 60 * 1000;
|
|
1517
|
+
const recent = parseResult.sessions.filter((s) => {
|
|
1518
|
+
if (s.startTime === null)
|
|
1519
|
+
return true; // defer to "none" filter inside helper
|
|
1520
|
+
const startMs = Date.parse(s.startTime);
|
|
1521
|
+
return Number.isFinite(startMs) && startMs >= cutoffMs;
|
|
1522
|
+
});
|
|
1523
|
+
const result = await syncCursorSessionsToApi(recent, {
|
|
1524
|
+
apiKey,
|
|
1525
|
+
});
|
|
1526
|
+
totalSynced += result.sessionCount;
|
|
1527
|
+
if (result.sessionCount > 0) {
|
|
1528
|
+
console.log(` Synced ${result.sessionCount} Cursor session${result.sessionCount === 1 ? "" : "s"}`);
|
|
1529
|
+
}
|
|
1530
|
+
if (result.totalRejected > 0) {
|
|
1531
|
+
console.log(` Skipped ${result.totalRejected} Cursor session${result.totalRejected === 1 ? "" : "s"} (no resolvable timestamp)`);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
catch (error) {
|
|
1535
|
+
console.error(` Warning: Cursor sync failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1282
1538
|
if (totalSynced > 0) {
|
|
1283
1539
|
console.log(`\n✅ ${totalSynced} session${totalSynced === 1 ? "" : "s"} synced to your CostHawk dashboard.`);
|
|
1284
1540
|
}
|
|
@@ -1296,18 +1552,23 @@ async function runLoginCommand(args) {
|
|
|
1296
1552
|
console.error(`Error: ${error instanceof Error ? error.message : "Browser login failed."}`);
|
|
1297
1553
|
process.exit(1);
|
|
1298
1554
|
}
|
|
1555
|
+
persistApiKey(apiKey);
|
|
1299
1556
|
if (shouldRunSetup) {
|
|
1300
1557
|
const setupArgs = ["--api-key", apiKey];
|
|
1301
1558
|
const passthroughFlags = [
|
|
1302
1559
|
"--non-interactive",
|
|
1303
1560
|
"--claude",
|
|
1304
1561
|
"--no-claude",
|
|
1562
|
+
"--gemini",
|
|
1563
|
+
"--no-gemini",
|
|
1305
1564
|
"--opencode",
|
|
1306
1565
|
"--no-opencode",
|
|
1307
1566
|
"--auto-sync",
|
|
1308
1567
|
"--no-auto-sync",
|
|
1309
1568
|
"--codex-sync",
|
|
1310
1569
|
"--no-codex-sync",
|
|
1570
|
+
"--cursor-sync",
|
|
1571
|
+
"--no-cursor-sync",
|
|
1311
1572
|
];
|
|
1312
1573
|
for (const flag of passthroughFlags) {
|
|
1313
1574
|
if (args.includes(flag)) {
|
|
@@ -1321,9 +1582,10 @@ async function runLoginCommand(args) {
|
|
|
1321
1582
|
}
|
|
1322
1583
|
console.log("");
|
|
1323
1584
|
console.log("✅ Login successful.");
|
|
1324
|
-
console.log("
|
|
1585
|
+
console.log(" Credentials saved locally (masked token):");
|
|
1325
1586
|
console.log(` COSTHAWK_API_KEY=${maskSecret(apiKey)}`);
|
|
1326
1587
|
console.log(" Full token is intentionally not printed for security.");
|
|
1588
|
+
console.log(` Stored auth file: ${AUTH_STATE_PATH}`);
|
|
1327
1589
|
console.log("");
|
|
1328
1590
|
console.log(" Or run: npm exec --yes costhawk@latest -- --setup");
|
|
1329
1591
|
console.log("");
|
|
@@ -1332,7 +1594,8 @@ async function runLoginCommand(args) {
|
|
|
1332
1594
|
}
|
|
1333
1595
|
// Markdown formatters
|
|
1334
1596
|
function formatUsageSummaryMarkdown(data) {
|
|
1335
|
-
let output = `# Usage Summary\n\n`;
|
|
1597
|
+
let output = `# API Usage Summary\n\n`;
|
|
1598
|
+
output += `This report covers CostHawk-tracked API requests from wrapped/proxied keys and provider-ingested API usage. It does not include local Claude Code, Codex, or Cursor sessions; use the local usage, sync, ROI, or savings tools for those.\n\n`;
|
|
1336
1599
|
output += `**Period:** ${data.period.start} to ${data.period.end}\n\n`;
|
|
1337
1600
|
output += `## Overview\n`;
|
|
1338
1601
|
output += `| Metric | Value |\n|--------|-------|\n`;
|
|
@@ -1340,6 +1603,9 @@ function formatUsageSummaryMarkdown(data) {
|
|
|
1340
1603
|
output += `| Total API Calls | ${formatNumber(data.totalCalls)} |\n`;
|
|
1341
1604
|
output += `| Input Tokens | ${formatNumber(data.totalInputTokens)} |\n`;
|
|
1342
1605
|
output += `| Output Tokens | ${formatNumber(data.totalOutputTokens)} |\n\n`;
|
|
1606
|
+
if (data.totalCalls === 0 && data.totalInputTokens === 0 && data.totalOutputTokens === 0) {
|
|
1607
|
+
output += `No API-request usage was recorded for this period. This does not mean local AI-tool usage is zero; Claude Code, Codex, and Cursor are tracked in separate local-tool reports.\n\n`;
|
|
1608
|
+
}
|
|
1343
1609
|
output += `## By Provider\n`;
|
|
1344
1610
|
output += `| Provider | Cost | Calls |\n|----------|------|-------|\n`;
|
|
1345
1611
|
for (const [provider, stats] of Object.entries(data.byProvider)) {
|
|
@@ -2697,7 +2963,7 @@ const ListIntegrationsSchema = {
|
|
|
2697
2963
|
};
|
|
2698
2964
|
// Register tools
|
|
2699
2965
|
server.registerTool("costhawk_get_usage_summary", {
|
|
2700
|
-
description: `Get a summary of
|
|
2966
|
+
description: `Get a summary of CostHawk-tracked API request usage over a time period. This covers wrapped/proxied keys and provider-ingested API usage stored as API requests. It does NOT include local Claude Code, Codex, or Cursor sessions; for local AI-tool token usage, use costhawk_get_local_claude_code_usage, costhawk_get_local_codex_usage, costhawk_get_local_cursor_usage, costhawk_get_local_roi_report, or costhawk_get_savings after syncing. Returns total API costs, API calls, and API token usage broken down by provider and model. Supports preset periods (last_24h, today, yesterday, last_7d, last_30d) or custom date ranges.\n\nIMPORTANT: Always display the COMPLETE output in your response using markdown tables. Render every section (Overview, By Provider, By Model). Never summarize or truncate. If this report is zero, do not say the user's local AI-tool usage is zero; explain that local Claude Code, Codex, and Cursor usage is reported separately.`,
|
|
2701
2967
|
inputSchema: UsageSummarySchema,
|
|
2702
2968
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
2703
2969
|
}, async (args, _extra) => {
|
|
@@ -4370,7 +4636,7 @@ server.registerTool("costhawk_list_integrations", {
|
|
|
4370
4636
|
async function performAutoSync() {
|
|
4371
4637
|
const apiKey = DEFAULT_API_KEY;
|
|
4372
4638
|
if (!apiKey) {
|
|
4373
|
-
console.error("[Auto-sync] Skipped: No
|
|
4639
|
+
console.error("[Auto-sync] Skipped: No CostHawk auth token found. Run `npm exec --yes costhawk@latest -- --login`.");
|
|
4374
4640
|
return;
|
|
4375
4641
|
}
|
|
4376
4642
|
if (!claudeCodeDirectoryExists()) {
|
|
@@ -4401,7 +4667,7 @@ async function performCodexAutoSync() {
|
|
|
4401
4667
|
}
|
|
4402
4668
|
const apiKey = DEFAULT_API_KEY;
|
|
4403
4669
|
if (!apiKey) {
|
|
4404
|
-
console.error("[Codex Auto-sync] Skipped: No
|
|
4670
|
+
console.error("[Codex Auto-sync] Skipped: No CostHawk auth token found. Run `npm exec --yes costhawk@latest -- --login`.");
|
|
4405
4671
|
return;
|
|
4406
4672
|
}
|
|
4407
4673
|
if (!codexDirectoryExists()) {
|
|
@@ -4426,6 +4692,47 @@ async function performCodexAutoSync() {
|
|
|
4426
4692
|
console.error(`[Codex Auto-sync] Error: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
4427
4693
|
}
|
|
4428
4694
|
}
|
|
4695
|
+
// Auto-sync function for Cursor usage (optional)
|
|
4696
|
+
async function performCursorAutoSync() {
|
|
4697
|
+
if (!CURSOR_AUTO_SYNC_ENABLED) {
|
|
4698
|
+
return;
|
|
4699
|
+
}
|
|
4700
|
+
const apiKey = DEFAULT_API_KEY;
|
|
4701
|
+
if (!apiKey) {
|
|
4702
|
+
console.error("[Cursor Auto-sync] Skipped: No CostHawk auth token found. Run `npm exec --yes costhawk@latest -- --login`.");
|
|
4703
|
+
return;
|
|
4704
|
+
}
|
|
4705
|
+
if (!cursorDbExists()) {
|
|
4706
|
+
console.error("[Cursor Auto-sync] Skipped: Cursor state.vscdb not found");
|
|
4707
|
+
return;
|
|
4708
|
+
}
|
|
4709
|
+
try {
|
|
4710
|
+
// parseCursorUsage() has no maxAgeHours param — filter client-side
|
|
4711
|
+
// by resolved startTime, same pattern as performLoginSync.
|
|
4712
|
+
const parseResult = parseCursorUsage();
|
|
4713
|
+
const cutoffMs = Date.now() - AUTO_SYNC_MAX_AGE_HOURS * 60 * 60 * 1000;
|
|
4714
|
+
const recent = parseResult.sessions.filter((s) => {
|
|
4715
|
+
if (s.startTime === null)
|
|
4716
|
+
return true; // deferred to "none" filter in helper
|
|
4717
|
+
const startMs = Date.parse(s.startTime);
|
|
4718
|
+
return Number.isFinite(startMs) && startMs >= cutoffMs;
|
|
4719
|
+
});
|
|
4720
|
+
if (recent.length === 0) {
|
|
4721
|
+
console.error("[Cursor Auto-sync] No sessions to sync");
|
|
4722
|
+
return;
|
|
4723
|
+
}
|
|
4724
|
+
const result = await syncCursorSessionsToApi(recent, {
|
|
4725
|
+
apiKey,
|
|
4726
|
+
});
|
|
4727
|
+
console.error(`[Cursor Auto-sync] Success: ${result.totalNew} new, ${result.totalUpdated} updated, ${formatTokens(result.totalTokens)} tokens`);
|
|
4728
|
+
if (result.totalRejected > 0) {
|
|
4729
|
+
console.error(`[Cursor Auto-sync] Skipped ${result.totalRejected} session${result.totalRejected === 1 ? "" : "s"} (no resolvable timestamp)`);
|
|
4730
|
+
}
|
|
4731
|
+
}
|
|
4732
|
+
catch (error) {
|
|
4733
|
+
console.error(`[Cursor Auto-sync] Error: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4429
4736
|
// Start auto-sync interval
|
|
4430
4737
|
function startAutoSync() {
|
|
4431
4738
|
if (!AUTO_SYNC_ENABLED) {
|
|
@@ -4433,18 +4740,20 @@ function startAutoSync() {
|
|
|
4433
4740
|
return;
|
|
4434
4741
|
}
|
|
4435
4742
|
if (!DEFAULT_API_KEY) {
|
|
4436
|
-
console.error("[Auto-sync] Disabled: No
|
|
4743
|
+
console.error("[Auto-sync] Disabled: No CostHawk auth token found. Run `npm exec --yes costhawk@latest -- --login`.");
|
|
4437
4744
|
return;
|
|
4438
4745
|
}
|
|
4439
4746
|
// Perform initial sync after 30 seconds (give server time to fully start)
|
|
4440
4747
|
setTimeout(() => {
|
|
4441
4748
|
performAutoSync();
|
|
4442
4749
|
performCodexAutoSync();
|
|
4750
|
+
performCursorAutoSync();
|
|
4443
4751
|
}, 30 * 1000);
|
|
4444
4752
|
// Then sync every 15 minutes
|
|
4445
4753
|
autoSyncIntervalId = setInterval(() => {
|
|
4446
4754
|
performAutoSync();
|
|
4447
4755
|
performCodexAutoSync();
|
|
4756
|
+
performCursorAutoSync();
|
|
4448
4757
|
}, AUTO_SYNC_INTERVAL_MS);
|
|
4449
4758
|
console.error(`[Auto-sync] Enabled: syncing every ${AUTO_SYNC_INTERVAL_MS / 60000} minutes`);
|
|
4450
4759
|
}
|
|
@@ -4480,11 +4789,13 @@ OPTIONS:
|
|
|
4480
4789
|
--no-open With --login, skip auto-opening the browser
|
|
4481
4790
|
--client-name Optional device/client label for login approval screen
|
|
4482
4791
|
--scopes Optional comma-separated scopes for login token (default: mcp:read,mcp:write,otel:ingest)
|
|
4483
|
-
--setup Configure MCP settings (Claude Code / OpenCode)
|
|
4792
|
+
--setup Configure MCP settings (Claude Code / Gemini CLI / OpenCode)
|
|
4484
4793
|
--opencode Also write OpenCode config during setup
|
|
4485
4794
|
--no-claude Skip writing Claude Code config during setup
|
|
4795
|
+
--no-gemini Skip writing Gemini CLI config during setup
|
|
4486
4796
|
--auto-sync Enable Claude Code auto-sync (setup only)
|
|
4487
4797
|
--codex-sync Enable OpenAI Codex CLI auto-sync (setup only)
|
|
4798
|
+
--cursor-sync Enable Cursor IDE auto-sync (setup only)
|
|
4488
4799
|
--api-key Provide API key non-interactively (setup only)
|
|
4489
4800
|
--non-interactive Skip prompts and use defaults (setup only)
|
|
4490
4801
|
|
|
@@ -4493,14 +4804,18 @@ ENVIRONMENT VARIABLES:
|
|
|
4493
4804
|
COSTHAWK_DEBUG Set to "true" to log API requests
|
|
4494
4805
|
COSTHAWK_AUTO_SYNC Set to "true" to enable automatic local sync
|
|
4495
4806
|
COSTHAWK_CODEX_AUTO_SYNC Set to "false" to disable Codex auto-sync (requires COSTHAWK_AUTO_SYNC=true)
|
|
4807
|
+
COSTHAWK_CURSOR_AUTO_SYNC Set to "false" to disable Cursor auto-sync (requires COSTHAWK_AUTO_SYNC=true)
|
|
4496
4808
|
COSTHAWK_CODEX_SESSIONS_DIR Override Codex sessions directory (optional)
|
|
4809
|
+
COSTHAWK_CURSOR_DB_PATH Override Cursor state.vscdb path (optional)
|
|
4497
4810
|
|
|
4498
4811
|
SETUP:
|
|
4499
4812
|
1. Recommended (browser login + auto-setup):
|
|
4500
4813
|
npm exec --yes costhawk@latest -- --login
|
|
4501
|
-
2. Manual API key mode:
|
|
4814
|
+
2. Manual API key mode (Claude Code):
|
|
4502
4815
|
claude mcp add -s user -e COSTHAWK_API_KEY=your_key -e COSTHAWK_AUTO_SYNC=true costhawk -- npx --yes costhawk@latest
|
|
4503
|
-
|
|
4816
|
+
Manual API key mode (Gemini CLI):
|
|
4817
|
+
gemini mcp add -s user -e COSTHAWK_API_KEY=your_key costhawk npx --yes costhawk@latest
|
|
4818
|
+
3. Interactive setup (writes Claude Code + Gemini CLI config, optional OpenCode):
|
|
4504
4819
|
npm exec --yes costhawk@latest -- --setup --opencode
|
|
4505
4820
|
|
|
4506
4821
|
DOCUMENTATION:
|
|
@@ -4534,7 +4849,11 @@ DOCUMENTATION:
|
|
|
4534
4849
|
exists: codexDirectoryExists(),
|
|
4535
4850
|
sampleFiles: codexSamples,
|
|
4536
4851
|
},
|
|
4537
|
-
|
|
4852
|
+
cursor: {
|
|
4853
|
+
databasePath: sanitizePathForOutput(getCursorDbPath()),
|
|
4854
|
+
exists: cursorDbExists(),
|
|
4855
|
+
},
|
|
4856
|
+
note: "Only file metadata is listed here; transcripts and Cursor state.vscdb are parsed locally.",
|
|
4538
4857
|
};
|
|
4539
4858
|
console.log(JSON.stringify(payload, null, 2));
|
|
4540
4859
|
process.exit(0);
|
|
@@ -4551,11 +4870,13 @@ DOCUMENTATION:
|
|
|
4551
4870
|
hasApiKey: Boolean(DEFAULT_API_KEY),
|
|
4552
4871
|
autoSyncEnabled: AUTO_SYNC_ENABLED,
|
|
4553
4872
|
codexAutoSyncEnabled: CODEX_AUTO_SYNC_ENABLED,
|
|
4873
|
+
cursorAutoSyncEnabled: CURSOR_AUTO_SYNC_ENABLED,
|
|
4554
4874
|
debugEnabled: DEBUG_ENABLED,
|
|
4555
4875
|
},
|
|
4556
4876
|
local: {
|
|
4557
4877
|
claudeCodeDir: claudeCodeDirectoryExists(),
|
|
4558
4878
|
codexDir: codexDirectoryExists(),
|
|
4879
|
+
cursorDb: cursorDbExists(),
|
|
4559
4880
|
},
|
|
4560
4881
|
};
|
|
4561
4882
|
console.log(JSON.stringify(payload, null, 2));
|