burnwatch 0.1.2 → 0.3.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.
- package/dist/cli.js +139 -103
- package/dist/cli.js.map +1 -1
- package/dist/hooks/on-file-change.js.map +1 -1
- package/dist/hooks/on-prompt.js.map +1 -1
- package/dist/hooks/on-session-start.js +104 -26
- package/dist/hooks/on-session-start.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +104 -26
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.d.ts +1 -0
- package/dist/mcp-server.js +934 -0
- package/dist/mcp-server.js.map +1 -0
- package/package.json +5 -1
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/mcp-server.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
// src/core/config.ts
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
import * as os from "os";
|
|
12
|
+
function globalConfigDir() {
|
|
13
|
+
const xdgConfig = process.env["XDG_CONFIG_HOME"];
|
|
14
|
+
if (xdgConfig) return path.join(xdgConfig, "burnwatch");
|
|
15
|
+
return path.join(os.homedir(), ".config", "burnwatch");
|
|
16
|
+
}
|
|
17
|
+
function projectConfigDir(projectRoot) {
|
|
18
|
+
const root = projectRoot ?? process.cwd();
|
|
19
|
+
return path.join(root, ".burnwatch");
|
|
20
|
+
}
|
|
21
|
+
function projectDataDir(projectRoot) {
|
|
22
|
+
return path.join(projectConfigDir(projectRoot), "data");
|
|
23
|
+
}
|
|
24
|
+
function readGlobalConfig() {
|
|
25
|
+
const configPath = path.join(globalConfigDir(), "config.json");
|
|
26
|
+
try {
|
|
27
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
28
|
+
return JSON.parse(raw);
|
|
29
|
+
} catch {
|
|
30
|
+
return { services: {} };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function readProjectConfig(projectRoot) {
|
|
34
|
+
const configPath = path.join(projectConfigDir(projectRoot), "config.json");
|
|
35
|
+
try {
|
|
36
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
37
|
+
return JSON.parse(raw);
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function isInitialized(projectRoot) {
|
|
43
|
+
return readProjectConfig(projectRoot) !== null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/core/ledger.ts
|
|
47
|
+
import * as fs2 from "fs";
|
|
48
|
+
import * as path2 from "path";
|
|
49
|
+
|
|
50
|
+
// src/core/types.ts
|
|
51
|
+
var CONFIDENCE_BADGES = {
|
|
52
|
+
live: "\u2705 LIVE",
|
|
53
|
+
calc: "\u{1F7E1} CALC",
|
|
54
|
+
est: "\u{1F7E0} EST",
|
|
55
|
+
blind: "\u{1F534} BLIND"
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/core/ledger.ts
|
|
59
|
+
function readLatestSnapshot(projectRoot) {
|
|
60
|
+
const snapshotDir = path2.join(projectDataDir(projectRoot), "snapshots");
|
|
61
|
+
try {
|
|
62
|
+
const files = fs2.readdirSync(snapshotDir).filter((f) => f.startsWith("snapshot-") && f.endsWith(".json")).sort().reverse();
|
|
63
|
+
if (files.length === 0) return null;
|
|
64
|
+
const raw = fs2.readFileSync(
|
|
65
|
+
path2.join(snapshotDir, files[0]),
|
|
66
|
+
"utf-8"
|
|
67
|
+
);
|
|
68
|
+
return JSON.parse(raw);
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/services/base.ts
|
|
75
|
+
async function fetchJson(url2, options = {}) {
|
|
76
|
+
try {
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
const timeoutId = setTimeout(
|
|
79
|
+
() => controller.abort(),
|
|
80
|
+
options.timeout ?? 1e4
|
|
81
|
+
);
|
|
82
|
+
const response = await fetch(url2, {
|
|
83
|
+
method: options.method ?? "GET",
|
|
84
|
+
headers: options.headers,
|
|
85
|
+
body: options.body,
|
|
86
|
+
signal: controller.signal
|
|
87
|
+
});
|
|
88
|
+
clearTimeout(timeoutId);
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
status: response.status,
|
|
93
|
+
error: `HTTP ${response.status}: ${response.statusText}`
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const data = await response.json();
|
|
97
|
+
return { ok: true, status: response.status, data };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
status: 0,
|
|
102
|
+
error: err instanceof Error ? err.message : "Unknown error"
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/services/anthropic.ts
|
|
108
|
+
var anthropicConnector = {
|
|
109
|
+
serviceId: "anthropic",
|
|
110
|
+
async fetchSpend(apiKey) {
|
|
111
|
+
const now = /* @__PURE__ */ new Date();
|
|
112
|
+
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
113
|
+
const startDate = startOfMonth.toISOString().split("T")[0];
|
|
114
|
+
const endDate = now.toISOString().split("T")[0];
|
|
115
|
+
const url2 = `https://api.anthropic.com/v1/organizations/usage?start_date=${startDate}&end_date=${endDate}`;
|
|
116
|
+
const result = await fetchJson(url2, {
|
|
117
|
+
headers: {
|
|
118
|
+
"x-api-key": apiKey,
|
|
119
|
+
"anthropic-version": "2023-06-01"
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
if (!result.ok || !result.data) {
|
|
123
|
+
return {
|
|
124
|
+
serviceId: "anthropic",
|
|
125
|
+
spend: 0,
|
|
126
|
+
isEstimate: true,
|
|
127
|
+
tier: "est",
|
|
128
|
+
error: result.error ?? "Failed to fetch Anthropic usage"
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
let totalSpend = 0;
|
|
132
|
+
if (result.data.total_cost_usd !== void 0) {
|
|
133
|
+
totalSpend = result.data.total_cost_usd;
|
|
134
|
+
} else if (result.data.data) {
|
|
135
|
+
totalSpend = result.data.data.reduce(
|
|
136
|
+
(sum, entry) => sum + (entry.total_cost_usd ?? entry.spend ?? 0),
|
|
137
|
+
0
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
serviceId: "anthropic",
|
|
142
|
+
spend: totalSpend,
|
|
143
|
+
isEstimate: false,
|
|
144
|
+
tier: "live",
|
|
145
|
+
raw: result.data
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// src/services/openai.ts
|
|
151
|
+
var openaiConnector = {
|
|
152
|
+
serviceId: "openai",
|
|
153
|
+
async fetchSpend(apiKey) {
|
|
154
|
+
const now = /* @__PURE__ */ new Date();
|
|
155
|
+
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
156
|
+
const startTime = Math.floor(startOfMonth.getTime() / 1e3);
|
|
157
|
+
const url2 = `https://api.openai.com/v1/organization/costs?start_time=${startTime}`;
|
|
158
|
+
const result = await fetchJson(url2, {
|
|
159
|
+
headers: {
|
|
160
|
+
Authorization: `Bearer ${apiKey}`
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
if (!result.ok || !result.data) {
|
|
164
|
+
return {
|
|
165
|
+
serviceId: "openai",
|
|
166
|
+
spend: 0,
|
|
167
|
+
isEstimate: true,
|
|
168
|
+
tier: "est",
|
|
169
|
+
error: result.error ?? "Failed to fetch OpenAI usage"
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
let totalSpend = 0;
|
|
173
|
+
if (result.data.data) {
|
|
174
|
+
for (const bucket of result.data.data) {
|
|
175
|
+
if (bucket.results) {
|
|
176
|
+
for (const r of bucket.results) {
|
|
177
|
+
totalSpend += r.amount?.value ?? 0;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
totalSpend = totalSpend / 100;
|
|
183
|
+
return {
|
|
184
|
+
serviceId: "openai",
|
|
185
|
+
spend: totalSpend,
|
|
186
|
+
isEstimate: false,
|
|
187
|
+
tier: "live",
|
|
188
|
+
raw: result.data
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// src/services/vercel.ts
|
|
194
|
+
var vercelConnector = {
|
|
195
|
+
serviceId: "vercel",
|
|
196
|
+
async fetchSpend(token, options) {
|
|
197
|
+
const teamId = options?.["teamId"] ?? "";
|
|
198
|
+
const teamParam = teamId ? `?teamId=${teamId}` : "";
|
|
199
|
+
const url2 = `https://api.vercel.com/v2/usage${teamParam}`;
|
|
200
|
+
const result = await fetchJson(url2, {
|
|
201
|
+
headers: {
|
|
202
|
+
Authorization: `Bearer ${token}`
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
if (!result.ok || !result.data) {
|
|
206
|
+
return {
|
|
207
|
+
serviceId: "vercel",
|
|
208
|
+
spend: 0,
|
|
209
|
+
isEstimate: true,
|
|
210
|
+
tier: "est",
|
|
211
|
+
error: result.error ?? "Failed to fetch Vercel usage"
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
let totalSpend = 0;
|
|
215
|
+
if (result.data.usage?.total !== void 0) {
|
|
216
|
+
totalSpend = result.data.usage.total;
|
|
217
|
+
} else if (result.data.billing?.invoiceItems) {
|
|
218
|
+
totalSpend = result.data.billing.invoiceItems.reduce(
|
|
219
|
+
(sum, item) => sum + (item.amount ?? 0),
|
|
220
|
+
0
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
serviceId: "vercel",
|
|
225
|
+
spend: totalSpend,
|
|
226
|
+
isEstimate: false,
|
|
227
|
+
tier: "live",
|
|
228
|
+
raw: result.data
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// src/services/scrapfly.ts
|
|
234
|
+
var scrapflyConnector = {
|
|
235
|
+
serviceId: "scrapfly",
|
|
236
|
+
async fetchSpend(apiKey) {
|
|
237
|
+
const url2 = `https://api.scrapfly.io/account?key=${apiKey}`;
|
|
238
|
+
const result = await fetchJson(url2);
|
|
239
|
+
if (!result.ok || !result.data) {
|
|
240
|
+
return {
|
|
241
|
+
serviceId: "scrapfly",
|
|
242
|
+
spend: 0,
|
|
243
|
+
isEstimate: true,
|
|
244
|
+
tier: "est",
|
|
245
|
+
error: result.error ?? "Failed to fetch Scrapfly account"
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
let creditsUsed = 0;
|
|
249
|
+
let creditsTotal = 0;
|
|
250
|
+
if (result.data.subscription?.usage?.scrape) {
|
|
251
|
+
creditsUsed = result.data.subscription.usage.scrape.used ?? 0;
|
|
252
|
+
creditsTotal = result.data.subscription.usage.scrape.allowed ?? 0;
|
|
253
|
+
} else if (result.data.account) {
|
|
254
|
+
creditsUsed = result.data.account.credits_used ?? 0;
|
|
255
|
+
creditsTotal = result.data.account.credits_total ?? 0;
|
|
256
|
+
}
|
|
257
|
+
const creditRate = 15e-5;
|
|
258
|
+
const spend = creditsUsed * creditRate;
|
|
259
|
+
return {
|
|
260
|
+
serviceId: "scrapfly",
|
|
261
|
+
spend,
|
|
262
|
+
isEstimate: false,
|
|
263
|
+
tier: "live",
|
|
264
|
+
raw: {
|
|
265
|
+
credits_used: creditsUsed,
|
|
266
|
+
credits_total: creditsTotal,
|
|
267
|
+
credit_rate: creditRate,
|
|
268
|
+
...result.data
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// src/core/registry.ts
|
|
275
|
+
import * as fs3 from "fs";
|
|
276
|
+
import * as path3 from "path";
|
|
277
|
+
import * as url from "url";
|
|
278
|
+
var __dirname = path3.dirname(url.fileURLToPath(import.meta.url));
|
|
279
|
+
var cachedRegistry = null;
|
|
280
|
+
function loadRegistry(projectRoot) {
|
|
281
|
+
if (cachedRegistry) return cachedRegistry;
|
|
282
|
+
const registry = /* @__PURE__ */ new Map();
|
|
283
|
+
const candidates = [
|
|
284
|
+
path3.resolve(__dirname, "../../registry.json"),
|
|
285
|
+
// from src/core/
|
|
286
|
+
path3.resolve(__dirname, "../registry.json")
|
|
287
|
+
// from dist/
|
|
288
|
+
];
|
|
289
|
+
for (const candidate of candidates) {
|
|
290
|
+
if (fs3.existsSync(candidate)) {
|
|
291
|
+
loadRegistryFile(candidate, registry);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (projectRoot) {
|
|
296
|
+
const localPath = path3.join(projectRoot, ".burnwatch", "registry.json");
|
|
297
|
+
if (fs3.existsSync(localPath)) {
|
|
298
|
+
loadRegistryFile(localPath, registry);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
cachedRegistry = registry;
|
|
302
|
+
return registry;
|
|
303
|
+
}
|
|
304
|
+
function loadRegistryFile(filePath, registry) {
|
|
305
|
+
try {
|
|
306
|
+
const raw = fs3.readFileSync(filePath, "utf-8");
|
|
307
|
+
const data = JSON.parse(raw);
|
|
308
|
+
for (const [id, service] of Object.entries(data.services)) {
|
|
309
|
+
registry.set(id, { ...service, id });
|
|
310
|
+
}
|
|
311
|
+
} catch {
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function getService(id, projectRoot) {
|
|
315
|
+
return loadRegistry(projectRoot).get(id);
|
|
316
|
+
}
|
|
317
|
+
function getAllServices(projectRoot) {
|
|
318
|
+
return Array.from(loadRegistry(projectRoot).values());
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/services/index.ts
|
|
322
|
+
var connectors = /* @__PURE__ */ new Map([
|
|
323
|
+
["anthropic", anthropicConnector],
|
|
324
|
+
["openai", openaiConnector],
|
|
325
|
+
["vercel", vercelConnector],
|
|
326
|
+
["scrapfly", scrapflyConnector]
|
|
327
|
+
]);
|
|
328
|
+
async function pollService(tracked) {
|
|
329
|
+
const globalConfig = readGlobalConfig();
|
|
330
|
+
const serviceConfig = globalConfig.services[tracked.serviceId];
|
|
331
|
+
const connector = connectors.get(tracked.serviceId);
|
|
332
|
+
const definition = getService(tracked.serviceId);
|
|
333
|
+
if (connector && serviceConfig?.apiKey) {
|
|
334
|
+
try {
|
|
335
|
+
const result = await connector.fetchSpend(
|
|
336
|
+
serviceConfig.apiKey,
|
|
337
|
+
serviceConfig
|
|
338
|
+
);
|
|
339
|
+
if (!result.error) return result;
|
|
340
|
+
} catch {
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (tracked.planCost !== void 0) {
|
|
344
|
+
const now = /* @__PURE__ */ new Date();
|
|
345
|
+
const daysInMonth = new Date(
|
|
346
|
+
now.getFullYear(),
|
|
347
|
+
now.getMonth() + 1,
|
|
348
|
+
0
|
|
349
|
+
).getDate();
|
|
350
|
+
const dayOfMonth = now.getDate();
|
|
351
|
+
const projectedSpend = tracked.planCost / daysInMonth * dayOfMonth;
|
|
352
|
+
return {
|
|
353
|
+
serviceId: tracked.serviceId,
|
|
354
|
+
spend: projectedSpend,
|
|
355
|
+
isEstimate: true,
|
|
356
|
+
tier: "calc"
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (definition) {
|
|
360
|
+
let tier;
|
|
361
|
+
if (tracked.tierOverride) {
|
|
362
|
+
tier = tracked.tierOverride;
|
|
363
|
+
} else if (definition.apiTier === "live") {
|
|
364
|
+
tier = "blind";
|
|
365
|
+
} else {
|
|
366
|
+
tier = definition.apiTier;
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
serviceId: tracked.serviceId,
|
|
370
|
+
spend: 0,
|
|
371
|
+
isEstimate: tier !== "live",
|
|
372
|
+
tier,
|
|
373
|
+
error: tier === "blind" ? "No API key configured" : void 0
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
serviceId: tracked.serviceId,
|
|
378
|
+
spend: 0,
|
|
379
|
+
isEstimate: true,
|
|
380
|
+
tier: "blind",
|
|
381
|
+
error: "Unknown service \u2014 not in registry"
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
async function pollAllServices(services) {
|
|
385
|
+
return Promise.all(services.map(pollService));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// src/core/brief.ts
|
|
389
|
+
function formatBrief(brief) {
|
|
390
|
+
const lines = [];
|
|
391
|
+
const width = 62;
|
|
392
|
+
const hrDouble = "\u2550".repeat(width);
|
|
393
|
+
const hrSingle = "\u2500".repeat(width - 4);
|
|
394
|
+
lines.push(`\u2554${hrDouble}\u2557`);
|
|
395
|
+
lines.push(
|
|
396
|
+
`\u2551 BURNWATCH \u2014 ${brief.projectName} \u2014 ${brief.period}`.padEnd(
|
|
397
|
+
width + 1
|
|
398
|
+
) + "\u2551"
|
|
399
|
+
);
|
|
400
|
+
lines.push(`\u2560${hrDouble}\u2563`);
|
|
401
|
+
lines.push(
|
|
402
|
+
formatRow("Service", "Spend", "Conf", "Budget", "Left", width)
|
|
403
|
+
);
|
|
404
|
+
lines.push(`\u2551 ${hrSingle} \u2551`);
|
|
405
|
+
for (const svc of brief.services) {
|
|
406
|
+
const spendStr = svc.isEstimate ? `~$${svc.spend.toFixed(2)}` : `$${svc.spend.toFixed(2)}`;
|
|
407
|
+
const badge = CONFIDENCE_BADGES[svc.tier];
|
|
408
|
+
const budgetStr = svc.budget ? `$${svc.budget}` : "\u2014";
|
|
409
|
+
const leftStr = formatLeft(svc);
|
|
410
|
+
lines.push(formatRow(svc.serviceId, spendStr, badge, budgetStr, leftStr, width));
|
|
411
|
+
}
|
|
412
|
+
lines.push(`\u2560${hrDouble}\u2563`);
|
|
413
|
+
const totalStr = brief.totalIsEstimate ? `~$${brief.totalSpend.toFixed(2)}` : `$${brief.totalSpend.toFixed(2)}`;
|
|
414
|
+
const marginStr = brief.estimateMargin > 0 ? ` Est margin: \xB1$${brief.estimateMargin.toFixed(0)}` : "";
|
|
415
|
+
const untrackedStr = brief.untrackedCount > 0 ? `Untracked: ${brief.untrackedCount} \u26A0\uFE0F` : `Untracked: 0 \u2705`;
|
|
416
|
+
lines.push(
|
|
417
|
+
`\u2551 TOTAL: ${totalStr} ${untrackedStr}${marginStr}`.padEnd(
|
|
418
|
+
width + 1
|
|
419
|
+
) + "\u2551"
|
|
420
|
+
);
|
|
421
|
+
for (const alert of brief.alerts) {
|
|
422
|
+
const icon = alert.severity === "critical" ? "\u{1F6A8}" : "\u26A0\uFE0F";
|
|
423
|
+
lines.push(
|
|
424
|
+
`\u2551 ${icon} ${alert.message}`.padEnd(width + 1) + "\u2551"
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
lines.push(`\u255A${hrDouble}\u255D`);
|
|
428
|
+
return lines.join("\n");
|
|
429
|
+
}
|
|
430
|
+
function formatSpendCard(snapshot) {
|
|
431
|
+
const badge = CONFIDENCE_BADGES[snapshot.tier];
|
|
432
|
+
const spendStr = snapshot.isEstimate ? `~$${snapshot.spend.toFixed(2)}` : `$${snapshot.spend.toFixed(2)}`;
|
|
433
|
+
const budgetStr = snapshot.budget ? `Budget: $${snapshot.budget}` : "No budget set";
|
|
434
|
+
const statusStr = snapshot.statusLabel;
|
|
435
|
+
const lines = [
|
|
436
|
+
`[BURNWATCH] ${snapshot.serviceId} \u2014 current period`,
|
|
437
|
+
` Spend: ${spendStr} | ${budgetStr} | ${statusStr}`,
|
|
438
|
+
` Confidence: ${badge}`
|
|
439
|
+
];
|
|
440
|
+
if (snapshot.status === "over" && snapshot.budgetPercent) {
|
|
441
|
+
lines.push(
|
|
442
|
+
` \u26A0\uFE0F ${snapshot.budgetPercent.toFixed(0)}% of budget consumed`
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
return lines.join("\n");
|
|
446
|
+
}
|
|
447
|
+
function buildBrief(projectName, snapshots, blindCount) {
|
|
448
|
+
const now = /* @__PURE__ */ new Date();
|
|
449
|
+
const period = now.toLocaleDateString("en-US", {
|
|
450
|
+
month: "long",
|
|
451
|
+
year: "numeric"
|
|
452
|
+
});
|
|
453
|
+
let totalSpend = 0;
|
|
454
|
+
let hasEstimates = false;
|
|
455
|
+
let estimateMargin = 0;
|
|
456
|
+
const alerts = [];
|
|
457
|
+
for (const snap of snapshots) {
|
|
458
|
+
totalSpend += snap.spend;
|
|
459
|
+
if (snap.isEstimate) {
|
|
460
|
+
hasEstimates = true;
|
|
461
|
+
estimateMargin += snap.spend * 0.15;
|
|
462
|
+
}
|
|
463
|
+
if (snap.status === "over") {
|
|
464
|
+
alerts.push({
|
|
465
|
+
serviceId: snap.serviceId,
|
|
466
|
+
type: "over_budget",
|
|
467
|
+
message: `${snap.serviceId.toUpperCase()} ${snap.budgetPercent?.toFixed(0) ?? "?"}% OVER BUDGET \u2014 review before use`,
|
|
468
|
+
severity: "critical"
|
|
469
|
+
});
|
|
470
|
+
} else if (snap.status === "caution" && snap.budgetPercent && snap.budgetPercent >= 80) {
|
|
471
|
+
alerts.push({
|
|
472
|
+
serviceId: snap.serviceId,
|
|
473
|
+
type: "near_budget",
|
|
474
|
+
message: `${snap.serviceId} at ${snap.budgetPercent.toFixed(0)}% of budget`,
|
|
475
|
+
severity: "warning"
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (blindCount > 0) {
|
|
480
|
+
alerts.push({
|
|
481
|
+
serviceId: "_blind",
|
|
482
|
+
type: "blind_service",
|
|
483
|
+
message: `${blindCount} service${blindCount > 1 ? "s" : ""} detected but untracked \u2014 run 'burnwatch status' to see`,
|
|
484
|
+
severity: "warning"
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
projectName,
|
|
489
|
+
generatedAt: now.toISOString(),
|
|
490
|
+
period,
|
|
491
|
+
services: snapshots,
|
|
492
|
+
totalSpend,
|
|
493
|
+
totalIsEstimate: hasEstimates,
|
|
494
|
+
estimateMargin,
|
|
495
|
+
untrackedCount: blindCount,
|
|
496
|
+
alerts
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function formatRow(service, spend, conf, budget, left, width) {
|
|
500
|
+
const row = ` ${service.padEnd(14)} ${spend.padEnd(11)} ${conf.padEnd(7)} ${budget.padEnd(7)} ${left}`;
|
|
501
|
+
return `\u2551${row}`.padEnd(width + 1) + "\u2551";
|
|
502
|
+
}
|
|
503
|
+
function formatLeft(snap) {
|
|
504
|
+
if (!snap.budget) return "\u2014";
|
|
505
|
+
if (snap.status === "over") return "\u26A0\uFE0F OVR";
|
|
506
|
+
if (snap.budgetPercent !== void 0) {
|
|
507
|
+
const remaining = 100 - snap.budgetPercent;
|
|
508
|
+
return `${remaining.toFixed(0)}%`;
|
|
509
|
+
}
|
|
510
|
+
return "\u2014";
|
|
511
|
+
}
|
|
512
|
+
function buildSnapshot(serviceId, tier, spend, budget) {
|
|
513
|
+
const isEstimate = tier === "est" || tier === "calc";
|
|
514
|
+
const budgetPercent = budget ? spend / budget * 100 : void 0;
|
|
515
|
+
let status = "unknown";
|
|
516
|
+
let statusLabel = "no budget";
|
|
517
|
+
if (budget) {
|
|
518
|
+
if (budgetPercent > 100) {
|
|
519
|
+
status = "over";
|
|
520
|
+
statusLabel = `\u26A0\uFE0F ${budgetPercent.toFixed(0)}% over`;
|
|
521
|
+
} else if (budgetPercent >= 75) {
|
|
522
|
+
status = "caution";
|
|
523
|
+
statusLabel = `${(100 - budgetPercent).toFixed(0)}% \u2014 caution`;
|
|
524
|
+
} else {
|
|
525
|
+
status = "healthy";
|
|
526
|
+
statusLabel = `${(100 - budgetPercent).toFixed(0)}% \u2014 healthy`;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (tier === "calc" && budget) {
|
|
530
|
+
statusLabel = `flat \u2014 on plan`;
|
|
531
|
+
status = "healthy";
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
serviceId,
|
|
535
|
+
spend,
|
|
536
|
+
isEstimate,
|
|
537
|
+
tier,
|
|
538
|
+
budget,
|
|
539
|
+
budgetPercent,
|
|
540
|
+
status,
|
|
541
|
+
statusLabel,
|
|
542
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// src/detection/detector.ts
|
|
547
|
+
import * as fs4 from "fs";
|
|
548
|
+
import * as path4 from "path";
|
|
549
|
+
function detectServices(projectRoot) {
|
|
550
|
+
const registry = loadRegistry(projectRoot);
|
|
551
|
+
const results = /* @__PURE__ */ new Map();
|
|
552
|
+
const pkgDeps = scanAllPackageJsons(projectRoot);
|
|
553
|
+
for (const [serviceId, service] of registry) {
|
|
554
|
+
const matchedPkgs = service.packageNames.filter(
|
|
555
|
+
(pkg) => pkgDeps.has(pkg)
|
|
556
|
+
);
|
|
557
|
+
if (matchedPkgs.length > 0) {
|
|
558
|
+
getOrCreate(results, serviceId, service).sources.push("package_json");
|
|
559
|
+
getOrCreate(results, serviceId, service).details.push(
|
|
560
|
+
`package.json: ${matchedPkgs.join(", ")}`
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const envVars = collectEnvVars(projectRoot);
|
|
565
|
+
for (const [serviceId, service] of registry) {
|
|
566
|
+
const matchedEnvs = service.envPatterns.filter(
|
|
567
|
+
(pattern) => envVars.has(pattern)
|
|
568
|
+
);
|
|
569
|
+
if (matchedEnvs.length > 0) {
|
|
570
|
+
getOrCreate(results, serviceId, service).sources.push("env_var");
|
|
571
|
+
getOrCreate(results, serviceId, service).details.push(
|
|
572
|
+
`env vars: ${matchedEnvs.join(", ")}`
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const importHits = scanImports(projectRoot);
|
|
577
|
+
for (const [serviceId, service] of registry) {
|
|
578
|
+
const matchedImports = service.importPatterns.filter(
|
|
579
|
+
(pattern) => importHits.has(pattern)
|
|
580
|
+
);
|
|
581
|
+
if (matchedImports.length > 0) {
|
|
582
|
+
if (!getOrCreate(results, serviceId, service).sources.includes(
|
|
583
|
+
"import_scan"
|
|
584
|
+
)) {
|
|
585
|
+
getOrCreate(results, serviceId, service).sources.push("import_scan");
|
|
586
|
+
getOrCreate(results, serviceId, service).details.push(
|
|
587
|
+
`imports: ${matchedImports.join(", ")}`
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return Array.from(results.values());
|
|
593
|
+
}
|
|
594
|
+
function getOrCreate(map, serviceId, service) {
|
|
595
|
+
let result = map.get(serviceId);
|
|
596
|
+
if (!result) {
|
|
597
|
+
result = { service, sources: [], details: [] };
|
|
598
|
+
map.set(serviceId, result);
|
|
599
|
+
}
|
|
600
|
+
return result;
|
|
601
|
+
}
|
|
602
|
+
function scanAllPackageJsons(projectRoot) {
|
|
603
|
+
const deps = /* @__PURE__ */ new Set();
|
|
604
|
+
const pkgFiles = findFiles(projectRoot, "package.json", 4);
|
|
605
|
+
for (const pkgPath of pkgFiles) {
|
|
606
|
+
try {
|
|
607
|
+
const raw = fs4.readFileSync(pkgPath, "utf-8");
|
|
608
|
+
const pkg = JSON.parse(raw);
|
|
609
|
+
for (const name of Object.keys(pkg.dependencies ?? {})) deps.add(name);
|
|
610
|
+
for (const name of Object.keys(pkg.devDependencies ?? {})) deps.add(name);
|
|
611
|
+
} catch {
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return deps;
|
|
615
|
+
}
|
|
616
|
+
function collectEnvVars(projectRoot) {
|
|
617
|
+
const envVars = new Set(Object.keys(process.env));
|
|
618
|
+
const envFiles = findEnvFiles(projectRoot, 3);
|
|
619
|
+
for (const envFile of envFiles) {
|
|
620
|
+
try {
|
|
621
|
+
const content = fs4.readFileSync(envFile, "utf-8");
|
|
622
|
+
const keys = content.split("\n").filter((line) => line.includes("=") && !line.startsWith("#")).map((line) => line.split("=")[0].trim()).filter(Boolean);
|
|
623
|
+
for (const key of keys) {
|
|
624
|
+
envVars.add(key);
|
|
625
|
+
}
|
|
626
|
+
} catch {
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return envVars;
|
|
630
|
+
}
|
|
631
|
+
function findEnvFiles(dir, maxDepth) {
|
|
632
|
+
const results = [];
|
|
633
|
+
if (maxDepth <= 0) return results;
|
|
634
|
+
try {
|
|
635
|
+
const entries = fs4.readdirSync(dir, { withFileTypes: true });
|
|
636
|
+
for (const entry of entries) {
|
|
637
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue;
|
|
638
|
+
const fullPath = path4.join(dir, entry.name);
|
|
639
|
+
if (entry.isDirectory()) {
|
|
640
|
+
results.push(...findEnvFiles(fullPath, maxDepth - 1));
|
|
641
|
+
} else if (entry.name.startsWith(".env")) {
|
|
642
|
+
results.push(fullPath);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
} catch {
|
|
646
|
+
}
|
|
647
|
+
return results;
|
|
648
|
+
}
|
|
649
|
+
function findFiles(dir, fileName, maxDepth) {
|
|
650
|
+
const results = [];
|
|
651
|
+
if (maxDepth <= 0) return results;
|
|
652
|
+
try {
|
|
653
|
+
const entries = fs4.readdirSync(dir, { withFileTypes: true });
|
|
654
|
+
for (const entry of entries) {
|
|
655
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue;
|
|
656
|
+
const fullPath = path4.join(dir, entry.name);
|
|
657
|
+
if (entry.isDirectory()) {
|
|
658
|
+
results.push(...findFiles(fullPath, fileName, maxDepth - 1));
|
|
659
|
+
} else if (entry.name === fileName) {
|
|
660
|
+
results.push(fullPath);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
} catch {
|
|
664
|
+
}
|
|
665
|
+
return results;
|
|
666
|
+
}
|
|
667
|
+
function scanImports(projectRoot) {
|
|
668
|
+
const imports = /* @__PURE__ */ new Set();
|
|
669
|
+
const codeDirs = ["src", "app", "lib", "pages", "components", "utils", "services", "hooks"];
|
|
670
|
+
const dirsToScan = [];
|
|
671
|
+
for (const dir of codeDirs) {
|
|
672
|
+
const fullPath = path4.join(projectRoot, dir);
|
|
673
|
+
if (fs4.existsSync(fullPath)) {
|
|
674
|
+
dirsToScan.push(fullPath);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
const entries = fs4.readdirSync(projectRoot, { withFileTypes: true });
|
|
679
|
+
for (const entry of entries) {
|
|
680
|
+
if (!entry.isDirectory()) continue;
|
|
681
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist" || entry.name.startsWith(".")) continue;
|
|
682
|
+
const subPkgPath = path4.join(projectRoot, entry.name, "package.json");
|
|
683
|
+
if (fs4.existsSync(subPkgPath)) {
|
|
684
|
+
for (const dir of codeDirs) {
|
|
685
|
+
const fullPath = path4.join(projectRoot, entry.name, dir);
|
|
686
|
+
if (fs4.existsSync(fullPath)) {
|
|
687
|
+
dirsToScan.push(fullPath);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
} catch {
|
|
693
|
+
}
|
|
694
|
+
for (const dir of dirsToScan) {
|
|
695
|
+
const files = walkDir(dir, /\.(ts|tsx|js|jsx|mjs|cjs)$/);
|
|
696
|
+
for (const file of files) {
|
|
697
|
+
try {
|
|
698
|
+
const content = fs4.readFileSync(file, "utf-8");
|
|
699
|
+
const importRegex = /(?:from\s+["']|require\s*\(\s*["'])([^./][^"']*?)(?:["'])/g;
|
|
700
|
+
let match;
|
|
701
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
702
|
+
const pkg = match[1];
|
|
703
|
+
if (pkg) {
|
|
704
|
+
const parts = pkg.split("/");
|
|
705
|
+
if (parts[0]?.startsWith("@") && parts.length >= 2) {
|
|
706
|
+
imports.add(`${parts[0]}/${parts[1]}`);
|
|
707
|
+
} else if (parts[0]) {
|
|
708
|
+
imports.add(parts[0]);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
} catch {
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return imports;
|
|
717
|
+
}
|
|
718
|
+
function walkDir(dir, pattern, maxDepth = 5) {
|
|
719
|
+
const results = [];
|
|
720
|
+
if (maxDepth <= 0) return results;
|
|
721
|
+
try {
|
|
722
|
+
const entries = fs4.readdirSync(dir, { withFileTypes: true });
|
|
723
|
+
for (const entry of entries) {
|
|
724
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
725
|
+
const fullPath = path4.join(dir, entry.name);
|
|
726
|
+
if (entry.isDirectory()) {
|
|
727
|
+
results.push(...walkDir(fullPath, pattern, maxDepth - 1));
|
|
728
|
+
} else if (pattern.test(entry.name)) {
|
|
729
|
+
results.push(fullPath);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
} catch {
|
|
733
|
+
}
|
|
734
|
+
return results;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/mcp-server.ts
|
|
738
|
+
var server = new McpServer({
|
|
739
|
+
name: "burnwatch",
|
|
740
|
+
version: "0.1.2"
|
|
741
|
+
});
|
|
742
|
+
server.tool(
|
|
743
|
+
"get_spend_brief",
|
|
744
|
+
"Get the current spend brief for this project. Shows all tracked services, their spend, confidence tier, budget status, and alerts. Use this when a developer asks about costs, spending, budget, or wants an overview of their SaaS expenses.",
|
|
745
|
+
{
|
|
746
|
+
project_path: z.string().optional().describe(
|
|
747
|
+
"Path to the project root. Defaults to current working directory."
|
|
748
|
+
)
|
|
749
|
+
},
|
|
750
|
+
async ({ project_path }) => {
|
|
751
|
+
const projectRoot = project_path ?? process.cwd();
|
|
752
|
+
if (!isInitialized(projectRoot)) {
|
|
753
|
+
return {
|
|
754
|
+
content: [
|
|
755
|
+
{
|
|
756
|
+
type: "text",
|
|
757
|
+
text: "burnwatch is not initialized in this project. Run `npx burnwatch init` to set up spend tracking."
|
|
758
|
+
}
|
|
759
|
+
]
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
const config = readProjectConfig(projectRoot);
|
|
763
|
+
const trackedServices = Object.values(config.services);
|
|
764
|
+
if (trackedServices.length === 0) {
|
|
765
|
+
return {
|
|
766
|
+
content: [
|
|
767
|
+
{
|
|
768
|
+
type: "text",
|
|
769
|
+
text: "No services are being tracked yet. Run `burnwatch add <service>` to start tracking."
|
|
770
|
+
}
|
|
771
|
+
]
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
const cached = readLatestSnapshot(projectRoot);
|
|
775
|
+
if (cached) {
|
|
776
|
+
return {
|
|
777
|
+
content: [
|
|
778
|
+
{ type: "text", text: formatBrief(cached) }
|
|
779
|
+
]
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
const results = await pollAllServices(trackedServices);
|
|
783
|
+
const snapshots = results.map(
|
|
784
|
+
(r) => buildSnapshot(
|
|
785
|
+
r.serviceId,
|
|
786
|
+
r.tier,
|
|
787
|
+
r.spend,
|
|
788
|
+
config.services[r.serviceId]?.budget
|
|
789
|
+
)
|
|
790
|
+
);
|
|
791
|
+
const blindCount = snapshots.filter((s) => s.tier === "blind").length;
|
|
792
|
+
const brief = buildBrief(config.projectName, snapshots, blindCount);
|
|
793
|
+
return {
|
|
794
|
+
content: [{ type: "text", text: formatBrief(brief) }]
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
);
|
|
798
|
+
server.tool(
|
|
799
|
+
"get_service_spend",
|
|
800
|
+
"Get detailed spend information for a specific service. Includes current spend, budget status, confidence tier, pricing model, gotchas, and cheaper alternatives. Use this when a developer mentions a specific paid service or asks about its cost.",
|
|
801
|
+
{
|
|
802
|
+
service_id: z.string().describe(
|
|
803
|
+
"The service identifier (e.g., 'anthropic', 'scrapfly', 'vercel', 'supabase')"
|
|
804
|
+
),
|
|
805
|
+
project_path: z.string().optional().describe("Path to the project root. Defaults to cwd.")
|
|
806
|
+
},
|
|
807
|
+
async ({ service_id, project_path }) => {
|
|
808
|
+
const projectRoot = project_path ?? process.cwd();
|
|
809
|
+
const definition = getService(service_id, projectRoot);
|
|
810
|
+
if (!definition) {
|
|
811
|
+
return {
|
|
812
|
+
content: [
|
|
813
|
+
{
|
|
814
|
+
type: "text",
|
|
815
|
+
text: `Service "${service_id}" not found in the burnwatch registry. Run \`burnwatch services\` to see available services.`
|
|
816
|
+
}
|
|
817
|
+
]
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
const lines = [];
|
|
821
|
+
lines.push(`## ${definition.name}`);
|
|
822
|
+
lines.push("");
|
|
823
|
+
if (isInitialized(projectRoot)) {
|
|
824
|
+
const snapshot = readLatestSnapshot(projectRoot);
|
|
825
|
+
const serviceSnap = snapshot?.services.find(
|
|
826
|
+
(s) => s.serviceId === service_id
|
|
827
|
+
);
|
|
828
|
+
if (serviceSnap) {
|
|
829
|
+
lines.push(formatSpendCard(serviceSnap));
|
|
830
|
+
lines.push("");
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
lines.push(`**Billing model:** ${definition.billingModel}`);
|
|
834
|
+
lines.push(`**Scaling:** ${definition.scalingShape}`);
|
|
835
|
+
lines.push(
|
|
836
|
+
`**Tracking tier:** ${definition.apiTier.toUpperCase()}`
|
|
837
|
+
);
|
|
838
|
+
if (definition.pricing?.formula) {
|
|
839
|
+
lines.push(`**Pricing formula:** ${definition.pricing.formula}`);
|
|
840
|
+
}
|
|
841
|
+
if (definition.gotchas && definition.gotchas.length > 0) {
|
|
842
|
+
lines.push("");
|
|
843
|
+
lines.push("**Cost gotchas:**");
|
|
844
|
+
for (const g of definition.gotchas) {
|
|
845
|
+
lines.push(`- ${g}`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (definition.alternatives && definition.alternatives.length > 0) {
|
|
849
|
+
lines.push("");
|
|
850
|
+
lines.push(
|
|
851
|
+
`**Cheaper alternatives:** ${definition.alternatives.join(", ")}`
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
if (definition.docsUrl) {
|
|
855
|
+
lines.push("");
|
|
856
|
+
lines.push(`**Pricing docs:** ${definition.docsUrl}`);
|
|
857
|
+
}
|
|
858
|
+
return {
|
|
859
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
);
|
|
863
|
+
server.tool(
|
|
864
|
+
"detect_paid_services",
|
|
865
|
+
"Scan a project for paid services. Detects services via package.json dependencies, environment variables, and import statements. Use this when a developer wants to know what paid services are in their project, or before recommending a new paid tool.",
|
|
866
|
+
{
|
|
867
|
+
project_path: z.string().optional().describe("Path to the project root. Defaults to cwd.")
|
|
868
|
+
},
|
|
869
|
+
async ({ project_path }) => {
|
|
870
|
+
const projectRoot = project_path ?? process.cwd();
|
|
871
|
+
const detected = detectServices(projectRoot);
|
|
872
|
+
if (detected.length === 0) {
|
|
873
|
+
return {
|
|
874
|
+
content: [
|
|
875
|
+
{
|
|
876
|
+
type: "text",
|
|
877
|
+
text: "No paid services detected in this project."
|
|
878
|
+
}
|
|
879
|
+
]
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
const lines = [];
|
|
883
|
+
lines.push(
|
|
884
|
+
`Found ${detected.length} paid service${detected.length > 1 ? "s" : ""}:`
|
|
885
|
+
);
|
|
886
|
+
lines.push("");
|
|
887
|
+
for (const det of detected) {
|
|
888
|
+
const tierLabel = det.service.apiTier === "live" ? "\u2705 LIVE" : det.service.apiTier === "calc" ? "\u{1F7E1} CALC" : det.service.apiTier === "est" ? "\u{1F7E0} EST" : "\u{1F534} BLIND";
|
|
889
|
+
lines.push(
|
|
890
|
+
`- **${det.service.name}** (${tierLabel}) \u2014 ${det.details.join(", ")}`
|
|
891
|
+
);
|
|
892
|
+
if (det.service.gotchas && det.service.gotchas.length > 0) {
|
|
893
|
+
lines.push(` \u26A0\uFE0F ${det.service.gotchas[0]}`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
lines.push("");
|
|
897
|
+
lines.push(
|
|
898
|
+
"Run `npx burnwatch init` to start tracking spend for all detected services."
|
|
899
|
+
);
|
|
900
|
+
return {
|
|
901
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
);
|
|
905
|
+
server.tool(
|
|
906
|
+
"list_registry_services",
|
|
907
|
+
"List all services in the burnwatch registry with their tracking tier and billing model. Use this to see what services burnwatch can track.",
|
|
908
|
+
{},
|
|
909
|
+
async () => {
|
|
910
|
+
const services = getAllServices();
|
|
911
|
+
const lines = [];
|
|
912
|
+
lines.push(`burnwatch registry: ${services.length} services`);
|
|
913
|
+
lines.push("");
|
|
914
|
+
lines.push("| Service | Tier | Billing Model |");
|
|
915
|
+
lines.push("|---------|------|--------------|");
|
|
916
|
+
for (const svc of services) {
|
|
917
|
+
const tier = svc.apiTier === "live" ? "\u2705 LIVE" : svc.apiTier === "calc" ? "\u{1F7E1} CALC" : svc.apiTier === "est" ? "\u{1F7E0} EST" : "\u{1F534} BLIND";
|
|
918
|
+
lines.push(`| ${svc.name} | ${tier} | ${svc.billingModel} |`);
|
|
919
|
+
}
|
|
920
|
+
return {
|
|
921
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
);
|
|
925
|
+
async function main() {
|
|
926
|
+
const transport = new StdioServerTransport();
|
|
927
|
+
await server.connect(transport);
|
|
928
|
+
console.error("burnwatch MCP server running on stdio");
|
|
929
|
+
}
|
|
930
|
+
main().catch((error) => {
|
|
931
|
+
console.error("Fatal error:", error);
|
|
932
|
+
process.exit(1);
|
|
933
|
+
});
|
|
934
|
+
//# sourceMappingURL=mcp-server.js.map
|