@costlens/mcp-server 0.4.3 → 0.6.0

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.
Files changed (3) hide show
  1. package/dist/cli.js +378 -136
  2. package/dist/index.js +110 -110
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -39,31 +39,41 @@ function resolveApiKey() {
39
39
  return void 0;
40
40
  }
41
41
  }
42
+ async function apiFetch(path, opts = {}) {
43
+ const headers = { "Authorization": `Bearer ${API_KEY}`, ...opts.headers };
44
+ if (opts.body) headers["Content-Type"] = "application/json";
45
+ return fetch(`${API_BASE}${path}`, { ...opts, headers, signal: AbortSignal.timeout(5e3) });
46
+ }
42
47
  async function loadPricing() {
43
48
  try {
44
- const headers = {};
45
- if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`;
46
- const res = await fetch(`${API_BASE}/v1/pricing`, { headers, signal: AbortSignal.timeout(5e3) });
49
+ const res = await apiFetch("/v1/pricing");
47
50
  if (res.ok) {
48
51
  const data = await res.json();
49
- for (const m of data.models) {
50
- import_classifier.PRICING[m.model] = { input: m.input, output: m.output };
51
- }
52
+ for (const m of data.models) import_classifier.PRICING[m.model] = { input: m.input, output: m.output };
52
53
  }
53
54
  } catch {
54
55
  }
55
56
  }
57
+ async function loadKeySettings() {
58
+ if (!API_KEY) return;
59
+ try {
60
+ const res = await apiFetch("/v1/plan");
61
+ if (!res.ok) return;
62
+ const data = await res.json();
63
+ trackingMode = data.keySettings?.trackingMode || "manual";
64
+ productivityEnabled = data.features?.includes("productivity_basic") ?? false;
65
+ } catch {
66
+ }
67
+ }
56
68
  function startHeartbeat(sessionId) {
57
69
  stopHeartbeat();
58
70
  activeSessionId = sessionId;
59
71
  heartbeatInterval = setInterval(async () => {
60
72
  if (!activeSessionId || !API_KEY) return;
61
73
  try {
62
- const res = await fetch(`${API_BASE}/v1/productivity/heartbeat`, {
74
+ const res = await apiFetch("/v1/productivity/heartbeat", {
63
75
  method: "POST",
64
- headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
65
- body: JSON.stringify({ session_id: activeSessionId }),
66
- signal: AbortSignal.timeout(5e3)
76
+ body: JSON.stringify({ session_id: activeSessionId })
67
77
  });
68
78
  if (res.status === 410) {
69
79
  activeSessionId = null;
@@ -79,58 +89,65 @@ function stopHeartbeat() {
79
89
  heartbeatInterval = null;
80
90
  }
81
91
  }
82
- async function syncEvent(result, params) {
92
+ async function ensureAutoSession() {
93
+ if (trackingMode !== "auto" || !API_KEY || !productivityEnabled || activeSessionId) return;
83
94
  try {
84
- await fetch(`${API_BASE}/v1/classifier/events`, {
95
+ const res = await apiFetch("/v1/productivity/sessions", {
85
96
  method: "POST",
86
- headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
87
- body: JSON.stringify({
88
- complexity: result.level,
89
- confidence: result.confidence,
90
- suggestedModel: result.suggestedModel,
91
- currentModel: params.currentModel,
92
- provider: params.provider,
93
- staticScore: result.signals.staticScore
94
- }),
95
- signal: AbortSignal.timeout(5e3)
97
+ body: JSON.stringify({ task_description: "Auto-tracked MCP session" })
96
98
  });
99
+ if (res.ok) {
100
+ const data = await res.json();
101
+ if (data.data?.id) startHeartbeat(data.data.id);
102
+ }
97
103
  } catch {
98
104
  }
99
105
  }
100
- async function checkFeature(feature) {
101
- if (!API_KEY) return false;
106
+ async function autoLogEvent(eventType, metadata) {
107
+ if (trackingMode !== "auto" || !activeSessionId || !API_KEY) return;
102
108
  try {
103
- const res = await fetch(`${API_BASE}/v1/plan`, {
104
- headers: { "Authorization": `Bearer ${API_KEY}` },
105
- signal: AbortSignal.timeout(5e3)
109
+ await apiFetch("/v1/productivity/events", {
110
+ method: "POST",
111
+ body: JSON.stringify({ session_id: activeSessionId, event_type: eventType, metadata })
106
112
  });
107
- if (!res.ok) return false;
108
- const plan = await res.json();
109
- return plan.features?.includes(feature) ?? false;
110
113
  } catch {
111
- return false;
114
+ }
115
+ }
116
+ async function onToolCall(toolName) {
117
+ toolCallCount++;
118
+ if (trackingMode === "auto") {
119
+ await ensureAutoSession();
112
120
  }
113
121
  }
114
122
  async function ensureProductivity() {
115
123
  if (!API_KEY) return "Not connected to CostLens. Run: npx @costlens/mcp-server setup";
116
124
  if (productivityEnabled === null) {
117
- productivityEnabled = await checkFeature("productivity_basic");
125
+ await loadKeySettings();
118
126
  }
119
127
  if (!productivityEnabled) return "Productivity tracking requires Business plan or Productivity add-on.";
120
128
  return null;
121
129
  }
130
+ async function checkFeature(feature) {
131
+ if (!API_KEY) return false;
132
+ try {
133
+ const res = await apiFetch("/v1/plan");
134
+ if (!res.ok) return false;
135
+ const plan = await res.json();
136
+ return plan.features?.includes(feature) ?? false;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
122
141
  async function main() {
123
- await loadPricing();
142
+ await Promise.all([loadPricing(), loadKeySettings()]);
124
143
  const transport = new import_stdio.StdioServerTransport();
125
144
  await server.connect(transport);
126
145
  const cleanup = async () => {
127
146
  if (activeSessionId && API_KEY) {
128
147
  try {
129
- await fetch(`${API_BASE}/v1/productivity/sessions/${activeSessionId}`, {
148
+ await apiFetch(`/v1/productivity/sessions/${activeSessionId}`, {
130
149
  method: "PATCH",
131
- headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
132
- body: JSON.stringify({ outcome: "completed" }),
133
- signal: AbortSignal.timeout(3e3)
150
+ body: JSON.stringify({ outcome: "completed", metadata: { toolCalls: toolCallCount } })
134
151
  });
135
152
  } catch {
136
153
  }
@@ -141,7 +158,7 @@ async function main() {
141
158
  process.on("SIGTERM", cleanup);
142
159
  process.on("SIGINT", cleanup);
143
160
  }
144
- var import_mcp, import_stdio, import_zod, import_classifier, import_fs, import_path, import_os, API_KEY, API_BASE, server, activeSessionId, heartbeatInterval, productivityEnabled;
161
+ var import_mcp, import_stdio, import_zod, import_classifier, import_fs, import_path, import_os, API_KEY, API_BASE, activeSessionId, heartbeatInterval, trackingMode, productivityEnabled, toolCallCount, server;
145
162
  var init_index = __esm({
146
163
  "src/index.ts"() {
147
164
  "use strict";
@@ -154,42 +171,32 @@ var init_index = __esm({
154
171
  import_os = require("os");
155
172
  API_KEY = resolveApiKey();
156
173
  API_BASE = process.env.COSTLENS_API_URL || "https://api.costlens.dev";
157
- server = new import_mcp.McpServer({
158
- name: "@lens360/mcp-server",
159
- version: "0.3.1"
160
- });
161
174
  activeSessionId = null;
162
175
  heartbeatInterval = null;
176
+ trackingMode = "manual";
177
+ productivityEnabled = null;
178
+ toolCallCount = 0;
179
+ server = new import_mcp.McpServer({ name: "@lens360/mcp-server", version: "0.4.0" });
163
180
  server.tool(
164
181
  "get_spend_summary",
165
182
  "Get current spend summary \u2014 daily, weekly, monthly, and session totals",
166
183
  {},
167
184
  async () => {
168
- if (!API_KEY) {
169
- return { content: [{ type: "text", text: "Not connected to CostLens. Run: npx @costlens/mcp-server setup" }] };
170
- }
185
+ await onToolCall("get_spend_summary");
186
+ if (!API_KEY) return { content: [{ type: "text", text: "Not connected to CostLens. Run: npx @costlens/mcp-server setup" }] };
171
187
  try {
172
- const res = await fetch(`${API_BASE}/v1/spend`, {
173
- headers: { "Authorization": `Bearer ${API_KEY}` },
174
- signal: AbortSignal.timeout(5e3)
175
- });
176
- if (!res.ok) {
177
- return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
178
- }
188
+ const res = await apiFetch("/v1/spend");
189
+ if (!res.ok) return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
179
190
  const data = await res.json();
180
191
  const lines = [
181
- `\u{1F4B0} Spend Summary (${data.key})`,
192
+ `Spend Summary (${data.key})`,
182
193
  ``,
183
194
  `Today: $${data.daily.cost.toFixed(4)} (${data.daily.requests} requests)`,
184
195
  `Week: $${data.weekly.cost.toFixed(4)} (${data.weekly.requests} requests)`,
185
196
  `Month: $${data.monthly.cost.toFixed(4)} (${data.monthly.requests} requests)`
186
197
  ];
187
- if (data.session) {
188
- lines.push(`Session: $${data.session.cost.toFixed(4)} (${data.session.requests} requests) [${data.session.correlationId}]`);
189
- }
190
- if (data.dailyBudget) {
191
- lines.push(``, `Budget: $${data.budgetRemaining.toFixed(2)} remaining of $${data.dailyBudget.toFixed(2)}/day`);
192
- }
198
+ if (data.session) lines.push(`Session: $${data.session.cost.toFixed(4)} (${data.session.requests} requests) [${data.session.correlationId}]`);
199
+ if (data.dailyBudget) lines.push(``, `Budget: $${data.budgetRemaining.toFixed(2)} remaining of $${data.dailyBudget.toFixed(2)}/day`);
193
200
  return { content: [{ type: "text", text: lines.join("\n") }] };
194
201
  } catch (err) {
195
202
  return { content: [{ type: "text", text: `Error fetching spend: ${err.message}` }] };
@@ -208,6 +215,7 @@ var init_index = __esm({
208
215
  isFirstMessage: import_zod.z.boolean().optional()
209
216
  },
210
217
  async (params) => {
218
+ await onToolCall("costlens_suggest_model");
211
219
  const result = (0, import_classifier.classify)({
212
220
  prompt: params.prompt,
213
221
  currentModel: params.currentModel,
@@ -217,15 +225,27 @@ var init_index = __esm({
217
225
  isFirstMessage: params.isFirstMessage
218
226
  });
219
227
  if (API_KEY && result.suggestedModel) {
220
- syncEvent(result, params).catch(() => {
228
+ apiFetch("/v1/classifier/events", {
229
+ method: "POST",
230
+ body: JSON.stringify({
231
+ complexity: result.level,
232
+ confidence: result.confidence,
233
+ suggestedModel: result.suggestedModel,
234
+ currentModel: params.currentModel,
235
+ provider: params.provider,
236
+ staticScore: result.signals.staticScore
237
+ })
238
+ }).catch(() => {
239
+ });
240
+ autoLogEvent("prompt", {
241
+ complexity: result.level,
242
+ model: result.suggestedModel,
243
+ provider: params.provider
221
244
  });
222
245
  }
223
- return {
224
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
225
- };
246
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
226
247
  }
227
248
  );
228
- productivityEnabled = null;
229
249
  server.tool(
230
250
  "start_session",
231
251
  "Start a productivity tracking session",
@@ -235,14 +255,13 @@ var init_index = __esm({
235
255
  repo: import_zod.z.string().optional()
236
256
  },
237
257
  async (params) => {
258
+ await onToolCall("start_session");
238
259
  const err = await ensureProductivity();
239
260
  if (err) return { content: [{ type: "text", text: err }] };
240
261
  try {
241
- const res = await fetch(`${API_BASE}/v1/productivity/sessions`, {
262
+ const res = await apiFetch("/v1/productivity/sessions", {
242
263
  method: "POST",
243
- headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
244
- body: JSON.stringify(params),
245
- signal: AbortSignal.timeout(5e3)
264
+ body: JSON.stringify(params)
246
265
  });
247
266
  if (!res.ok) return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
248
267
  const data = await res.json();
@@ -262,14 +281,13 @@ var init_index = __esm({
262
281
  artifacts: import_zod.z.array(import_zod.z.string()).optional()
263
282
  },
264
283
  async (params) => {
284
+ await onToolCall("end_session");
265
285
  const err = await ensureProductivity();
266
286
  if (err) return { content: [{ type: "text", text: err }] };
267
287
  try {
268
- const res = await fetch(`${API_BASE}/v1/productivity/sessions/${params.session_id}`, {
288
+ const res = await apiFetch(`/v1/productivity/sessions/${params.session_id}`, {
269
289
  method: "PATCH",
270
- headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
271
- body: JSON.stringify({ outcome: params.outcome, artifacts: params.artifacts }),
272
- signal: AbortSignal.timeout(5e3)
290
+ body: JSON.stringify({ outcome: params.outcome, artifacts: params.artifacts })
273
291
  });
274
292
  if (!res.ok) return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
275
293
  const data = await res.json();
@@ -290,18 +308,14 @@ var init_index = __esm({
290
308
  metadata: import_zod.z.record(import_zod.z.any()).optional()
291
309
  },
292
310
  async (params) => {
311
+ await onToolCall("log_event");
293
312
  if (!API_KEY) return { content: [{ type: "text", text: "Not connected to CostLens. Run: npx @costlens/mcp-server setup" }] };
294
- if (productivityEnabled === null) {
295
- productivityEnabled = await checkFeature("productivity_basic");
296
- }
297
313
  const hasFull = await checkFeature("productivity_full");
298
314
  if (!hasFull) return { content: [{ type: "text", text: "Event logging requires Productivity Insights add-on." }] };
299
315
  try {
300
- const res = await fetch(`${API_BASE}/v1/productivity/events`, {
316
+ const res = await apiFetch("/v1/productivity/events", {
301
317
  method: "POST",
302
- headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
303
- body: JSON.stringify(params),
304
- signal: AbortSignal.timeout(5e3)
318
+ body: JSON.stringify(params)
305
319
  });
306
320
  if (!res.ok) return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
307
321
  return { content: [{ type: "text", text: "Event logged." }] };
@@ -313,22 +327,18 @@ var init_index = __esm({
313
327
  server.tool(
314
328
  "get_productivity_summary",
315
329
  "Get productivity summary for a time period",
316
- {
317
- period: import_zod.z.enum(["today", "week", "month"]).optional()
318
- },
330
+ { period: import_zod.z.enum(["today", "week", "month"]).optional() },
319
331
  async (params) => {
332
+ await onToolCall("get_productivity_summary");
320
333
  const err = await ensureProductivity();
321
334
  if (err) return { content: [{ type: "text", text: err }] };
322
335
  try {
323
336
  const period = params.period || "week";
324
- const res = await fetch(`${API_BASE}/v1/productivity/summary?period=${period}`, {
325
- headers: { "Authorization": `Bearer ${API_KEY}` },
326
- signal: AbortSignal.timeout(5e3)
327
- });
337
+ const res = await apiFetch(`/v1/productivity/summary?period=${period}`);
328
338
  if (!res.ok) return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
329
339
  const data = await res.json();
330
340
  const lines = [
331
- `\u{1F4CA} Productivity Summary (${period})`,
341
+ `Productivity Summary (${period})`,
332
342
  ``,
333
343
  `Sessions: ${data.sessions}`,
334
344
  `Time: ${data.total_time_minutes} min`,
@@ -349,25 +359,15 @@ var init_index = __esm({
349
359
  server.tool(
350
360
  "get_github_metrics",
351
361
  "Get GitHub PR metrics \u2014 PRs merged, review rounds, first-pass rate, cost per PR",
352
- {
353
- period: import_zod.z.enum(["today", "week", "month"]).optional()
354
- },
362
+ { period: import_zod.z.enum(["today", "week", "month"]).optional() },
355
363
  async (params) => {
356
- if (!API_KEY) {
357
- return { content: [{ type: "text", text: "Not connected to CostLens. Run: npx @costlens/mcp-server setup" }] };
358
- }
364
+ await onToolCall("get_github_metrics");
365
+ if (!API_KEY) return { content: [{ type: "text", text: "Not connected to CostLens. Run: npx @costlens/mcp-server setup" }] };
359
366
  try {
360
367
  const period = params.period || "week";
361
- const res = await fetch(`${API_BASE}/v1/productivity/github?period=${period}`, {
362
- headers: { "Authorization": `Bearer ${API_KEY}` },
363
- signal: AbortSignal.timeout(5e3)
364
- });
365
- if (res.status === 404) {
366
- return { content: [{ type: "text", text: "GitHub not connected. Connect in Settings \u2192 GitHub at costlens.dev" }] };
367
- }
368
- if (!res.ok) {
369
- return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
370
- }
368
+ const res = await apiFetch(`/v1/productivity/github?period=${period}`);
369
+ if (res.status === 404) return { content: [{ type: "text", text: "GitHub not connected. Connect in Settings \u2192 GitHub at costlens.dev" }] };
370
+ if (!res.ok) return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
371
371
  const { data } = await res.json();
372
372
  const lines = [
373
373
  `GitHub Metrics (${period}):`,
@@ -398,6 +398,11 @@ var CONFIG_FILE = (0, import_path2.join)(CONFIG_DIR, "config.json");
398
398
  var API_BASE2 = process.env.COSTLENS_API_URL || "https://api.costlens.dev";
399
399
  var APP_URL = process.env.COSTLENS_APP_URL || "https://costlens.dev";
400
400
  function readConfig() {
401
+ try {
402
+ const projectConfig = JSON.parse((0, import_fs2.readFileSync)((0, import_path2.join)(process.cwd(), ".costlens.json"), "utf-8"));
403
+ if (projectConfig.key) return { apiKey: projectConfig.key };
404
+ } catch {
405
+ }
401
406
  try {
402
407
  return JSON.parse((0, import_fs2.readFileSync)(CONFIG_FILE, "utf-8"));
403
408
  } catch {
@@ -408,8 +413,103 @@ function writeConfig(config) {
408
413
  if (!(0, import_fs2.existsSync)(CONFIG_DIR)) (0, import_fs2.mkdirSync)(CONFIG_DIR, { recursive: true });
409
414
  (0, import_fs2.writeFileSync)(CONFIG_FILE, JSON.stringify(config, null, 2));
410
415
  }
416
+ var HOOK_MARKER = "# costlens-hook";
417
+ var POST_COMMIT_HOOK = `#!/bin/sh
418
+ ${HOOK_MARKER}
419
+ # CostLens \u2014 tracks commits during AI sessions (metadata only, no code content)
420
+ # Sends: hash, message, files changed, insertions, deletions, branch, timestamp
421
+ # Remove this file to uninstall: .git/hooks/post-commit
422
+
423
+ COSTLENS_KEY=$(cat ~/.costlens/config.json 2>/dev/null | grep -o '"apiKey"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
424
+ if [ -z "$COSTLENS_KEY" ]; then exit 0; fi
425
+
426
+ HASH=$(git rev-parse HEAD)
427
+ MSG=$(git log -1 --format=%s | head -c 200)
428
+ BRANCH=$(git rev-parse --abbrev-ref HEAD)
429
+ FILES=$(git diff --numstat HEAD~1 HEAD 2>/dev/null | wc -l | tr -d ' ')
430
+ STATS=$(git diff --shortstat HEAD~1 HEAD 2>/dev/null)
431
+ INSERTIONS=$(echo "$STATS" | grep -o '[0-9]* insertion' | grep -o '[0-9]*' || echo "0")
432
+ DELETIONS=$(echo "$STATS" | grep -o '[0-9]* deletion' | grep -o '[0-9]*' || echo "0")
433
+
434
+ curl -s -X POST "\${COSTLENS_API_URL:-https://api.costlens.dev}/v1/hooks/commit" \\
435
+ -H "Authorization: Bearer $COSTLENS_KEY" \\
436
+ -H "Content-Type: application/json" \\
437
+ -d "{\\"hash\\":\\"$HASH\\",\\"message\\":\\"$MSG\\",\\"branch\\":\\"$BRANCH\\",\\"filesChanged\\":$FILES,\\"insertions\\":\${INSERTIONS:-0},\\"deletions\\":\${DELETIONS:-0}}" \\
438
+ --max-time 3 > /dev/null 2>&1 &
439
+
440
+ exit 0
441
+ `;
442
+ function findGitRepos() {
443
+ const cwd = process.cwd();
444
+ const repos = [];
445
+ if ((0, import_fs2.existsSync)((0, import_path2.join)(cwd, ".git"))) repos.push(cwd);
446
+ const searchDirs = [
447
+ (0, import_path2.join)((0, import_os2.homedir)(), "Desktop"),
448
+ (0, import_path2.join)((0, import_os2.homedir)(), "Projects"),
449
+ (0, import_path2.join)((0, import_os2.homedir)(), "projects"),
450
+ (0, import_path2.join)((0, import_os2.homedir)(), "dev"),
451
+ (0, import_path2.join)((0, import_os2.homedir)(), "code"),
452
+ (0, import_path2.join)((0, import_os2.homedir)(), "workspace"),
453
+ (0, import_path2.join)((0, import_os2.homedir)(), "Workspace"),
454
+ (0, import_path2.join)((0, import_os2.homedir)(), "Desktop", "Workspace")
455
+ ];
456
+ for (const dir of searchDirs) {
457
+ if (!(0, import_fs2.existsSync)(dir)) continue;
458
+ try {
459
+ const entries = require("fs").readdirSync(dir, { withFileTypes: true });
460
+ for (const entry of entries) {
461
+ if (entry.isDirectory() && (0, import_fs2.existsSync)((0, import_path2.join)(dir, entry.name, ".git"))) {
462
+ repos.push((0, import_path2.join)(dir, entry.name));
463
+ }
464
+ }
465
+ } catch {
466
+ }
467
+ }
468
+ return [...new Set(repos)];
469
+ }
470
+ function installGitHook(repoPath) {
471
+ const hooksDir = (0, import_path2.join)(repoPath, ".git", "hooks");
472
+ const hookPath = (0, import_path2.join)(hooksDir, "post-commit");
473
+ if ((0, import_fs2.existsSync)(hookPath)) {
474
+ try {
475
+ const content = (0, import_fs2.readFileSync)(hookPath, "utf-8");
476
+ if (content.includes(HOOK_MARKER)) return "exists";
477
+ return "skipped";
478
+ } catch {
479
+ return "skipped";
480
+ }
481
+ }
482
+ try {
483
+ if (!(0, import_fs2.existsSync)(hooksDir)) (0, import_fs2.mkdirSync)(hooksDir, { recursive: true });
484
+ (0, import_fs2.writeFileSync)(hookPath, POST_COMMIT_HOOK);
485
+ (0, import_fs2.chmodSync)(hookPath, "755");
486
+ return "installed";
487
+ } catch {
488
+ return "skipped";
489
+ }
490
+ }
491
+ function installHooks() {
492
+ const repos = findGitRepos();
493
+ if (repos.length === 0) {
494
+ console.log(" \xB7 No git repos detected\n");
495
+ return;
496
+ }
497
+ console.log(`
498
+ Git hooks (tracks commits during AI sessions):
499
+ `);
500
+ for (const repo of repos.slice(0, 10)) {
501
+ const name = repo.split("/").pop();
502
+ const result = installGitHook(repo);
503
+ if (result === "installed") console.log(` + ${name} \u2014 hook installed`);
504
+ else if (result === "exists") console.log(` \xB7 ${name} \u2014 already installed`);
505
+ else console.log(` \xB7 ${name} \u2014 skipped (existing hook)`);
506
+ }
507
+ console.log(`
508
+ Hooks send: commit hash, message, branch, files changed.`);
509
+ console.log(` No code content is ever sent. Remove .git/hooks/post-commit to uninstall.`);
510
+ }
411
511
  async function login() {
412
- console.log("\u{1F511} CostLens \u2014 Authenticating...\n");
512
+ console.log(" CostLens \u2014 Authenticating...\n");
413
513
  const sessionId = import_crypto.default.randomBytes(16).toString("hex");
414
514
  const authUrl = `${APP_URL}/cli-auth?session=${sessionId}`;
415
515
  console.log(` Opening browser: ${authUrl}
@@ -437,27 +537,24 @@ async function login() {
437
537
  const data = await res.json();
438
538
  if (data.key) {
439
539
  writeConfig({ apiKey: data.key });
440
- console.log("\n\u2713 Authenticated successfully");
540
+ console.log("\n Authenticated successfully");
441
541
  console.log(` Key saved to ${CONFIG_FILE}
442
542
  `);
443
- if (process.argv[2] === "setup") {
444
- init();
445
- } else {
446
- process.exit(0);
447
- }
543
+ if (process.argv[2] === "setup") init();
544
+ else process.exit(0);
448
545
  return;
449
546
  }
450
547
  }
451
548
  } catch {
452
549
  }
453
550
  }
454
- console.log("\n\u2717 Timed out. Try again.");
551
+ console.log("\n Timed out. Try again.");
455
552
  process.exit(1);
456
553
  }
457
554
  function init() {
458
555
  const config = readConfig();
459
556
  if (!config.apiKey) {
460
- console.log("\u2717 Not authenticated. Run: npx @costlens/mcp-server login\n");
557
+ console.log(" Not authenticated. Run: npx @costlens/mcp-server login\n");
461
558
  process.exit(1);
462
559
  }
463
560
  const mcpConfig = {
@@ -465,20 +562,10 @@ function init() {
465
562
  costlens: {
466
563
  command: "npx",
467
564
  args: ["-y", "@costlens/mcp-server"],
468
- env: {
469
- COSTLENS_MCP_KEY: config.apiKey
470
- }
565
+ env: { COSTLENS_MCP_KEY: config.apiKey }
471
566
  }
472
567
  }
473
568
  };
474
- console.log("\u{1F4CB} MCP Configuration:\n");
475
- console.log(JSON.stringify(mcpConfig, null, 2));
476
- console.log("\n Add this to your agent config file:\n");
477
- console.log(" Kiro: ~/.kiro/settings/mcp.json");
478
- console.log(" Cursor: ~/.cursor/mcp.json");
479
- console.log(" Claude Code: ~/.claude/mcp_servers.json");
480
- console.log(" VS Code: .vscode/mcp.json");
481
- console.log("");
482
569
  const kiroPath = (0, import_path2.join)((0, import_os2.homedir)(), ".kiro", "settings", "mcp.json");
483
570
  const cursorPath = (0, import_path2.join)((0, import_os2.homedir)(), ".cursor", "mcp.json");
484
571
  const claudePath = (0, import_path2.join)((0, import_os2.homedir)(), ".claude", "mcp_servers.json");
@@ -487,6 +574,8 @@ function init() {
487
574
  { name: "Cursor", path: cursorPath },
488
575
  { name: "Claude Code", path: claudePath }
489
576
  ];
577
+ console.log(" Agent configuration:\n");
578
+ let configured = false;
490
579
  for (const { name, path } of paths) {
491
580
  if ((0, import_fs2.existsSync)(path)) {
492
581
  try {
@@ -494,50 +583,63 @@ function init() {
494
583
  if (!existing.mcpServers?.costlens) {
495
584
  existing.mcpServers = { ...existing.mcpServers, ...mcpConfig.mcpServers };
496
585
  (0, import_fs2.writeFileSync)(path, JSON.stringify(existing, null, 2));
497
- console.log(` \u2713 Auto-configured ${name} (${path})`);
586
+ console.log(` + ${name} \u2014 configured`);
587
+ configured = true;
498
588
  } else {
499
- console.log(` \xB7 ${name} already configured`);
589
+ console.log(` \xB7 ${name} \u2014 already configured`);
590
+ configured = true;
500
591
  }
501
592
  } catch {
502
- console.log(` \xB7 Could not auto-configure ${name}`);
593
+ console.log(` \xB7 ${name} \u2014 could not configure`);
503
594
  }
504
595
  }
505
596
  }
597
+ if (!configured) {
598
+ console.log(" No agents detected. Add manually:\n");
599
+ console.log(JSON.stringify(mcpConfig, null, 2));
600
+ console.log("\n Config paths:");
601
+ console.log(" Kiro: ~/.kiro/settings/mcp.json");
602
+ console.log(" Cursor: ~/.cursor/mcp.json");
603
+ console.log(" Claude Code: ~/.claude/mcp_servers.json");
604
+ console.log(" VS Code: .vscode/mcp.json");
605
+ }
606
+ installHooks();
506
607
  console.log("\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
507
- console.log("\n \u2713 Setup complete. What's next:\n");
608
+ console.log("\n Setup complete.\n");
508
609
  console.log(" 1. Restart your coding agent");
509
- console.log(" 2. Start coding \u2014 sessions are tracked automatically");
510
- console.log(" 3. View your dashboard: https://costlens.dev/dashboard");
511
- console.log(" 4. Docs: https://costlens.dev/docs/mcp\n");
610
+ console.log(" 2. Start coding \u2014 sessions tracked automatically");
611
+ console.log(" 3. Dashboard: https://costlens.dev/dashboard\n");
512
612
  console.log(" Commands:");
513
613
  console.log(" npx @costlens/mcp-server status \u2014 check connection");
514
614
  console.log(" npx @costlens/mcp-server login \u2014 re-authenticate");
615
+ console.log(" npx @costlens/mcp-server hooks \u2014 reinstall git hooks");
515
616
  console.log("");
617
+ process.exit(0);
516
618
  }
517
619
  async function status() {
518
620
  const config = readConfig();
519
621
  if (!config.apiKey) {
520
- console.log("\u2717 Not authenticated. Run: npx @costlens/mcp-server login\n");
622
+ console.log(" Not authenticated. Run: npx @costlens/mcp-server login\n");
521
623
  process.exit(1);
522
624
  }
523
- console.log("\u{1F4CA} CostLens Status\n");
625
+ console.log(" CostLens Status\n");
524
626
  try {
525
627
  const res = await fetch(`${API_BASE2}/v1/spend`, {
526
628
  headers: { Authorization: `Bearer ${config.apiKey}` },
527
629
  signal: AbortSignal.timeout(5e3)
528
630
  });
529
631
  if (!res.ok) {
530
- console.log(" \u2717 Connection failed (invalid key or server error)");
632
+ console.log(" Connection failed (invalid key or server error)");
531
633
  process.exit(1);
532
634
  }
533
635
  const data = await res.json();
534
- console.log(` \u2713 Connected`);
636
+ console.log(` Connected`);
535
637
  console.log(` Key: ${config.apiKey.slice(0, 8)}...`);
536
638
  console.log(` Today: $${(data.today || 0).toFixed(4)}`);
537
639
  console.log(` This week: $${(data.week || 0).toFixed(4)}`);
538
640
  console.log(` This month: $${(data.month || 0).toFixed(4)}`);
539
641
  } catch (e) {
540
- console.log(` \u2717 Could not connect: ${e.message}`);
642
+ console.log(` Could not connect: ${e.message}`);
541
643
  process.exit(1);
542
644
  }
543
645
  }
@@ -546,11 +648,15 @@ if (command === "login") login();
546
648
  else if (command === "init") init();
547
649
  else if (command === "status") status();
548
650
  else if (command === "setup") setup();
651
+ else if (command === "hooks") {
652
+ installHooks();
653
+ process.exit(0);
654
+ } else if (command === "doctor") doctor();
549
655
  else {
550
656
  init_index();
551
657
  }
552
658
  async function setup() {
553
- console.log("\u{1F527} CostLens \u2014 One-step setup\n");
659
+ console.log(" CostLens \u2014 One-step setup\n");
554
660
  const existing = readConfig();
555
661
  if (existing.apiKey) {
556
662
  console.log(" Already authenticated. Running init...\n");
@@ -559,3 +665,139 @@ async function setup() {
559
665
  }
560
666
  await login();
561
667
  }
668
+ async function doctor() {
669
+ console.log("\n CostLens Doctor\n");
670
+ const config = readConfig();
671
+ const issues = [];
672
+ let keyValid = false;
673
+ let planData = null;
674
+ let spendData = null;
675
+ if (!config.apiKey) {
676
+ console.log(" Key: not configured");
677
+ issues.push("No API key. Run: npx @costlens/mcp-server setup");
678
+ } else {
679
+ try {
680
+ const res = await fetch(`${API_BASE2}/v1/plan`, {
681
+ headers: { Authorization: `Bearer ${config.apiKey}` },
682
+ signal: AbortSignal.timeout(5e3)
683
+ });
684
+ if (res.ok) {
685
+ planData = await res.json();
686
+ keyValid = true;
687
+ console.log(` Key: valid (${config.apiKey.slice(0, 10)}...)`);
688
+ } else {
689
+ console.log(` Key: invalid (${res.status})`);
690
+ issues.push("API key is invalid or expired. Run: npx @costlens/mcp-server login");
691
+ }
692
+ } catch (e) {
693
+ console.log(` Key: error (${e.message})`);
694
+ issues.push("Cannot reach CostLens API. Check your network.");
695
+ }
696
+ }
697
+ if (planData) {
698
+ console.log(` Plan: ${planData.plan}`);
699
+ console.log(` Tracking: ${planData.keySettings?.trackingMode || "manual"}`);
700
+ }
701
+ if (keyValid) {
702
+ try {
703
+ const res = await fetch(`${API_BASE2}/v1/spend`, {
704
+ headers: { Authorization: `Bearer ${config.apiKey}` },
705
+ signal: AbortSignal.timeout(5e3)
706
+ });
707
+ if (res.ok) {
708
+ spendData = await res.json();
709
+ const daily = spendData.daily?.cost ?? 0;
710
+ const budget = spendData.dailyBudget;
711
+ if (budget) {
712
+ console.log(` Budget: $${daily.toFixed(2)} / $${budget.toFixed(2)} today`);
713
+ } else {
714
+ console.log(` Spend: $${daily.toFixed(4)} today`);
715
+ }
716
+ }
717
+ } catch {
718
+ }
719
+ }
720
+ const kiroPath = (0, import_path2.join)((0, import_os2.homedir)(), ".kiro", "settings", "mcp.json");
721
+ const cursorPath = (0, import_path2.join)((0, import_os2.homedir)(), ".cursor", "mcp.json");
722
+ const claudePath = (0, import_path2.join)((0, import_os2.homedir)(), ".claude", "mcp_servers.json");
723
+ const agents = [
724
+ { name: "Kiro", path: kiroPath },
725
+ { name: "Cursor", path: cursorPath },
726
+ { name: "Claude Code", path: claudePath }
727
+ ];
728
+ let agentConfigured = false;
729
+ for (const { name, path } of agents) {
730
+ if ((0, import_fs2.existsSync)(path)) {
731
+ try {
732
+ const content = JSON.parse((0, import_fs2.readFileSync)(path, "utf-8"));
733
+ if (content.mcpServers?.costlens) {
734
+ console.log(` MCP: ${name} (configured)`);
735
+ agentConfigured = true;
736
+ break;
737
+ }
738
+ } catch {
739
+ }
740
+ }
741
+ }
742
+ if (!agentConfigured) {
743
+ console.log(" MCP: not configured");
744
+ issues.push("No agent configured. Run: npx @costlens/mcp-server setup");
745
+ }
746
+ const cwd = process.cwd();
747
+ const hookPath = (0, import_path2.join)(cwd, ".git", "hooks", "post-commit");
748
+ if ((0, import_fs2.existsSync)(hookPath)) {
749
+ try {
750
+ const content = (0, import_fs2.readFileSync)(hookPath, "utf-8");
751
+ if (content.includes(HOOK_MARKER)) {
752
+ console.log(" Git hook: installed (post-commit)");
753
+ } else {
754
+ console.log(" Git hook: exists (not CostLens)");
755
+ }
756
+ } catch {
757
+ console.log(" Git hook: error reading");
758
+ }
759
+ } else if ((0, import_fs2.existsSync)((0, import_path2.join)(cwd, ".git"))) {
760
+ console.log(" Git hook: not installed");
761
+ issues.push("Git hook not installed. Run: npx @costlens/mcp-server hooks");
762
+ } else {
763
+ console.log(" Git hook: no git repo in cwd");
764
+ }
765
+ const pkgPath = (0, import_path2.join)(cwd, "package.json");
766
+ if ((0, import_fs2.existsSync)(pkgPath)) {
767
+ try {
768
+ const pkg = JSON.parse((0, import_fs2.readFileSync)(pkgPath, "utf-8"));
769
+ const sdkVersion = pkg.dependencies?.costlens || pkg.devDependencies?.costlens;
770
+ if (sdkVersion) {
771
+ console.log(` SDK: installed (${sdkVersion})`);
772
+ } else {
773
+ console.log(" SDK: not installed");
774
+ }
775
+ } catch {
776
+ }
777
+ }
778
+ if (keyValid) {
779
+ try {
780
+ const res = await fetch(`${API_BASE2}/v1/productivity/github?period=week`, {
781
+ headers: { Authorization: `Bearer ${config.apiKey}` },
782
+ signal: AbortSignal.timeout(5e3)
783
+ });
784
+ if (res.ok) {
785
+ console.log(" GitHub: connected");
786
+ } else if (res.status === 404) {
787
+ console.log(" GitHub: not connected");
788
+ }
789
+ } catch {
790
+ }
791
+ }
792
+ console.log("");
793
+ if (issues.length === 0) {
794
+ console.log(" All good.\n");
795
+ } else {
796
+ console.log(" Issues:\n");
797
+ for (const issue of issues) {
798
+ console.log(` - ${issue}`);
799
+ }
800
+ console.log("");
801
+ }
802
+ process.exit(issues.length > 0 ? 1 : 0);
803
+ }
package/dist/index.js CHANGED
@@ -21,37 +21,46 @@ function resolveApiKey() {
21
21
  }
22
22
  var API_KEY = resolveApiKey();
23
23
  var API_BASE = process.env.COSTLENS_API_URL || "https://api.costlens.dev";
24
+ var activeSessionId = null;
25
+ var heartbeatInterval = null;
26
+ var trackingMode = "manual";
27
+ var productivityEnabled = null;
28
+ var toolCallCount = 0;
29
+ async function apiFetch(path, opts = {}) {
30
+ const headers = { "Authorization": `Bearer ${API_KEY}`, ...opts.headers };
31
+ if (opts.body) headers["Content-Type"] = "application/json";
32
+ return fetch(`${API_BASE}${path}`, { ...opts, headers, signal: AbortSignal.timeout(5e3) });
33
+ }
24
34
  async function loadPricing() {
25
35
  try {
26
- const headers = {};
27
- if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`;
28
- const res = await fetch(`${API_BASE}/v1/pricing`, { headers, signal: AbortSignal.timeout(5e3) });
36
+ const res = await apiFetch("/v1/pricing");
29
37
  if (res.ok) {
30
38
  const data = await res.json();
31
- for (const m of data.models) {
32
- import_classifier.PRICING[m.model] = { input: m.input, output: m.output };
33
- }
39
+ for (const m of data.models) import_classifier.PRICING[m.model] = { input: m.input, output: m.output };
34
40
  }
35
41
  } catch {
36
42
  }
37
43
  }
38
- var server = new import_mcp.McpServer({
39
- name: "@lens360/mcp-server",
40
- version: "0.3.1"
41
- });
42
- var activeSessionId = null;
43
- var heartbeatInterval = null;
44
+ async function loadKeySettings() {
45
+ if (!API_KEY) return;
46
+ try {
47
+ const res = await apiFetch("/v1/plan");
48
+ if (!res.ok) return;
49
+ const data = await res.json();
50
+ trackingMode = data.keySettings?.trackingMode || "manual";
51
+ productivityEnabled = data.features?.includes("productivity_basic") ?? false;
52
+ } catch {
53
+ }
54
+ }
44
55
  function startHeartbeat(sessionId) {
45
56
  stopHeartbeat();
46
57
  activeSessionId = sessionId;
47
58
  heartbeatInterval = setInterval(async () => {
48
59
  if (!activeSessionId || !API_KEY) return;
49
60
  try {
50
- const res = await fetch(`${API_BASE}/v1/productivity/heartbeat`, {
61
+ const res = await apiFetch("/v1/productivity/heartbeat", {
51
62
  method: "POST",
52
- headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
53
- body: JSON.stringify({ session_id: activeSessionId }),
54
- signal: AbortSignal.timeout(5e3)
63
+ body: JSON.stringify({ session_id: activeSessionId })
55
64
  });
56
65
  if (res.status === 410) {
57
66
  activeSessionId = null;
@@ -67,36 +76,57 @@ function stopHeartbeat() {
67
76
  heartbeatInterval = null;
68
77
  }
69
78
  }
79
+ async function ensureAutoSession() {
80
+ if (trackingMode !== "auto" || !API_KEY || !productivityEnabled || activeSessionId) return;
81
+ try {
82
+ const res = await apiFetch("/v1/productivity/sessions", {
83
+ method: "POST",
84
+ body: JSON.stringify({ task_description: "Auto-tracked MCP session" })
85
+ });
86
+ if (res.ok) {
87
+ const data = await res.json();
88
+ if (data.data?.id) startHeartbeat(data.data.id);
89
+ }
90
+ } catch {
91
+ }
92
+ }
93
+ async function autoLogEvent(eventType, metadata) {
94
+ if (trackingMode !== "auto" || !activeSessionId || !API_KEY) return;
95
+ try {
96
+ await apiFetch("/v1/productivity/events", {
97
+ method: "POST",
98
+ body: JSON.stringify({ session_id: activeSessionId, event_type: eventType, metadata })
99
+ });
100
+ } catch {
101
+ }
102
+ }
103
+ async function onToolCall(toolName) {
104
+ toolCallCount++;
105
+ if (trackingMode === "auto") {
106
+ await ensureAutoSession();
107
+ }
108
+ }
109
+ var server = new import_mcp.McpServer({ name: "@lens360/mcp-server", version: "0.4.0" });
70
110
  server.tool(
71
111
  "get_spend_summary",
72
112
  "Get current spend summary \u2014 daily, weekly, monthly, and session totals",
73
113
  {},
74
114
  async () => {
75
- if (!API_KEY) {
76
- return { content: [{ type: "text", text: "Not connected to CostLens. Run: npx @costlens/mcp-server setup" }] };
77
- }
115
+ await onToolCall("get_spend_summary");
116
+ if (!API_KEY) return { content: [{ type: "text", text: "Not connected to CostLens. Run: npx @costlens/mcp-server setup" }] };
78
117
  try {
79
- const res = await fetch(`${API_BASE}/v1/spend`, {
80
- headers: { "Authorization": `Bearer ${API_KEY}` },
81
- signal: AbortSignal.timeout(5e3)
82
- });
83
- if (!res.ok) {
84
- return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
85
- }
118
+ const res = await apiFetch("/v1/spend");
119
+ if (!res.ok) return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
86
120
  const data = await res.json();
87
121
  const lines = [
88
- `\u{1F4B0} Spend Summary (${data.key})`,
122
+ `Spend Summary (${data.key})`,
89
123
  ``,
90
124
  `Today: $${data.daily.cost.toFixed(4)} (${data.daily.requests} requests)`,
91
125
  `Week: $${data.weekly.cost.toFixed(4)} (${data.weekly.requests} requests)`,
92
126
  `Month: $${data.monthly.cost.toFixed(4)} (${data.monthly.requests} requests)`
93
127
  ];
94
- if (data.session) {
95
- lines.push(`Session: $${data.session.cost.toFixed(4)} (${data.session.requests} requests) [${data.session.correlationId}]`);
96
- }
97
- if (data.dailyBudget) {
98
- lines.push(``, `Budget: $${data.budgetRemaining.toFixed(2)} remaining of $${data.dailyBudget.toFixed(2)}/day`);
99
- }
128
+ if (data.session) lines.push(`Session: $${data.session.cost.toFixed(4)} (${data.session.requests} requests) [${data.session.correlationId}]`);
129
+ if (data.dailyBudget) lines.push(``, `Budget: $${data.budgetRemaining.toFixed(2)} remaining of $${data.dailyBudget.toFixed(2)}/day`);
100
130
  return { content: [{ type: "text", text: lines.join("\n") }] };
101
131
  } catch (err) {
102
132
  return { content: [{ type: "text", text: `Error fetching spend: ${err.message}` }] };
@@ -115,6 +145,7 @@ server.tool(
115
145
  isFirstMessage: import_zod.z.boolean().optional()
116
146
  },
117
147
  async (params) => {
148
+ await onToolCall("costlens_suggest_model");
118
149
  const result = (0, import_classifier.classify)({
119
150
  prompt: params.prompt,
120
151
  currentModel: params.currentModel,
@@ -124,39 +155,39 @@ server.tool(
124
155
  isFirstMessage: params.isFirstMessage
125
156
  });
126
157
  if (API_KEY && result.suggestedModel) {
127
- syncEvent(result, params).catch(() => {
158
+ apiFetch("/v1/classifier/events", {
159
+ method: "POST",
160
+ body: JSON.stringify({
161
+ complexity: result.level,
162
+ confidence: result.confidence,
163
+ suggestedModel: result.suggestedModel,
164
+ currentModel: params.currentModel,
165
+ provider: params.provider,
166
+ staticScore: result.signals.staticScore
167
+ })
168
+ }).catch(() => {
169
+ });
170
+ autoLogEvent("prompt", {
171
+ complexity: result.level,
172
+ model: result.suggestedModel,
173
+ provider: params.provider
128
174
  });
129
175
  }
130
- return {
131
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
132
- };
176
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
133
177
  }
134
178
  );
135
- async function syncEvent(result, params) {
136
- try {
137
- await fetch(`${API_BASE}/v1/classifier/events`, {
138
- method: "POST",
139
- headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
140
- body: JSON.stringify({
141
- complexity: result.level,
142
- confidence: result.confidence,
143
- suggestedModel: result.suggestedModel,
144
- currentModel: params.currentModel,
145
- provider: params.provider,
146
- staticScore: result.signals.staticScore
147
- }),
148
- signal: AbortSignal.timeout(5e3)
149
- });
150
- } catch {
179
+ async function ensureProductivity() {
180
+ if (!API_KEY) return "Not connected to CostLens. Run: npx @costlens/mcp-server setup";
181
+ if (productivityEnabled === null) {
182
+ await loadKeySettings();
151
183
  }
184
+ if (!productivityEnabled) return "Productivity tracking requires Business plan or Productivity add-on.";
185
+ return null;
152
186
  }
153
187
  async function checkFeature(feature) {
154
188
  if (!API_KEY) return false;
155
189
  try {
156
- const res = await fetch(`${API_BASE}/v1/plan`, {
157
- headers: { "Authorization": `Bearer ${API_KEY}` },
158
- signal: AbortSignal.timeout(5e3)
159
- });
190
+ const res = await apiFetch("/v1/plan");
160
191
  if (!res.ok) return false;
161
192
  const plan = await res.json();
162
193
  return plan.features?.includes(feature) ?? false;
@@ -164,15 +195,6 @@ async function checkFeature(feature) {
164
195
  return false;
165
196
  }
166
197
  }
167
- var productivityEnabled = null;
168
- async function ensureProductivity() {
169
- if (!API_KEY) return "Not connected to CostLens. Run: npx @costlens/mcp-server setup";
170
- if (productivityEnabled === null) {
171
- productivityEnabled = await checkFeature("productivity_basic");
172
- }
173
- if (!productivityEnabled) return "Productivity tracking requires Business plan or Productivity add-on.";
174
- return null;
175
- }
176
198
  server.tool(
177
199
  "start_session",
178
200
  "Start a productivity tracking session",
@@ -182,14 +204,13 @@ server.tool(
182
204
  repo: import_zod.z.string().optional()
183
205
  },
184
206
  async (params) => {
207
+ await onToolCall("start_session");
185
208
  const err = await ensureProductivity();
186
209
  if (err) return { content: [{ type: "text", text: err }] };
187
210
  try {
188
- const res = await fetch(`${API_BASE}/v1/productivity/sessions`, {
211
+ const res = await apiFetch("/v1/productivity/sessions", {
189
212
  method: "POST",
190
- headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
191
- body: JSON.stringify(params),
192
- signal: AbortSignal.timeout(5e3)
213
+ body: JSON.stringify(params)
193
214
  });
194
215
  if (!res.ok) return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
195
216
  const data = await res.json();
@@ -209,14 +230,13 @@ server.tool(
209
230
  artifacts: import_zod.z.array(import_zod.z.string()).optional()
210
231
  },
211
232
  async (params) => {
233
+ await onToolCall("end_session");
212
234
  const err = await ensureProductivity();
213
235
  if (err) return { content: [{ type: "text", text: err }] };
214
236
  try {
215
- const res = await fetch(`${API_BASE}/v1/productivity/sessions/${params.session_id}`, {
237
+ const res = await apiFetch(`/v1/productivity/sessions/${params.session_id}`, {
216
238
  method: "PATCH",
217
- headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
218
- body: JSON.stringify({ outcome: params.outcome, artifacts: params.artifacts }),
219
- signal: AbortSignal.timeout(5e3)
239
+ body: JSON.stringify({ outcome: params.outcome, artifacts: params.artifacts })
220
240
  });
221
241
  if (!res.ok) return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
222
242
  const data = await res.json();
@@ -237,18 +257,14 @@ server.tool(
237
257
  metadata: import_zod.z.record(import_zod.z.any()).optional()
238
258
  },
239
259
  async (params) => {
260
+ await onToolCall("log_event");
240
261
  if (!API_KEY) return { content: [{ type: "text", text: "Not connected to CostLens. Run: npx @costlens/mcp-server setup" }] };
241
- if (productivityEnabled === null) {
242
- productivityEnabled = await checkFeature("productivity_basic");
243
- }
244
262
  const hasFull = await checkFeature("productivity_full");
245
263
  if (!hasFull) return { content: [{ type: "text", text: "Event logging requires Productivity Insights add-on." }] };
246
264
  try {
247
- const res = await fetch(`${API_BASE}/v1/productivity/events`, {
265
+ const res = await apiFetch("/v1/productivity/events", {
248
266
  method: "POST",
249
- headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
250
- body: JSON.stringify(params),
251
- signal: AbortSignal.timeout(5e3)
267
+ body: JSON.stringify(params)
252
268
  });
253
269
  if (!res.ok) return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
254
270
  return { content: [{ type: "text", text: "Event logged." }] };
@@ -260,22 +276,18 @@ server.tool(
260
276
  server.tool(
261
277
  "get_productivity_summary",
262
278
  "Get productivity summary for a time period",
263
- {
264
- period: import_zod.z.enum(["today", "week", "month"]).optional()
265
- },
279
+ { period: import_zod.z.enum(["today", "week", "month"]).optional() },
266
280
  async (params) => {
281
+ await onToolCall("get_productivity_summary");
267
282
  const err = await ensureProductivity();
268
283
  if (err) return { content: [{ type: "text", text: err }] };
269
284
  try {
270
285
  const period = params.period || "week";
271
- const res = await fetch(`${API_BASE}/v1/productivity/summary?period=${period}`, {
272
- headers: { "Authorization": `Bearer ${API_KEY}` },
273
- signal: AbortSignal.timeout(5e3)
274
- });
286
+ const res = await apiFetch(`/v1/productivity/summary?period=${period}`);
275
287
  if (!res.ok) return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
276
288
  const data = await res.json();
277
289
  const lines = [
278
- `\u{1F4CA} Productivity Summary (${period})`,
290
+ `Productivity Summary (${period})`,
279
291
  ``,
280
292
  `Sessions: ${data.sessions}`,
281
293
  `Time: ${data.total_time_minutes} min`,
@@ -296,25 +308,15 @@ server.tool(
296
308
  server.tool(
297
309
  "get_github_metrics",
298
310
  "Get GitHub PR metrics \u2014 PRs merged, review rounds, first-pass rate, cost per PR",
299
- {
300
- period: import_zod.z.enum(["today", "week", "month"]).optional()
301
- },
311
+ { period: import_zod.z.enum(["today", "week", "month"]).optional() },
302
312
  async (params) => {
303
- if (!API_KEY) {
304
- return { content: [{ type: "text", text: "Not connected to CostLens. Run: npx @costlens/mcp-server setup" }] };
305
- }
313
+ await onToolCall("get_github_metrics");
314
+ if (!API_KEY) return { content: [{ type: "text", text: "Not connected to CostLens. Run: npx @costlens/mcp-server setup" }] };
306
315
  try {
307
316
  const period = params.period || "week";
308
- const res = await fetch(`${API_BASE}/v1/productivity/github?period=${period}`, {
309
- headers: { "Authorization": `Bearer ${API_KEY}` },
310
- signal: AbortSignal.timeout(5e3)
311
- });
312
- if (res.status === 404) {
313
- return { content: [{ type: "text", text: "GitHub not connected. Connect in Settings \u2192 GitHub at costlens.dev" }] };
314
- }
315
- if (!res.ok) {
316
- return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
317
- }
317
+ const res = await apiFetch(`/v1/productivity/github?period=${period}`);
318
+ if (res.status === 404) return { content: [{ type: "text", text: "GitHub not connected. Connect in Settings \u2192 GitHub at costlens.dev" }] };
319
+ if (!res.ok) return { content: [{ type: "text", text: `Error: ${res.status} \u2014 ${await res.text()}` }] };
318
320
  const { data } = await res.json();
319
321
  const lines = [
320
322
  `GitHub Metrics (${period}):`,
@@ -331,17 +333,15 @@ server.tool(
331
333
  }
332
334
  );
333
335
  async function main() {
334
- await loadPricing();
336
+ await Promise.all([loadPricing(), loadKeySettings()]);
335
337
  const transport = new import_stdio.StdioServerTransport();
336
338
  await server.connect(transport);
337
339
  const cleanup = async () => {
338
340
  if (activeSessionId && API_KEY) {
339
341
  try {
340
- await fetch(`${API_BASE}/v1/productivity/sessions/${activeSessionId}`, {
342
+ await apiFetch(`/v1/productivity/sessions/${activeSessionId}`, {
341
343
  method: "PATCH",
342
- headers: { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" },
343
- body: JSON.stringify({ outcome: "completed" }),
344
- signal: AbortSignal.timeout(3e3)
344
+ body: JSON.stringify({ outcome: "completed", metadata: { toolCalls: toolCallCount } })
345
345
  });
346
346
  } catch {
347
347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@costlens/mcp-server",
3
- "version": "0.4.3",
3
+ "version": "0.6.0",
4
4
  "description": "MCP server for AI cost optimization with prompt complexity classification",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",