burnwatch 0.1.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 ADDED
@@ -0,0 +1,1151 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import * as fs5 from "fs";
5
+ import * as path5 from "path";
6
+
7
+ // src/core/config.ts
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ import * as os from "os";
11
+ function globalConfigDir() {
12
+ const xdgConfig = process.env["XDG_CONFIG_HOME"];
13
+ if (xdgConfig) return path.join(xdgConfig, "burnwatch");
14
+ return path.join(os.homedir(), ".config", "burnwatch");
15
+ }
16
+ function projectConfigDir(projectRoot) {
17
+ const root = projectRoot ?? process.cwd();
18
+ return path.join(root, ".burnwatch");
19
+ }
20
+ function projectDataDir(projectRoot) {
21
+ return path.join(projectConfigDir(projectRoot), "data");
22
+ }
23
+ function readGlobalConfig() {
24
+ const configPath = path.join(globalConfigDir(), "config.json");
25
+ try {
26
+ const raw = fs.readFileSync(configPath, "utf-8");
27
+ return JSON.parse(raw);
28
+ } catch {
29
+ return { services: {} };
30
+ }
31
+ }
32
+ function writeGlobalConfig(config) {
33
+ const dir = globalConfigDir();
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ const configPath = path.join(dir, "config.json");
36
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
37
+ fs.chmodSync(configPath, 384);
38
+ }
39
+ function readProjectConfig(projectRoot) {
40
+ const configPath = path.join(projectConfigDir(projectRoot), "config.json");
41
+ try {
42
+ const raw = fs.readFileSync(configPath, "utf-8");
43
+ return JSON.parse(raw);
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+ function writeProjectConfig(config, projectRoot) {
49
+ const dir = projectConfigDir(projectRoot);
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ config.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
52
+ const configPath = path.join(dir, "config.json");
53
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
54
+ }
55
+ function ensureProjectDirs(projectRoot) {
56
+ const dirs = [
57
+ projectConfigDir(projectRoot),
58
+ projectDataDir(projectRoot),
59
+ path.join(projectDataDir(projectRoot), "cache"),
60
+ path.join(projectDataDir(projectRoot), "snapshots")
61
+ ];
62
+ for (const dir of dirs) {
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ }
65
+ }
66
+ function isInitialized(projectRoot) {
67
+ return readProjectConfig(projectRoot) !== null;
68
+ }
69
+
70
+ // src/detection/detector.ts
71
+ import * as fs3 from "fs";
72
+ import * as path3 from "path";
73
+
74
+ // src/core/registry.ts
75
+ import * as fs2 from "fs";
76
+ import * as path2 from "path";
77
+ import * as url from "url";
78
+ var __dirname = path2.dirname(url.fileURLToPath(import.meta.url));
79
+ var cachedRegistry = null;
80
+ function loadRegistry(projectRoot) {
81
+ if (cachedRegistry) return cachedRegistry;
82
+ const registry = /* @__PURE__ */ new Map();
83
+ const candidates = [
84
+ path2.resolve(__dirname, "../../registry.json"),
85
+ // from src/core/
86
+ path2.resolve(__dirname, "../registry.json")
87
+ // from dist/
88
+ ];
89
+ for (const candidate of candidates) {
90
+ if (fs2.existsSync(candidate)) {
91
+ loadRegistryFile(candidate, registry);
92
+ break;
93
+ }
94
+ }
95
+ if (projectRoot) {
96
+ const localPath = path2.join(projectRoot, ".burnwatch", "registry.json");
97
+ if (fs2.existsSync(localPath)) {
98
+ loadRegistryFile(localPath, registry);
99
+ }
100
+ }
101
+ cachedRegistry = registry;
102
+ return registry;
103
+ }
104
+ function loadRegistryFile(filePath, registry) {
105
+ try {
106
+ const raw = fs2.readFileSync(filePath, "utf-8");
107
+ const data = JSON.parse(raw);
108
+ for (const [id, service] of Object.entries(data.services)) {
109
+ registry.set(id, { ...service, id });
110
+ }
111
+ } catch {
112
+ }
113
+ }
114
+ function getService(id, projectRoot) {
115
+ return loadRegistry(projectRoot).get(id);
116
+ }
117
+ function getAllServices(projectRoot) {
118
+ return Array.from(loadRegistry(projectRoot).values());
119
+ }
120
+
121
+ // src/detection/detector.ts
122
+ function detectServices(projectRoot) {
123
+ const registry = loadRegistry(projectRoot);
124
+ const results = /* @__PURE__ */ new Map();
125
+ const pkgDeps = scanPackageJson(projectRoot);
126
+ for (const [serviceId, service] of registry) {
127
+ const matchedPkgs = service.packageNames.filter(
128
+ (pkg) => pkgDeps.has(pkg)
129
+ );
130
+ if (matchedPkgs.length > 0) {
131
+ getOrCreate(results, serviceId, service).sources.push("package_json");
132
+ getOrCreate(results, serviceId, service).details.push(
133
+ `package.json: ${matchedPkgs.join(", ")}`
134
+ );
135
+ }
136
+ }
137
+ const envVars = new Set(Object.keys(process.env));
138
+ for (const [serviceId, service] of registry) {
139
+ const matchedEnvs = service.envPatterns.filter(
140
+ (pattern) => envVars.has(pattern)
141
+ );
142
+ if (matchedEnvs.length > 0) {
143
+ getOrCreate(results, serviceId, service).sources.push("env_var");
144
+ getOrCreate(results, serviceId, service).details.push(
145
+ `env vars: ${matchedEnvs.join(", ")}`
146
+ );
147
+ }
148
+ }
149
+ const importHits = scanImports(projectRoot);
150
+ for (const [serviceId, service] of registry) {
151
+ const matchedImports = service.importPatterns.filter(
152
+ (pattern) => importHits.has(pattern)
153
+ );
154
+ if (matchedImports.length > 0) {
155
+ if (!getOrCreate(results, serviceId, service).sources.includes(
156
+ "import_scan"
157
+ )) {
158
+ getOrCreate(results, serviceId, service).sources.push("import_scan");
159
+ getOrCreate(results, serviceId, service).details.push(
160
+ `imports: ${matchedImports.join(", ")}`
161
+ );
162
+ }
163
+ }
164
+ }
165
+ return Array.from(results.values());
166
+ }
167
+ function getOrCreate(map, serviceId, service) {
168
+ let result = map.get(serviceId);
169
+ if (!result) {
170
+ result = { service, sources: [], details: [] };
171
+ map.set(serviceId, result);
172
+ }
173
+ return result;
174
+ }
175
+ function scanPackageJson(projectRoot) {
176
+ const deps = /* @__PURE__ */ new Set();
177
+ const pkgPath = path3.join(projectRoot, "package.json");
178
+ try {
179
+ const raw = fs3.readFileSync(pkgPath, "utf-8");
180
+ const pkg = JSON.parse(raw);
181
+ for (const name of Object.keys(pkg.dependencies ?? {})) deps.add(name);
182
+ for (const name of Object.keys(pkg.devDependencies ?? {})) deps.add(name);
183
+ } catch {
184
+ }
185
+ return deps;
186
+ }
187
+ function scanImports(projectRoot) {
188
+ const imports = /* @__PURE__ */ new Set();
189
+ const srcDir = path3.join(projectRoot, "src");
190
+ if (!fs3.existsSync(srcDir)) return imports;
191
+ const files = walkDir(srcDir, /\.(ts|tsx|js|jsx|mjs|cjs)$/);
192
+ for (const file of files) {
193
+ try {
194
+ const content = fs3.readFileSync(file, "utf-8");
195
+ const importRegex = /(?:from\s+["']|require\s*\(\s*["'])([^./][^"']*?)(?:["'])/g;
196
+ let match;
197
+ while ((match = importRegex.exec(content)) !== null) {
198
+ const pkg = match[1];
199
+ if (pkg) {
200
+ const parts = pkg.split("/");
201
+ if (parts[0]?.startsWith("@") && parts.length >= 2) {
202
+ imports.add(`${parts[0]}/${parts[1]}`);
203
+ } else if (parts[0]) {
204
+ imports.add(parts[0]);
205
+ }
206
+ }
207
+ }
208
+ } catch {
209
+ }
210
+ }
211
+ return imports;
212
+ }
213
+ function walkDir(dir, pattern, maxDepth = 5) {
214
+ const results = [];
215
+ if (maxDepth <= 0) return results;
216
+ try {
217
+ const entries = fs3.readdirSync(dir, { withFileTypes: true });
218
+ for (const entry of entries) {
219
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
220
+ const fullPath = path3.join(dir, entry.name);
221
+ if (entry.isDirectory()) {
222
+ results.push(...walkDir(fullPath, pattern, maxDepth - 1));
223
+ } else if (pattern.test(entry.name)) {
224
+ results.push(fullPath);
225
+ }
226
+ }
227
+ } catch {
228
+ }
229
+ return results;
230
+ }
231
+
232
+ // src/services/base.ts
233
+ async function fetchJson(url2, options = {}) {
234
+ try {
235
+ const controller = new AbortController();
236
+ const timeoutId = setTimeout(
237
+ () => controller.abort(),
238
+ options.timeout ?? 1e4
239
+ );
240
+ const response = await fetch(url2, {
241
+ method: options.method ?? "GET",
242
+ headers: options.headers,
243
+ body: options.body,
244
+ signal: controller.signal
245
+ });
246
+ clearTimeout(timeoutId);
247
+ if (!response.ok) {
248
+ return {
249
+ ok: false,
250
+ status: response.status,
251
+ error: `HTTP ${response.status}: ${response.statusText}`
252
+ };
253
+ }
254
+ const data = await response.json();
255
+ return { ok: true, status: response.status, data };
256
+ } catch (err) {
257
+ return {
258
+ ok: false,
259
+ status: 0,
260
+ error: err instanceof Error ? err.message : "Unknown error"
261
+ };
262
+ }
263
+ }
264
+
265
+ // src/services/anthropic.ts
266
+ var anthropicConnector = {
267
+ serviceId: "anthropic",
268
+ async fetchSpend(apiKey) {
269
+ const now = /* @__PURE__ */ new Date();
270
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
271
+ const startDate = startOfMonth.toISOString().split("T")[0];
272
+ const endDate = now.toISOString().split("T")[0];
273
+ const url2 = `https://api.anthropic.com/v1/organizations/usage?start_date=${startDate}&end_date=${endDate}`;
274
+ const result = await fetchJson(url2, {
275
+ headers: {
276
+ "x-api-key": apiKey,
277
+ "anthropic-version": "2023-06-01"
278
+ }
279
+ });
280
+ if (!result.ok || !result.data) {
281
+ return {
282
+ serviceId: "anthropic",
283
+ spend: 0,
284
+ isEstimate: true,
285
+ tier: "est",
286
+ error: result.error ?? "Failed to fetch Anthropic usage"
287
+ };
288
+ }
289
+ let totalSpend = 0;
290
+ if (result.data.total_cost_usd !== void 0) {
291
+ totalSpend = result.data.total_cost_usd;
292
+ } else if (result.data.data) {
293
+ totalSpend = result.data.data.reduce(
294
+ (sum, entry) => sum + (entry.total_cost_usd ?? entry.spend ?? 0),
295
+ 0
296
+ );
297
+ }
298
+ return {
299
+ serviceId: "anthropic",
300
+ spend: totalSpend,
301
+ isEstimate: false,
302
+ tier: "live",
303
+ raw: result.data
304
+ };
305
+ }
306
+ };
307
+
308
+ // src/services/openai.ts
309
+ var openaiConnector = {
310
+ serviceId: "openai",
311
+ async fetchSpend(apiKey) {
312
+ const now = /* @__PURE__ */ new Date();
313
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
314
+ const startTime = Math.floor(startOfMonth.getTime() / 1e3);
315
+ const url2 = `https://api.openai.com/v1/organization/costs?start_time=${startTime}`;
316
+ const result = await fetchJson(url2, {
317
+ headers: {
318
+ Authorization: `Bearer ${apiKey}`
319
+ }
320
+ });
321
+ if (!result.ok || !result.data) {
322
+ return {
323
+ serviceId: "openai",
324
+ spend: 0,
325
+ isEstimate: true,
326
+ tier: "est",
327
+ error: result.error ?? "Failed to fetch OpenAI usage"
328
+ };
329
+ }
330
+ let totalSpend = 0;
331
+ if (result.data.data) {
332
+ for (const bucket of result.data.data) {
333
+ if (bucket.results) {
334
+ for (const r of bucket.results) {
335
+ totalSpend += r.amount?.value ?? 0;
336
+ }
337
+ }
338
+ }
339
+ }
340
+ totalSpend = totalSpend / 100;
341
+ return {
342
+ serviceId: "openai",
343
+ spend: totalSpend,
344
+ isEstimate: false,
345
+ tier: "live",
346
+ raw: result.data
347
+ };
348
+ }
349
+ };
350
+
351
+ // src/services/vercel.ts
352
+ var vercelConnector = {
353
+ serviceId: "vercel",
354
+ async fetchSpend(token, options) {
355
+ const teamId = options?.["teamId"] ?? "";
356
+ const teamParam = teamId ? `?teamId=${teamId}` : "";
357
+ const url2 = `https://api.vercel.com/v2/usage${teamParam}`;
358
+ const result = await fetchJson(url2, {
359
+ headers: {
360
+ Authorization: `Bearer ${token}`
361
+ }
362
+ });
363
+ if (!result.ok || !result.data) {
364
+ return {
365
+ serviceId: "vercel",
366
+ spend: 0,
367
+ isEstimate: true,
368
+ tier: "est",
369
+ error: result.error ?? "Failed to fetch Vercel usage"
370
+ };
371
+ }
372
+ let totalSpend = 0;
373
+ if (result.data.usage?.total !== void 0) {
374
+ totalSpend = result.data.usage.total;
375
+ } else if (result.data.billing?.invoiceItems) {
376
+ totalSpend = result.data.billing.invoiceItems.reduce(
377
+ (sum, item) => sum + (item.amount ?? 0),
378
+ 0
379
+ );
380
+ }
381
+ return {
382
+ serviceId: "vercel",
383
+ spend: totalSpend,
384
+ isEstimate: false,
385
+ tier: "live",
386
+ raw: result.data
387
+ };
388
+ }
389
+ };
390
+
391
+ // src/services/scrapfly.ts
392
+ var scrapflyConnector = {
393
+ serviceId: "scrapfly",
394
+ async fetchSpend(apiKey) {
395
+ const url2 = `https://api.scrapfly.io/account?key=${apiKey}`;
396
+ const result = await fetchJson(url2);
397
+ if (!result.ok || !result.data) {
398
+ return {
399
+ serviceId: "scrapfly",
400
+ spend: 0,
401
+ isEstimate: true,
402
+ tier: "est",
403
+ error: result.error ?? "Failed to fetch Scrapfly account"
404
+ };
405
+ }
406
+ let creditsUsed = 0;
407
+ let creditsTotal = 0;
408
+ if (result.data.subscription?.usage?.scrape) {
409
+ creditsUsed = result.data.subscription.usage.scrape.used ?? 0;
410
+ creditsTotal = result.data.subscription.usage.scrape.allowed ?? 0;
411
+ } else if (result.data.account) {
412
+ creditsUsed = result.data.account.credits_used ?? 0;
413
+ creditsTotal = result.data.account.credits_total ?? 0;
414
+ }
415
+ const creditRate = 15e-5;
416
+ const spend = creditsUsed * creditRate;
417
+ return {
418
+ serviceId: "scrapfly",
419
+ spend,
420
+ isEstimate: false,
421
+ tier: "live",
422
+ raw: {
423
+ credits_used: creditsUsed,
424
+ credits_total: creditsTotal,
425
+ credit_rate: creditRate,
426
+ ...result.data
427
+ }
428
+ };
429
+ }
430
+ };
431
+
432
+ // src/services/index.ts
433
+ var connectors = /* @__PURE__ */ new Map([
434
+ ["anthropic", anthropicConnector],
435
+ ["openai", openaiConnector],
436
+ ["vercel", vercelConnector],
437
+ ["scrapfly", scrapflyConnector]
438
+ ]);
439
+ async function pollService(tracked) {
440
+ const globalConfig = readGlobalConfig();
441
+ const serviceConfig = globalConfig.services[tracked.serviceId];
442
+ const connector = connectors.get(tracked.serviceId);
443
+ const definition = getService(tracked.serviceId);
444
+ if (connector && serviceConfig?.apiKey) {
445
+ try {
446
+ const result = await connector.fetchSpend(
447
+ serviceConfig.apiKey,
448
+ serviceConfig
449
+ );
450
+ if (!result.error) return result;
451
+ } catch {
452
+ }
453
+ }
454
+ if (tracked.planCost !== void 0) {
455
+ const now = /* @__PURE__ */ new Date();
456
+ const daysInMonth = new Date(
457
+ now.getFullYear(),
458
+ now.getMonth() + 1,
459
+ 0
460
+ ).getDate();
461
+ const dayOfMonth = now.getDate();
462
+ const projectedSpend = tracked.planCost / daysInMonth * dayOfMonth;
463
+ return {
464
+ serviceId: tracked.serviceId,
465
+ spend: projectedSpend,
466
+ isEstimate: true,
467
+ tier: "calc"
468
+ };
469
+ }
470
+ if (definition) {
471
+ let tier;
472
+ if (tracked.tierOverride) {
473
+ tier = tracked.tierOverride;
474
+ } else if (definition.apiTier === "live") {
475
+ tier = "blind";
476
+ } else {
477
+ tier = definition.apiTier;
478
+ }
479
+ return {
480
+ serviceId: tracked.serviceId,
481
+ spend: 0,
482
+ isEstimate: tier !== "live",
483
+ tier,
484
+ error: tier === "blind" ? "No API key configured" : void 0
485
+ };
486
+ }
487
+ return {
488
+ serviceId: tracked.serviceId,
489
+ spend: 0,
490
+ isEstimate: true,
491
+ tier: "blind",
492
+ error: "Unknown service \u2014 not in registry"
493
+ };
494
+ }
495
+ async function pollAllServices(services) {
496
+ return Promise.all(services.map(pollService));
497
+ }
498
+
499
+ // src/core/types.ts
500
+ var CONFIDENCE_BADGES = {
501
+ live: "\u2705 LIVE",
502
+ calc: "\u{1F7E1} CALC",
503
+ est: "\u{1F7E0} EST",
504
+ blind: "\u{1F534} BLIND"
505
+ };
506
+
507
+ // src/core/brief.ts
508
+ function formatBrief(brief) {
509
+ const lines = [];
510
+ const width = 62;
511
+ const hrDouble = "\u2550".repeat(width);
512
+ const hrSingle = "\u2500".repeat(width - 4);
513
+ lines.push(`\u2554${hrDouble}\u2557`);
514
+ lines.push(
515
+ `\u2551 BURNWATCH \u2014 ${brief.projectName} \u2014 ${brief.period}`.padEnd(
516
+ width + 1
517
+ ) + "\u2551"
518
+ );
519
+ lines.push(`\u2560${hrDouble}\u2563`);
520
+ lines.push(
521
+ formatRow("Service", "Spend", "Conf", "Budget", "Left", width)
522
+ );
523
+ lines.push(`\u2551 ${hrSingle} \u2551`);
524
+ for (const svc of brief.services) {
525
+ const spendStr = svc.isEstimate ? `~$${svc.spend.toFixed(2)}` : `$${svc.spend.toFixed(2)}`;
526
+ const badge = CONFIDENCE_BADGES[svc.tier];
527
+ const budgetStr = svc.budget ? `$${svc.budget}` : "\u2014";
528
+ const leftStr = formatLeft(svc);
529
+ lines.push(formatRow(svc.serviceId, spendStr, badge, budgetStr, leftStr, width));
530
+ }
531
+ lines.push(`\u2560${hrDouble}\u2563`);
532
+ const totalStr = brief.totalIsEstimate ? `~$${brief.totalSpend.toFixed(2)}` : `$${brief.totalSpend.toFixed(2)}`;
533
+ const marginStr = brief.estimateMargin > 0 ? ` Est margin: \xB1$${brief.estimateMargin.toFixed(0)}` : "";
534
+ const untrackedStr = brief.untrackedCount > 0 ? `Untracked: ${brief.untrackedCount} \u26A0\uFE0F` : `Untracked: 0 \u2705`;
535
+ lines.push(
536
+ `\u2551 TOTAL: ${totalStr} ${untrackedStr}${marginStr}`.padEnd(
537
+ width + 1
538
+ ) + "\u2551"
539
+ );
540
+ for (const alert of brief.alerts) {
541
+ const icon = alert.severity === "critical" ? "\u{1F6A8}" : "\u26A0\uFE0F";
542
+ lines.push(
543
+ `\u2551 ${icon} ${alert.message}`.padEnd(width + 1) + "\u2551"
544
+ );
545
+ }
546
+ lines.push(`\u255A${hrDouble}\u255D`);
547
+ return lines.join("\n");
548
+ }
549
+ function buildBrief(projectName, snapshots, blindCount) {
550
+ const now = /* @__PURE__ */ new Date();
551
+ const period = now.toLocaleDateString("en-US", {
552
+ month: "long",
553
+ year: "numeric"
554
+ });
555
+ let totalSpend = 0;
556
+ let hasEstimates = false;
557
+ let estimateMargin = 0;
558
+ const alerts = [];
559
+ for (const snap of snapshots) {
560
+ totalSpend += snap.spend;
561
+ if (snap.isEstimate) {
562
+ hasEstimates = true;
563
+ estimateMargin += snap.spend * 0.15;
564
+ }
565
+ if (snap.status === "over") {
566
+ alerts.push({
567
+ serviceId: snap.serviceId,
568
+ type: "over_budget",
569
+ message: `${snap.serviceId.toUpperCase()} ${snap.budgetPercent?.toFixed(0) ?? "?"}% OVER BUDGET \u2014 review before use`,
570
+ severity: "critical"
571
+ });
572
+ } else if (snap.status === "caution" && snap.budgetPercent && snap.budgetPercent >= 80) {
573
+ alerts.push({
574
+ serviceId: snap.serviceId,
575
+ type: "near_budget",
576
+ message: `${snap.serviceId} at ${snap.budgetPercent.toFixed(0)}% of budget`,
577
+ severity: "warning"
578
+ });
579
+ }
580
+ }
581
+ if (blindCount > 0) {
582
+ alerts.push({
583
+ serviceId: "_blind",
584
+ type: "blind_service",
585
+ message: `${blindCount} service${blindCount > 1 ? "s" : ""} detected but untracked \u2014 run 'burnwatch status' to see`,
586
+ severity: "warning"
587
+ });
588
+ }
589
+ return {
590
+ projectName,
591
+ generatedAt: now.toISOString(),
592
+ period,
593
+ services: snapshots,
594
+ totalSpend,
595
+ totalIsEstimate: hasEstimates,
596
+ estimateMargin,
597
+ untrackedCount: blindCount,
598
+ alerts
599
+ };
600
+ }
601
+ function formatRow(service, spend, conf, budget, left, width) {
602
+ const row = ` ${service.padEnd(14)} ${spend.padEnd(11)} ${conf.padEnd(7)} ${budget.padEnd(7)} ${left}`;
603
+ return `\u2551${row}`.padEnd(width + 1) + "\u2551";
604
+ }
605
+ function formatLeft(snap) {
606
+ if (!snap.budget) return "\u2014";
607
+ if (snap.status === "over") return "\u26A0\uFE0F OVR";
608
+ if (snap.budgetPercent !== void 0) {
609
+ const remaining = 100 - snap.budgetPercent;
610
+ return `${remaining.toFixed(0)}%`;
611
+ }
612
+ return "\u2014";
613
+ }
614
+ function buildSnapshot(serviceId, tier, spend, budget) {
615
+ const isEstimate = tier === "est" || tier === "calc";
616
+ const budgetPercent = budget ? spend / budget * 100 : void 0;
617
+ let status = "unknown";
618
+ let statusLabel = "no budget";
619
+ if (budget) {
620
+ if (budgetPercent > 100) {
621
+ status = "over";
622
+ statusLabel = `\u26A0\uFE0F ${budgetPercent.toFixed(0)}% over`;
623
+ } else if (budgetPercent >= 75) {
624
+ status = "caution";
625
+ statusLabel = `${(100 - budgetPercent).toFixed(0)}% \u2014 caution`;
626
+ } else {
627
+ status = "healthy";
628
+ statusLabel = `${(100 - budgetPercent).toFixed(0)}% \u2014 healthy`;
629
+ }
630
+ }
631
+ if (tier === "calc" && budget) {
632
+ statusLabel = `flat \u2014 on plan`;
633
+ status = "healthy";
634
+ }
635
+ return {
636
+ serviceId,
637
+ spend,
638
+ isEstimate,
639
+ tier,
640
+ budget,
641
+ budgetPercent,
642
+ status,
643
+ statusLabel,
644
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
645
+ };
646
+ }
647
+
648
+ // src/core/ledger.ts
649
+ import * as fs4 from "fs";
650
+ import * as path4 from "path";
651
+ function writeLedger(brief, projectRoot) {
652
+ const now = /* @__PURE__ */ new Date();
653
+ const lines = [];
654
+ lines.push(`# Burnwatch Ledger \u2014 ${brief.projectName}`);
655
+ lines.push(`Last updated: ${now.toISOString()}`);
656
+ lines.push("");
657
+ lines.push(`## This Month (${brief.period})`);
658
+ lines.push("");
659
+ lines.push("| Service | Spend | Conf | Budget | Status |");
660
+ lines.push("|---------|-------|------|--------|--------|");
661
+ for (const svc of brief.services) {
662
+ const spendStr = svc.isEstimate ? `~$${svc.spend.toFixed(2)}` : `$${svc.spend.toFixed(2)}`;
663
+ const badge = CONFIDENCE_BADGES[svc.tier];
664
+ const budgetStr = svc.budget ? `$${svc.budget}` : "\u2014";
665
+ lines.push(
666
+ `| ${svc.serviceId} | ${spendStr} | ${badge} | ${budgetStr} | ${svc.statusLabel} |`
667
+ );
668
+ }
669
+ lines.push("");
670
+ const totalStr = brief.totalIsEstimate ? `~$${brief.totalSpend.toFixed(2)}` : `$${brief.totalSpend.toFixed(2)}`;
671
+ const marginStr = brief.estimateMargin > 0 ? ` (\xB1$${brief.estimateMargin.toFixed(0)} estimated margin)` : "";
672
+ lines.push(`## TOTAL: ${totalStr}${marginStr}`);
673
+ lines.push(`## Untracked services: ${brief.untrackedCount}`);
674
+ lines.push("");
675
+ if (brief.alerts.length > 0) {
676
+ lines.push("## Alerts");
677
+ for (const alert of brief.alerts) {
678
+ const icon = alert.severity === "critical" ? "\u{1F6A8}" : "\u26A0\uFE0F";
679
+ lines.push(`- ${icon} ${alert.message}`);
680
+ }
681
+ lines.push("");
682
+ }
683
+ const ledgerPath = path4.join(
684
+ projectConfigDir(projectRoot),
685
+ "spend-ledger.md"
686
+ );
687
+ fs4.mkdirSync(path4.dirname(ledgerPath), { recursive: true });
688
+ fs4.writeFileSync(ledgerPath, lines.join("\n") + "\n", "utf-8");
689
+ }
690
+ function saveSnapshot(brief, projectRoot) {
691
+ const snapshotDir = path4.join(projectDataDir(projectRoot), "snapshots");
692
+ fs4.mkdirSync(snapshotDir, { recursive: true });
693
+ const filename = `snapshot-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.json`;
694
+ fs4.writeFileSync(
695
+ path4.join(snapshotDir, filename),
696
+ JSON.stringify(brief, null, 2) + "\n",
697
+ "utf-8"
698
+ );
699
+ }
700
+
701
+ // src/cli.ts
702
+ var args = process.argv.slice(2);
703
+ var command = args[0];
704
+ async function main() {
705
+ switch (command) {
706
+ case "init":
707
+ await cmdInit();
708
+ break;
709
+ case "add":
710
+ await cmdAdd();
711
+ break;
712
+ case "status":
713
+ await cmdStatus();
714
+ break;
715
+ case "services":
716
+ cmdServices();
717
+ break;
718
+ case "reconcile":
719
+ await cmdReconcile();
720
+ break;
721
+ case "setup":
722
+ await cmdSetup();
723
+ break;
724
+ case "help":
725
+ case "--help":
726
+ case "-h":
727
+ cmdHelp();
728
+ break;
729
+ case "version":
730
+ case "--version":
731
+ case "-v":
732
+ cmdVersion();
733
+ break;
734
+ default:
735
+ if (command) {
736
+ console.error(`Unknown command: ${command}`);
737
+ console.error('Run "burnwatch help" for usage.');
738
+ process.exit(1);
739
+ }
740
+ cmdHelp();
741
+ }
742
+ }
743
+ async function cmdInit() {
744
+ const projectRoot = process.cwd();
745
+ if (isInitialized(projectRoot)) {
746
+ console.log("\u2705 burnwatch is already initialized in this project.");
747
+ console.log(` Config: ${projectConfigDir(projectRoot)}/config.json`);
748
+ return;
749
+ }
750
+ let projectName = path5.basename(projectRoot);
751
+ try {
752
+ const pkgPath = path5.join(projectRoot, "package.json");
753
+ const pkg = JSON.parse(fs5.readFileSync(pkgPath, "utf-8"));
754
+ if (pkg.name) projectName = pkg.name;
755
+ } catch {
756
+ }
757
+ ensureProjectDirs(projectRoot);
758
+ console.log("\u{1F50D} Scanning project for paid services...\n");
759
+ const detected = detectServices(projectRoot);
760
+ const config = {
761
+ projectName,
762
+ services: {},
763
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
764
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
765
+ };
766
+ for (const det of detected) {
767
+ const tracked = {
768
+ serviceId: det.service.id,
769
+ detectedVia: det.sources,
770
+ hasApiKey: false,
771
+ firstDetected: (/* @__PURE__ */ new Date()).toISOString()
772
+ };
773
+ config.services[det.service.id] = tracked;
774
+ }
775
+ writeProjectConfig(config, projectRoot);
776
+ const gitignorePath = path5.join(projectConfigDir(projectRoot), ".gitignore");
777
+ fs5.writeFileSync(
778
+ gitignorePath,
779
+ [
780
+ "# Burnwatch \u2014 ignore cache and snapshots, keep ledger and config",
781
+ "data/cache/",
782
+ "data/snapshots/",
783
+ "data/events.jsonl",
784
+ ""
785
+ ].join("\n"),
786
+ "utf-8"
787
+ );
788
+ if (detected.length === 0) {
789
+ console.log(" No paid services detected yet.");
790
+ console.log(" Services will be detected as they enter your project.\n");
791
+ } else {
792
+ console.log(` Found ${detected.length} paid service${detected.length > 1 ? "s" : ""}:
793
+ `);
794
+ for (const det of detected) {
795
+ const tierBadge = det.service.apiTier === "live" ? "\u2705 LIVE API available" : det.service.apiTier === "calc" ? "\u{1F7E1} Flat-rate tracking" : det.service.apiTier === "est" ? "\u{1F7E0} Estimate tracking" : "\u{1F534} Detection only";
796
+ console.log(` \u2022 ${det.service.name} (${tierBadge})`);
797
+ console.log(` Detected via: ${det.details.join(", ")}`);
798
+ }
799
+ console.log("");
800
+ }
801
+ console.log("\u{1F517} Registering Claude Code hooks...\n");
802
+ registerHooks(projectRoot);
803
+ console.log("\u2705 burnwatch initialized!\n");
804
+ console.log("Next steps:");
805
+ console.log(" 1. Add API keys for LIVE tracking:");
806
+ console.log(" burnwatch add anthropic --key $ANTHROPIC_ADMIN_KEY --budget 100");
807
+ console.log(" 2. Set budgets for detected services:");
808
+ console.log(" burnwatch add scrapfly --key $SCRAPFLY_KEY --budget 50");
809
+ console.log(" 3. Check your spend:");
810
+ console.log(" burnwatch status\n");
811
+ }
812
+ async function cmdAdd() {
813
+ const projectRoot = process.cwd();
814
+ if (!isInitialized(projectRoot)) {
815
+ console.error('\u274C burnwatch not initialized. Run "burnwatch init" first.');
816
+ process.exit(1);
817
+ }
818
+ const serviceId = args[1];
819
+ if (!serviceId) {
820
+ console.error("Usage: burnwatch add <service> [--key KEY] [--budget N]");
821
+ process.exit(1);
822
+ }
823
+ const options = {};
824
+ for (let i = 2; i < args.length; i++) {
825
+ const arg = args[i];
826
+ if (arg.startsWith("--") && i + 1 < args.length) {
827
+ options[arg.slice(2)] = args[i + 1];
828
+ i++;
829
+ }
830
+ }
831
+ const apiKey = options["key"] ?? options["token"];
832
+ const budget = options["budget"] ? parseFloat(options["budget"]) : void 0;
833
+ const planCost = options["plan-cost"] ? parseFloat(options["plan-cost"]) : void 0;
834
+ const definition = getService(serviceId, projectRoot);
835
+ if (!definition) {
836
+ console.error(
837
+ `\u26A0\uFE0F "${serviceId}" not found in registry. Adding as custom service.`
838
+ );
839
+ }
840
+ const config = readProjectConfig(projectRoot);
841
+ const existing = config.services[serviceId];
842
+ const tracked = {
843
+ serviceId,
844
+ detectedVia: existing?.detectedVia ?? ["manual"],
845
+ budget: budget ?? existing?.budget,
846
+ hasApiKey: !!apiKey || (existing?.hasApiKey ?? false),
847
+ planCost: planCost ?? existing?.planCost,
848
+ firstDetected: existing?.firstDetected ?? (/* @__PURE__ */ new Date()).toISOString()
849
+ };
850
+ config.services[serviceId] = tracked;
851
+ writeProjectConfig(config, projectRoot);
852
+ if (apiKey) {
853
+ const globalConfig = readGlobalConfig();
854
+ if (!globalConfig.services[serviceId]) {
855
+ globalConfig.services[serviceId] = {};
856
+ }
857
+ globalConfig.services[serviceId].apiKey = apiKey;
858
+ writeGlobalConfig(globalConfig);
859
+ console.log(`\u{1F510} API key saved to global config (never stored in project)`);
860
+ }
861
+ let tierLabel;
862
+ if (!definition) {
863
+ tierLabel = "\u{1F534} BLIND";
864
+ } else if (apiKey) {
865
+ tierLabel = "\u2705 LIVE";
866
+ } else if (planCost !== void 0) {
867
+ tierLabel = "\u{1F7E1} CALC";
868
+ } else if (definition.apiTier === "est") {
869
+ tierLabel = "\u{1F7E0} EST";
870
+ } else if (definition.apiTier === "calc") {
871
+ tierLabel = "\u{1F7E1} CALC";
872
+ } else if (definition.apiTier === "live" && !apiKey) {
873
+ tierLabel = `\u{1F534} BLIND (add --key for \u2705 LIVE)`;
874
+ } else {
875
+ tierLabel = "\u{1F534} BLIND";
876
+ }
877
+ console.log(`
878
+ \u2705 ${serviceId} configured:`);
879
+ console.log(` Tier: ${tierLabel}`);
880
+ if (budget) console.log(` Budget: $${budget}/mo`);
881
+ if (planCost) console.log(` Plan cost: $${planCost}/mo`);
882
+ console.log("");
883
+ }
884
+ async function cmdStatus() {
885
+ const projectRoot = process.cwd();
886
+ if (!isInitialized(projectRoot)) {
887
+ console.error('\u274C burnwatch not initialized. Run "burnwatch init" first.');
888
+ process.exit(1);
889
+ }
890
+ const config = readProjectConfig(projectRoot);
891
+ const trackedServices = Object.values(config.services);
892
+ if (trackedServices.length === 0) {
893
+ console.log("No services tracked yet.");
894
+ console.log('Run "burnwatch add <service>" to start tracking.');
895
+ return;
896
+ }
897
+ console.log("\u{1F4CA} Polling services...\n");
898
+ const results = await pollAllServices(trackedServices);
899
+ const snapshots = results.map(
900
+ (r) => buildSnapshot(
901
+ r.serviceId,
902
+ r.tier,
903
+ r.spend,
904
+ config.services[r.serviceId]?.budget
905
+ )
906
+ );
907
+ const blindCount = snapshots.filter((s) => s.tier === "blind").length;
908
+ const brief = buildBrief(config.projectName, snapshots, blindCount);
909
+ saveSnapshot(brief, projectRoot);
910
+ writeLedger(brief, projectRoot);
911
+ console.log(formatBrief(brief));
912
+ console.log("");
913
+ if (blindCount > 0) {
914
+ console.log(`\u26A0\uFE0F ${blindCount} service${blindCount > 1 ? "s" : ""} untracked:`);
915
+ for (const snap of snapshots.filter((s) => s.tier === "blind")) {
916
+ console.log(
917
+ ` \u2022 ${snap.serviceId} \u2014 run 'burnwatch add ${snap.serviceId} --key YOUR_KEY --budget N'`
918
+ );
919
+ }
920
+ console.log("");
921
+ }
922
+ }
923
+ async function cmdSetup() {
924
+ const projectRoot = process.cwd();
925
+ if (!isInitialized(projectRoot)) {
926
+ await cmdInit();
927
+ }
928
+ const config = readProjectConfig(projectRoot);
929
+ const detected = Object.values(config.services);
930
+ if (detected.length === 0) {
931
+ console.log("No paid services detected. You're all set!");
932
+ return;
933
+ }
934
+ console.log("\u{1F4CB} Auto-configuring detected services...\n");
935
+ const globalConfig = readGlobalConfig();
936
+ const liveServices = [];
937
+ const calcServices = [];
938
+ const estServices = [];
939
+ const blindServices = [];
940
+ for (const tracked of detected) {
941
+ const definition = getService(tracked.serviceId, projectRoot);
942
+ if (!definition) continue;
943
+ const hasKey = !!globalConfig.services[tracked.serviceId]?.apiKey;
944
+ if (hasKey && definition.apiTier === "live") {
945
+ tracked.hasApiKey = true;
946
+ liveServices.push(`${definition.name}`);
947
+ } else if (definition.apiTier === "calc") {
948
+ calcServices.push(`${definition.name}`);
949
+ } else if (definition.apiTier === "est") {
950
+ estServices.push(`${definition.name}`);
951
+ } else {
952
+ blindServices.push(`${definition.name}`);
953
+ }
954
+ }
955
+ writeProjectConfig(config, projectRoot);
956
+ if (liveServices.length > 0) {
957
+ console.log(` \u2705 LIVE (real billing data): ${liveServices.join(", ")}`);
958
+ }
959
+ if (calcServices.length > 0) {
960
+ console.log(` \u{1F7E1} CALC (flat-rate tracking): ${calcServices.join(", ")}`);
961
+ }
962
+ if (estServices.length > 0) {
963
+ console.log(` \u{1F7E0} EST (estimated from usage): ${estServices.join(", ")}`);
964
+ }
965
+ if (blindServices.length > 0) {
966
+ console.log(` \u{1F534} BLIND (detected, need API key): ${blindServices.join(", ")}`);
967
+ }
968
+ console.log("");
969
+ if (blindServices.length > 0) {
970
+ console.log("To upgrade BLIND services to LIVE, add API keys:");
971
+ for (const tracked of detected) {
972
+ const definition = getService(tracked.serviceId, projectRoot);
973
+ if (definition?.apiTier === "live" && !tracked.hasApiKey) {
974
+ const envHint = definition.envPatterns[0] ?? "YOUR_KEY";
975
+ console.log(` burnwatch add ${tracked.serviceId} --key $${envHint} --budget <N>`);
976
+ }
977
+ }
978
+ console.log("");
979
+ }
980
+ console.log("To set budgets for any service:");
981
+ console.log(" burnwatch add <service> --budget <monthly_amount>");
982
+ console.log("");
983
+ console.log("Or use /setup-burnwatch in Claude Code for guided setup with budget suggestions.\n");
984
+ await cmdStatus();
985
+ }
986
+ function cmdServices() {
987
+ const services = getAllServices();
988
+ console.log(`
989
+ \u{1F4CB} Registry: ${services.length} services available
990
+ `);
991
+ for (const svc of services) {
992
+ const tierBadge = svc.apiTier === "live" ? "\u2705 LIVE" : svc.apiTier === "calc" ? "\u{1F7E1} CALC" : svc.apiTier === "est" ? "\u{1F7E0} EST" : "\u{1F534} BLIND";
993
+ console.log(` ${svc.name.padEnd(24)} ${tierBadge.padEnd(10)} ${svc.billingModel}`);
994
+ }
995
+ console.log("");
996
+ }
997
+ async function cmdReconcile() {
998
+ const projectRoot = process.cwd();
999
+ if (!isInitialized(projectRoot)) {
1000
+ console.error('\u274C burnwatch not initialized. Run "burnwatch init" first.');
1001
+ process.exit(1);
1002
+ }
1003
+ console.log("\u{1F50D} Scanning for untracked services and missed sessions...\n");
1004
+ const detected = detectServices(projectRoot);
1005
+ const config = readProjectConfig(projectRoot);
1006
+ let newCount = 0;
1007
+ for (const det of detected) {
1008
+ if (!config.services[det.service.id]) {
1009
+ config.services[det.service.id] = {
1010
+ serviceId: det.service.id,
1011
+ detectedVia: det.sources,
1012
+ hasApiKey: false,
1013
+ firstDetected: (/* @__PURE__ */ new Date()).toISOString()
1014
+ };
1015
+ newCount++;
1016
+ console.log(` \u{1F195} ${det.service.name} \u2014 detected via ${det.details.join(", ")}`);
1017
+ }
1018
+ }
1019
+ if (newCount > 0) {
1020
+ writeProjectConfig(config, projectRoot);
1021
+ console.log(
1022
+ `
1023
+ \u2705 Found ${newCount} new service${newCount > 1 ? "s" : ""}. Run 'burnwatch status' to see updated brief.`
1024
+ );
1025
+ } else {
1026
+ console.log(" \u2705 No new services found. All services already tracked.");
1027
+ }
1028
+ console.log("");
1029
+ }
1030
+ function cmdHelp() {
1031
+ console.log(`
1032
+ burnwatch \u2014 Passive cost memory for vibe coding
1033
+
1034
+ Usage:
1035
+ burnwatch init Initialize in current project
1036
+ burnwatch setup Init + auto-configure all detected services
1037
+ burnwatch add <service> [options] Register a service for tracking
1038
+ burnwatch status Show current spend brief
1039
+ burnwatch services List all services in registry
1040
+ burnwatch reconcile Scan for untracked services
1041
+
1042
+ Options for 'add':
1043
+ --key <API_KEY> API key for LIVE tracking (saved to ~/.config/burnwatch/)
1044
+ --token <TOKEN> Same as --key (alias)
1045
+ --budget <AMOUNT> Monthly budget in USD
1046
+ --plan-cost <AMOUNT> Monthly plan cost for CALC tracking
1047
+
1048
+ Examples:
1049
+ burnwatch init
1050
+ burnwatch add anthropic --key sk-ant-admin-xxx --budget 100
1051
+ burnwatch add scrapfly --key scp-xxx --budget 50
1052
+ burnwatch add posthog --plan-cost 0 --budget 0
1053
+ burnwatch status
1054
+ `);
1055
+ }
1056
+ function cmdVersion() {
1057
+ try {
1058
+ const pkgPath = path5.resolve(
1059
+ path5.dirname(new URL(import.meta.url).pathname),
1060
+ "../package.json"
1061
+ );
1062
+ const pkg = JSON.parse(fs5.readFileSync(pkgPath, "utf-8"));
1063
+ console.log(`burnwatch v${pkg.version}`);
1064
+ } catch {
1065
+ console.log("burnwatch v0.1.0");
1066
+ }
1067
+ }
1068
+ function registerHooks(projectRoot) {
1069
+ const claudeDir = path5.join(projectRoot, ".claude");
1070
+ const settingsPath = path5.join(claudeDir, "settings.json");
1071
+ fs5.mkdirSync(claudeDir, { recursive: true });
1072
+ let settings = {};
1073
+ try {
1074
+ settings = JSON.parse(fs5.readFileSync(settingsPath, "utf-8"));
1075
+ } catch {
1076
+ }
1077
+ const hookBase = `node "${path5.join(projectRoot, "node_modules", "burnwatch", "dist", "hooks")}"`;
1078
+ const useNpx = !fs5.existsSync(
1079
+ path5.join(projectRoot, "node_modules", "burnwatch")
1080
+ );
1081
+ const prefix = useNpx ? "npx --yes burnwatch-hook" : hookBase;
1082
+ const hooksDir = path5.resolve(
1083
+ path5.dirname(new URL(import.meta.url).pathname),
1084
+ "hooks"
1085
+ );
1086
+ const hooks = settings["hooks"] ?? {};
1087
+ if (!hooks["SessionStart"]) hooks["SessionStart"] = [];
1088
+ addHookIfMissing(hooks["SessionStart"], "SessionStart", {
1089
+ matcher: "startup|resume",
1090
+ hooks: [
1091
+ {
1092
+ type: "command",
1093
+ command: `node "${path5.join(hooksDir, "on-session-start.js")}"`,
1094
+ timeout: 15
1095
+ }
1096
+ ]
1097
+ });
1098
+ if (!hooks["UserPromptSubmit"]) hooks["UserPromptSubmit"] = [];
1099
+ addHookIfMissing(
1100
+ hooks["UserPromptSubmit"],
1101
+ "UserPromptSubmit",
1102
+ {
1103
+ hooks: [
1104
+ {
1105
+ type: "command",
1106
+ command: `node "${path5.join(hooksDir, "on-prompt.js")}"`,
1107
+ timeout: 5
1108
+ }
1109
+ ]
1110
+ }
1111
+ );
1112
+ if (!hooks["PostToolUse"]) hooks["PostToolUse"] = [];
1113
+ addHookIfMissing(hooks["PostToolUse"], "PostToolUse", {
1114
+ matcher: "Edit|Write",
1115
+ hooks: [
1116
+ {
1117
+ type: "command",
1118
+ command: `node "${path5.join(hooksDir, "on-file-change.js")}"`,
1119
+ timeout: 5
1120
+ }
1121
+ ]
1122
+ });
1123
+ if (!hooks["Stop"]) hooks["Stop"] = [];
1124
+ addHookIfMissing(hooks["Stop"], "Stop", {
1125
+ hooks: [
1126
+ {
1127
+ type: "command",
1128
+ command: `node "${path5.join(hooksDir, "on-stop.js")}"`,
1129
+ timeout: 15,
1130
+ async: true
1131
+ }
1132
+ ]
1133
+ });
1134
+ settings["hooks"] = hooks;
1135
+ fs5.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
1136
+ console.log(` Hooks registered in ${settingsPath}`);
1137
+ }
1138
+ function addHookIfMissing(hookArray, _eventName, hookConfig) {
1139
+ const existing = hookArray.some((h) => {
1140
+ const hook = h;
1141
+ return hook.hooks?.some((inner) => inner.command?.includes("burnwatch"));
1142
+ });
1143
+ if (!existing) {
1144
+ hookArray.push(hookConfig);
1145
+ }
1146
+ }
1147
+ main().catch((err) => {
1148
+ console.error("Error:", err instanceof Error ? err.message : err);
1149
+ process.exit(1);
1150
+ });
1151
+ //# sourceMappingURL=cli.js.map