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.
@@ -0,0 +1,766 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/hooks/on-session-start.ts
4
+ import * as fs5 from "fs";
5
+
6
+ // src/core/config.ts
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+ import * as os from "os";
10
+ function globalConfigDir() {
11
+ const xdgConfig = process.env["XDG_CONFIG_HOME"];
12
+ if (xdgConfig) return path.join(xdgConfig, "burnwatch");
13
+ return path.join(os.homedir(), ".config", "burnwatch");
14
+ }
15
+ function projectConfigDir(projectRoot) {
16
+ const root = projectRoot ?? process.cwd();
17
+ return path.join(root, ".burnwatch");
18
+ }
19
+ function projectDataDir(projectRoot) {
20
+ return path.join(projectConfigDir(projectRoot), "data");
21
+ }
22
+ function readGlobalConfig() {
23
+ const configPath = path.join(globalConfigDir(), "config.json");
24
+ try {
25
+ const raw = fs.readFileSync(configPath, "utf-8");
26
+ return JSON.parse(raw);
27
+ } catch {
28
+ return { services: {} };
29
+ }
30
+ }
31
+ function readProjectConfig(projectRoot) {
32
+ const configPath = path.join(projectConfigDir(projectRoot), "config.json");
33
+ try {
34
+ const raw = fs.readFileSync(configPath, "utf-8");
35
+ return JSON.parse(raw);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+ function isInitialized(projectRoot) {
41
+ return readProjectConfig(projectRoot) !== null;
42
+ }
43
+
44
+ // src/core/ledger.ts
45
+ import * as fs2 from "fs";
46
+ import * as path2 from "path";
47
+
48
+ // src/core/types.ts
49
+ var CONFIDENCE_BADGES = {
50
+ live: "\u2705 LIVE",
51
+ calc: "\u{1F7E1} CALC",
52
+ est: "\u{1F7E0} EST",
53
+ blind: "\u{1F534} BLIND"
54
+ };
55
+
56
+ // src/core/ledger.ts
57
+ function writeLedger(brief, projectRoot) {
58
+ const now = /* @__PURE__ */ new Date();
59
+ const lines = [];
60
+ lines.push(`# Burnwatch Ledger \u2014 ${brief.projectName}`);
61
+ lines.push(`Last updated: ${now.toISOString()}`);
62
+ lines.push("");
63
+ lines.push(`## This Month (${brief.period})`);
64
+ lines.push("");
65
+ lines.push("| Service | Spend | Conf | Budget | Status |");
66
+ lines.push("|---------|-------|------|--------|--------|");
67
+ for (const svc of brief.services) {
68
+ const spendStr = svc.isEstimate ? `~$${svc.spend.toFixed(2)}` : `$${svc.spend.toFixed(2)}`;
69
+ const badge = CONFIDENCE_BADGES[svc.tier];
70
+ const budgetStr = svc.budget ? `$${svc.budget}` : "\u2014";
71
+ lines.push(
72
+ `| ${svc.serviceId} | ${spendStr} | ${badge} | ${budgetStr} | ${svc.statusLabel} |`
73
+ );
74
+ }
75
+ lines.push("");
76
+ const totalStr = brief.totalIsEstimate ? `~$${brief.totalSpend.toFixed(2)}` : `$${brief.totalSpend.toFixed(2)}`;
77
+ const marginStr = brief.estimateMargin > 0 ? ` (\xB1$${brief.estimateMargin.toFixed(0)} estimated margin)` : "";
78
+ lines.push(`## TOTAL: ${totalStr}${marginStr}`);
79
+ lines.push(`## Untracked services: ${brief.untrackedCount}`);
80
+ lines.push("");
81
+ if (brief.alerts.length > 0) {
82
+ lines.push("## Alerts");
83
+ for (const alert of brief.alerts) {
84
+ const icon = alert.severity === "critical" ? "\u{1F6A8}" : "\u26A0\uFE0F";
85
+ lines.push(`- ${icon} ${alert.message}`);
86
+ }
87
+ lines.push("");
88
+ }
89
+ const ledgerPath = path2.join(
90
+ projectConfigDir(projectRoot),
91
+ "spend-ledger.md"
92
+ );
93
+ fs2.mkdirSync(path2.dirname(ledgerPath), { recursive: true });
94
+ fs2.writeFileSync(ledgerPath, lines.join("\n") + "\n", "utf-8");
95
+ }
96
+ function logEvent(event, projectRoot) {
97
+ const logPath = path2.join(projectDataDir(projectRoot), "events.jsonl");
98
+ fs2.mkdirSync(path2.dirname(logPath), { recursive: true });
99
+ fs2.appendFileSync(logPath, JSON.stringify(event) + "\n", "utf-8");
100
+ }
101
+ function saveSnapshot(brief, projectRoot) {
102
+ const snapshotDir = path2.join(projectDataDir(projectRoot), "snapshots");
103
+ fs2.mkdirSync(snapshotDir, { recursive: true });
104
+ const filename = `snapshot-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.json`;
105
+ fs2.writeFileSync(
106
+ path2.join(snapshotDir, filename),
107
+ JSON.stringify(brief, null, 2) + "\n",
108
+ "utf-8"
109
+ );
110
+ }
111
+ function readLatestSnapshot(projectRoot) {
112
+ const snapshotDir = path2.join(projectDataDir(projectRoot), "snapshots");
113
+ try {
114
+ const files = fs2.readdirSync(snapshotDir).filter((f) => f.startsWith("snapshot-") && f.endsWith(".json")).sort().reverse();
115
+ if (files.length === 0) return null;
116
+ const raw = fs2.readFileSync(
117
+ path2.join(snapshotDir, files[0]),
118
+ "utf-8"
119
+ );
120
+ return JSON.parse(raw);
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ // src/core/brief.ts
127
+ function formatBrief(brief) {
128
+ const lines = [];
129
+ const width = 62;
130
+ const hrDouble = "\u2550".repeat(width);
131
+ const hrSingle = "\u2500".repeat(width - 4);
132
+ lines.push(`\u2554${hrDouble}\u2557`);
133
+ lines.push(
134
+ `\u2551 BURNWATCH \u2014 ${brief.projectName} \u2014 ${brief.period}`.padEnd(
135
+ width + 1
136
+ ) + "\u2551"
137
+ );
138
+ lines.push(`\u2560${hrDouble}\u2563`);
139
+ lines.push(
140
+ formatRow("Service", "Spend", "Conf", "Budget", "Left", width)
141
+ );
142
+ lines.push(`\u2551 ${hrSingle} \u2551`);
143
+ for (const svc of brief.services) {
144
+ const spendStr = svc.isEstimate ? `~$${svc.spend.toFixed(2)}` : `$${svc.spend.toFixed(2)}`;
145
+ const badge = CONFIDENCE_BADGES[svc.tier];
146
+ const budgetStr = svc.budget ? `$${svc.budget}` : "\u2014";
147
+ const leftStr = formatLeft(svc);
148
+ lines.push(formatRow(svc.serviceId, spendStr, badge, budgetStr, leftStr, width));
149
+ }
150
+ lines.push(`\u2560${hrDouble}\u2563`);
151
+ const totalStr = brief.totalIsEstimate ? `~$${brief.totalSpend.toFixed(2)}` : `$${brief.totalSpend.toFixed(2)}`;
152
+ const marginStr = brief.estimateMargin > 0 ? ` Est margin: \xB1$${brief.estimateMargin.toFixed(0)}` : "";
153
+ const untrackedStr = brief.untrackedCount > 0 ? `Untracked: ${brief.untrackedCount} \u26A0\uFE0F` : `Untracked: 0 \u2705`;
154
+ lines.push(
155
+ `\u2551 TOTAL: ${totalStr} ${untrackedStr}${marginStr}`.padEnd(
156
+ width + 1
157
+ ) + "\u2551"
158
+ );
159
+ for (const alert of brief.alerts) {
160
+ const icon = alert.severity === "critical" ? "\u{1F6A8}" : "\u26A0\uFE0F";
161
+ lines.push(
162
+ `\u2551 ${icon} ${alert.message}`.padEnd(width + 1) + "\u2551"
163
+ );
164
+ }
165
+ lines.push(`\u255A${hrDouble}\u255D`);
166
+ return lines.join("\n");
167
+ }
168
+ function buildBrief(projectName, snapshots, blindCount) {
169
+ const now = /* @__PURE__ */ new Date();
170
+ const period = now.toLocaleDateString("en-US", {
171
+ month: "long",
172
+ year: "numeric"
173
+ });
174
+ let totalSpend = 0;
175
+ let hasEstimates = false;
176
+ let estimateMargin = 0;
177
+ const alerts = [];
178
+ for (const snap of snapshots) {
179
+ totalSpend += snap.spend;
180
+ if (snap.isEstimate) {
181
+ hasEstimates = true;
182
+ estimateMargin += snap.spend * 0.15;
183
+ }
184
+ if (snap.status === "over") {
185
+ alerts.push({
186
+ serviceId: snap.serviceId,
187
+ type: "over_budget",
188
+ message: `${snap.serviceId.toUpperCase()} ${snap.budgetPercent?.toFixed(0) ?? "?"}% OVER BUDGET \u2014 review before use`,
189
+ severity: "critical"
190
+ });
191
+ } else if (snap.status === "caution" && snap.budgetPercent && snap.budgetPercent >= 80) {
192
+ alerts.push({
193
+ serviceId: snap.serviceId,
194
+ type: "near_budget",
195
+ message: `${snap.serviceId} at ${snap.budgetPercent.toFixed(0)}% of budget`,
196
+ severity: "warning"
197
+ });
198
+ }
199
+ }
200
+ if (blindCount > 0) {
201
+ alerts.push({
202
+ serviceId: "_blind",
203
+ type: "blind_service",
204
+ message: `${blindCount} service${blindCount > 1 ? "s" : ""} detected but untracked \u2014 run 'burnwatch status' to see`,
205
+ severity: "warning"
206
+ });
207
+ }
208
+ return {
209
+ projectName,
210
+ generatedAt: now.toISOString(),
211
+ period,
212
+ services: snapshots,
213
+ totalSpend,
214
+ totalIsEstimate: hasEstimates,
215
+ estimateMargin,
216
+ untrackedCount: blindCount,
217
+ alerts
218
+ };
219
+ }
220
+ function formatRow(service, spend, conf, budget, left, width) {
221
+ const row = ` ${service.padEnd(14)} ${spend.padEnd(11)} ${conf.padEnd(7)} ${budget.padEnd(7)} ${left}`;
222
+ return `\u2551${row}`.padEnd(width + 1) + "\u2551";
223
+ }
224
+ function formatLeft(snap) {
225
+ if (!snap.budget) return "\u2014";
226
+ if (snap.status === "over") return "\u26A0\uFE0F OVR";
227
+ if (snap.budgetPercent !== void 0) {
228
+ const remaining = 100 - snap.budgetPercent;
229
+ return `${remaining.toFixed(0)}%`;
230
+ }
231
+ return "\u2014";
232
+ }
233
+ function buildSnapshot(serviceId, tier, spend, budget) {
234
+ const isEstimate = tier === "est" || tier === "calc";
235
+ const budgetPercent = budget ? spend / budget * 100 : void 0;
236
+ let status = "unknown";
237
+ let statusLabel = "no budget";
238
+ if (budget) {
239
+ if (budgetPercent > 100) {
240
+ status = "over";
241
+ statusLabel = `\u26A0\uFE0F ${budgetPercent.toFixed(0)}% over`;
242
+ } else if (budgetPercent >= 75) {
243
+ status = "caution";
244
+ statusLabel = `${(100 - budgetPercent).toFixed(0)}% \u2014 caution`;
245
+ } else {
246
+ status = "healthy";
247
+ statusLabel = `${(100 - budgetPercent).toFixed(0)}% \u2014 healthy`;
248
+ }
249
+ }
250
+ if (tier === "calc" && budget) {
251
+ statusLabel = `flat \u2014 on plan`;
252
+ status = "healthy";
253
+ }
254
+ return {
255
+ serviceId,
256
+ spend,
257
+ isEstimate,
258
+ tier,
259
+ budget,
260
+ budgetPercent,
261
+ status,
262
+ statusLabel,
263
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
264
+ };
265
+ }
266
+
267
+ // src/detection/detector.ts
268
+ import * as fs4 from "fs";
269
+ import * as path4 from "path";
270
+
271
+ // src/core/registry.ts
272
+ import * as fs3 from "fs";
273
+ import * as path3 from "path";
274
+ import * as url from "url";
275
+ var __dirname = path3.dirname(url.fileURLToPath(import.meta.url));
276
+ var cachedRegistry = null;
277
+ function loadRegistry(projectRoot) {
278
+ if (cachedRegistry) return cachedRegistry;
279
+ const registry = /* @__PURE__ */ new Map();
280
+ const candidates = [
281
+ path3.resolve(__dirname, "../../registry.json"),
282
+ // from src/core/
283
+ path3.resolve(__dirname, "../registry.json")
284
+ // from dist/
285
+ ];
286
+ for (const candidate of candidates) {
287
+ if (fs3.existsSync(candidate)) {
288
+ loadRegistryFile(candidate, registry);
289
+ break;
290
+ }
291
+ }
292
+ if (projectRoot) {
293
+ const localPath = path3.join(projectRoot, ".burnwatch", "registry.json");
294
+ if (fs3.existsSync(localPath)) {
295
+ loadRegistryFile(localPath, registry);
296
+ }
297
+ }
298
+ cachedRegistry = registry;
299
+ return registry;
300
+ }
301
+ function loadRegistryFile(filePath, registry) {
302
+ try {
303
+ const raw = fs3.readFileSync(filePath, "utf-8");
304
+ const data = JSON.parse(raw);
305
+ for (const [id, service] of Object.entries(data.services)) {
306
+ registry.set(id, { ...service, id });
307
+ }
308
+ } catch {
309
+ }
310
+ }
311
+ function getService(id, projectRoot) {
312
+ return loadRegistry(projectRoot).get(id);
313
+ }
314
+
315
+ // src/detection/detector.ts
316
+ function detectServices(projectRoot) {
317
+ const registry = loadRegistry(projectRoot);
318
+ const results = /* @__PURE__ */ new Map();
319
+ const pkgDeps = scanPackageJson(projectRoot);
320
+ for (const [serviceId, service] of registry) {
321
+ const matchedPkgs = service.packageNames.filter(
322
+ (pkg) => pkgDeps.has(pkg)
323
+ );
324
+ if (matchedPkgs.length > 0) {
325
+ getOrCreate(results, serviceId, service).sources.push("package_json");
326
+ getOrCreate(results, serviceId, service).details.push(
327
+ `package.json: ${matchedPkgs.join(", ")}`
328
+ );
329
+ }
330
+ }
331
+ const envVars = new Set(Object.keys(process.env));
332
+ for (const [serviceId, service] of registry) {
333
+ const matchedEnvs = service.envPatterns.filter(
334
+ (pattern) => envVars.has(pattern)
335
+ );
336
+ if (matchedEnvs.length > 0) {
337
+ getOrCreate(results, serviceId, service).sources.push("env_var");
338
+ getOrCreate(results, serviceId, service).details.push(
339
+ `env vars: ${matchedEnvs.join(", ")}`
340
+ );
341
+ }
342
+ }
343
+ const importHits = scanImports(projectRoot);
344
+ for (const [serviceId, service] of registry) {
345
+ const matchedImports = service.importPatterns.filter(
346
+ (pattern) => importHits.has(pattern)
347
+ );
348
+ if (matchedImports.length > 0) {
349
+ if (!getOrCreate(results, serviceId, service).sources.includes(
350
+ "import_scan"
351
+ )) {
352
+ getOrCreate(results, serviceId, service).sources.push("import_scan");
353
+ getOrCreate(results, serviceId, service).details.push(
354
+ `imports: ${matchedImports.join(", ")}`
355
+ );
356
+ }
357
+ }
358
+ }
359
+ return Array.from(results.values());
360
+ }
361
+ function getOrCreate(map, serviceId, service) {
362
+ let result = map.get(serviceId);
363
+ if (!result) {
364
+ result = { service, sources: [], details: [] };
365
+ map.set(serviceId, result);
366
+ }
367
+ return result;
368
+ }
369
+ function scanPackageJson(projectRoot) {
370
+ const deps = /* @__PURE__ */ new Set();
371
+ const pkgPath = path4.join(projectRoot, "package.json");
372
+ try {
373
+ const raw = fs4.readFileSync(pkgPath, "utf-8");
374
+ const pkg = JSON.parse(raw);
375
+ for (const name of Object.keys(pkg.dependencies ?? {})) deps.add(name);
376
+ for (const name of Object.keys(pkg.devDependencies ?? {})) deps.add(name);
377
+ } catch {
378
+ }
379
+ return deps;
380
+ }
381
+ function scanImports(projectRoot) {
382
+ const imports = /* @__PURE__ */ new Set();
383
+ const srcDir = path4.join(projectRoot, "src");
384
+ if (!fs4.existsSync(srcDir)) return imports;
385
+ const files = walkDir(srcDir, /\.(ts|tsx|js|jsx|mjs|cjs)$/);
386
+ for (const file of files) {
387
+ try {
388
+ const content = fs4.readFileSync(file, "utf-8");
389
+ const importRegex = /(?:from\s+["']|require\s*\(\s*["'])([^./][^"']*?)(?:["'])/g;
390
+ let match;
391
+ while ((match = importRegex.exec(content)) !== null) {
392
+ const pkg = match[1];
393
+ if (pkg) {
394
+ const parts = pkg.split("/");
395
+ if (parts[0]?.startsWith("@") && parts.length >= 2) {
396
+ imports.add(`${parts[0]}/${parts[1]}`);
397
+ } else if (parts[0]) {
398
+ imports.add(parts[0]);
399
+ }
400
+ }
401
+ }
402
+ } catch {
403
+ }
404
+ }
405
+ return imports;
406
+ }
407
+ function walkDir(dir, pattern, maxDepth = 5) {
408
+ const results = [];
409
+ if (maxDepth <= 0) return results;
410
+ try {
411
+ const entries = fs4.readdirSync(dir, { withFileTypes: true });
412
+ for (const entry of entries) {
413
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
414
+ const fullPath = path4.join(dir, entry.name);
415
+ if (entry.isDirectory()) {
416
+ results.push(...walkDir(fullPath, pattern, maxDepth - 1));
417
+ } else if (pattern.test(entry.name)) {
418
+ results.push(fullPath);
419
+ }
420
+ }
421
+ } catch {
422
+ }
423
+ return results;
424
+ }
425
+
426
+ // src/services/base.ts
427
+ async function fetchJson(url2, options = {}) {
428
+ try {
429
+ const controller = new AbortController();
430
+ const timeoutId = setTimeout(
431
+ () => controller.abort(),
432
+ options.timeout ?? 1e4
433
+ );
434
+ const response = await fetch(url2, {
435
+ method: options.method ?? "GET",
436
+ headers: options.headers,
437
+ body: options.body,
438
+ signal: controller.signal
439
+ });
440
+ clearTimeout(timeoutId);
441
+ if (!response.ok) {
442
+ return {
443
+ ok: false,
444
+ status: response.status,
445
+ error: `HTTP ${response.status}: ${response.statusText}`
446
+ };
447
+ }
448
+ const data = await response.json();
449
+ return { ok: true, status: response.status, data };
450
+ } catch (err) {
451
+ return {
452
+ ok: false,
453
+ status: 0,
454
+ error: err instanceof Error ? err.message : "Unknown error"
455
+ };
456
+ }
457
+ }
458
+
459
+ // src/services/anthropic.ts
460
+ var anthropicConnector = {
461
+ serviceId: "anthropic",
462
+ async fetchSpend(apiKey) {
463
+ const now = /* @__PURE__ */ new Date();
464
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
465
+ const startDate = startOfMonth.toISOString().split("T")[0];
466
+ const endDate = now.toISOString().split("T")[0];
467
+ const url2 = `https://api.anthropic.com/v1/organizations/usage?start_date=${startDate}&end_date=${endDate}`;
468
+ const result = await fetchJson(url2, {
469
+ headers: {
470
+ "x-api-key": apiKey,
471
+ "anthropic-version": "2023-06-01"
472
+ }
473
+ });
474
+ if (!result.ok || !result.data) {
475
+ return {
476
+ serviceId: "anthropic",
477
+ spend: 0,
478
+ isEstimate: true,
479
+ tier: "est",
480
+ error: result.error ?? "Failed to fetch Anthropic usage"
481
+ };
482
+ }
483
+ let totalSpend = 0;
484
+ if (result.data.total_cost_usd !== void 0) {
485
+ totalSpend = result.data.total_cost_usd;
486
+ } else if (result.data.data) {
487
+ totalSpend = result.data.data.reduce(
488
+ (sum, entry) => sum + (entry.total_cost_usd ?? entry.spend ?? 0),
489
+ 0
490
+ );
491
+ }
492
+ return {
493
+ serviceId: "anthropic",
494
+ spend: totalSpend,
495
+ isEstimate: false,
496
+ tier: "live",
497
+ raw: result.data
498
+ };
499
+ }
500
+ };
501
+
502
+ // src/services/openai.ts
503
+ var openaiConnector = {
504
+ serviceId: "openai",
505
+ async fetchSpend(apiKey) {
506
+ const now = /* @__PURE__ */ new Date();
507
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
508
+ const startTime = Math.floor(startOfMonth.getTime() / 1e3);
509
+ const url2 = `https://api.openai.com/v1/organization/costs?start_time=${startTime}`;
510
+ const result = await fetchJson(url2, {
511
+ headers: {
512
+ Authorization: `Bearer ${apiKey}`
513
+ }
514
+ });
515
+ if (!result.ok || !result.data) {
516
+ return {
517
+ serviceId: "openai",
518
+ spend: 0,
519
+ isEstimate: true,
520
+ tier: "est",
521
+ error: result.error ?? "Failed to fetch OpenAI usage"
522
+ };
523
+ }
524
+ let totalSpend = 0;
525
+ if (result.data.data) {
526
+ for (const bucket of result.data.data) {
527
+ if (bucket.results) {
528
+ for (const r of bucket.results) {
529
+ totalSpend += r.amount?.value ?? 0;
530
+ }
531
+ }
532
+ }
533
+ }
534
+ totalSpend = totalSpend / 100;
535
+ return {
536
+ serviceId: "openai",
537
+ spend: totalSpend,
538
+ isEstimate: false,
539
+ tier: "live",
540
+ raw: result.data
541
+ };
542
+ }
543
+ };
544
+
545
+ // src/services/vercel.ts
546
+ var vercelConnector = {
547
+ serviceId: "vercel",
548
+ async fetchSpend(token, options) {
549
+ const teamId = options?.["teamId"] ?? "";
550
+ const teamParam = teamId ? `?teamId=${teamId}` : "";
551
+ const url2 = `https://api.vercel.com/v2/usage${teamParam}`;
552
+ const result = await fetchJson(url2, {
553
+ headers: {
554
+ Authorization: `Bearer ${token}`
555
+ }
556
+ });
557
+ if (!result.ok || !result.data) {
558
+ return {
559
+ serviceId: "vercel",
560
+ spend: 0,
561
+ isEstimate: true,
562
+ tier: "est",
563
+ error: result.error ?? "Failed to fetch Vercel usage"
564
+ };
565
+ }
566
+ let totalSpend = 0;
567
+ if (result.data.usage?.total !== void 0) {
568
+ totalSpend = result.data.usage.total;
569
+ } else if (result.data.billing?.invoiceItems) {
570
+ totalSpend = result.data.billing.invoiceItems.reduce(
571
+ (sum, item) => sum + (item.amount ?? 0),
572
+ 0
573
+ );
574
+ }
575
+ return {
576
+ serviceId: "vercel",
577
+ spend: totalSpend,
578
+ isEstimate: false,
579
+ tier: "live",
580
+ raw: result.data
581
+ };
582
+ }
583
+ };
584
+
585
+ // src/services/scrapfly.ts
586
+ var scrapflyConnector = {
587
+ serviceId: "scrapfly",
588
+ async fetchSpend(apiKey) {
589
+ const url2 = `https://api.scrapfly.io/account?key=${apiKey}`;
590
+ const result = await fetchJson(url2);
591
+ if (!result.ok || !result.data) {
592
+ return {
593
+ serviceId: "scrapfly",
594
+ spend: 0,
595
+ isEstimate: true,
596
+ tier: "est",
597
+ error: result.error ?? "Failed to fetch Scrapfly account"
598
+ };
599
+ }
600
+ let creditsUsed = 0;
601
+ let creditsTotal = 0;
602
+ if (result.data.subscription?.usage?.scrape) {
603
+ creditsUsed = result.data.subscription.usage.scrape.used ?? 0;
604
+ creditsTotal = result.data.subscription.usage.scrape.allowed ?? 0;
605
+ } else if (result.data.account) {
606
+ creditsUsed = result.data.account.credits_used ?? 0;
607
+ creditsTotal = result.data.account.credits_total ?? 0;
608
+ }
609
+ const creditRate = 15e-5;
610
+ const spend = creditsUsed * creditRate;
611
+ return {
612
+ serviceId: "scrapfly",
613
+ spend,
614
+ isEstimate: false,
615
+ tier: "live",
616
+ raw: {
617
+ credits_used: creditsUsed,
618
+ credits_total: creditsTotal,
619
+ credit_rate: creditRate,
620
+ ...result.data
621
+ }
622
+ };
623
+ }
624
+ };
625
+
626
+ // src/services/index.ts
627
+ var connectors = /* @__PURE__ */ new Map([
628
+ ["anthropic", anthropicConnector],
629
+ ["openai", openaiConnector],
630
+ ["vercel", vercelConnector],
631
+ ["scrapfly", scrapflyConnector]
632
+ ]);
633
+ async function pollService(tracked) {
634
+ const globalConfig = readGlobalConfig();
635
+ const serviceConfig = globalConfig.services[tracked.serviceId];
636
+ const connector = connectors.get(tracked.serviceId);
637
+ const definition = getService(tracked.serviceId);
638
+ if (connector && serviceConfig?.apiKey) {
639
+ try {
640
+ const result = await connector.fetchSpend(
641
+ serviceConfig.apiKey,
642
+ serviceConfig
643
+ );
644
+ if (!result.error) return result;
645
+ } catch {
646
+ }
647
+ }
648
+ if (tracked.planCost !== void 0) {
649
+ const now = /* @__PURE__ */ new Date();
650
+ const daysInMonth = new Date(
651
+ now.getFullYear(),
652
+ now.getMonth() + 1,
653
+ 0
654
+ ).getDate();
655
+ const dayOfMonth = now.getDate();
656
+ const projectedSpend = tracked.planCost / daysInMonth * dayOfMonth;
657
+ return {
658
+ serviceId: tracked.serviceId,
659
+ spend: projectedSpend,
660
+ isEstimate: true,
661
+ tier: "calc"
662
+ };
663
+ }
664
+ if (definition) {
665
+ let tier;
666
+ if (tracked.tierOverride) {
667
+ tier = tracked.tierOverride;
668
+ } else if (definition.apiTier === "live") {
669
+ tier = "blind";
670
+ } else {
671
+ tier = definition.apiTier;
672
+ }
673
+ return {
674
+ serviceId: tracked.serviceId,
675
+ spend: 0,
676
+ isEstimate: tier !== "live",
677
+ tier,
678
+ error: tier === "blind" ? "No API key configured" : void 0
679
+ };
680
+ }
681
+ return {
682
+ serviceId: tracked.serviceId,
683
+ spend: 0,
684
+ isEstimate: true,
685
+ tier: "blind",
686
+ error: "Unknown service \u2014 not in registry"
687
+ };
688
+ }
689
+ async function pollAllServices(services) {
690
+ return Promise.all(services.map(pollService));
691
+ }
692
+
693
+ // src/hooks/on-session-start.ts
694
+ async function main() {
695
+ let input;
696
+ try {
697
+ const stdin = fs5.readFileSync(0, "utf-8");
698
+ input = JSON.parse(stdin);
699
+ } catch {
700
+ process.exit(0);
701
+ return;
702
+ }
703
+ const projectRoot = input.cwd;
704
+ if (!isInitialized(projectRoot)) {
705
+ process.exit(0);
706
+ return;
707
+ }
708
+ const config = readProjectConfig(projectRoot);
709
+ const cachedBrief = readLatestSnapshot(projectRoot);
710
+ let briefText;
711
+ if (cachedBrief) {
712
+ briefText = formatBrief(cachedBrief);
713
+ } else {
714
+ const detected = detectServices(projectRoot);
715
+ const snapshots = Object.values(config.services).map((tracked) => {
716
+ return buildSnapshot(
717
+ tracked.serviceId,
718
+ tracked.hasApiKey ? "live" : "blind",
719
+ tracked.planCost ?? 0,
720
+ tracked.budget
721
+ );
722
+ });
723
+ for (const det of detected) {
724
+ if (!config.services[det.service.id]) {
725
+ snapshots.push(
726
+ buildSnapshot(det.service.id, "blind", 0, void 0)
727
+ );
728
+ }
729
+ }
730
+ const blindCount = snapshots.filter((s) => s.tier === "blind").length;
731
+ const brief = buildBrief(config.projectName, snapshots, blindCount);
732
+ briefText = formatBrief(brief);
733
+ }
734
+ const output = {
735
+ hookSpecificOutput: {
736
+ hookEventName: "SessionStart",
737
+ additionalContext: briefText
738
+ }
739
+ };
740
+ process.stdout.write(JSON.stringify(output));
741
+ logEvent(
742
+ {
743
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
744
+ sessionId: input.session_id,
745
+ type: "session_start",
746
+ data: { source: input.source }
747
+ },
748
+ projectRoot
749
+ );
750
+ try {
751
+ const trackedServices = Object.values(config.services);
752
+ if (trackedServices.length > 0) {
753
+ const results = await pollAllServices(trackedServices);
754
+ const snapshots = results.map(
755
+ (r) => buildSnapshot(r.serviceId, r.tier, r.spend, config.services[r.serviceId]?.budget)
756
+ );
757
+ const blindCount = snapshots.filter((s) => s.tier === "blind").length;
758
+ const brief = buildBrief(config.projectName, snapshots, blindCount);
759
+ saveSnapshot(brief, projectRoot);
760
+ writeLedger(brief, projectRoot);
761
+ }
762
+ } catch {
763
+ }
764
+ }
765
+ main().catch(() => process.exit(0));
766
+ //# sourceMappingURL=on-session-start.js.map