burnwatch 0.12.0 → 0.13.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 CHANGED
@@ -287,8 +287,8 @@ var init_probes = __esm({
287
287
  });
288
288
 
289
289
  // src/cli.ts
290
- import * as fs5 from "fs";
291
- import * as path5 from "path";
290
+ import * as fs7 from "fs";
291
+ import * as path7 from "path";
292
292
 
293
293
  // src/core/config.ts
294
294
  import * as fs from "fs";
@@ -502,8 +502,7 @@ function collectEnvVars(projectRoot) {
502
502
  for (const envFile of envFiles) {
503
503
  try {
504
504
  const content = fs3.readFileSync(envFile, "utf-8");
505
- const keys = content.split("\n").filter((line) => line.includes("=") && !line.startsWith("#")).map((line) => line.split("=")[0].trim()).filter(Boolean);
506
- for (const key of keys) {
505
+ for (const key of parseEnvKeys(content)) {
507
506
  envVars.add(key);
508
507
  }
509
508
  } catch {
@@ -511,6 +510,19 @@ function collectEnvVars(projectRoot) {
511
510
  }
512
511
  return envVars;
513
512
  }
513
+ function parseEnvKeys(content) {
514
+ const keys = /* @__PURE__ */ new Set();
515
+ for (const line of content.split("\n")) {
516
+ const trimmed = line.trim();
517
+ if (!trimmed || trimmed.startsWith("#")) continue;
518
+ const stripped = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed;
519
+ const eqIdx = stripped.indexOf("=");
520
+ if (eqIdx > 0) {
521
+ keys.add(stripped.slice(0, eqIdx).trim());
522
+ }
523
+ }
524
+ return keys;
525
+ }
514
526
  function findEnvFiles(dir, maxDepth) {
515
527
  const results = [];
516
528
  if (maxDepth <= 0) return results;
@@ -1267,6 +1279,8 @@ function saveSnapshot(brief, projectRoot) {
1267
1279
 
1268
1280
  // src/interactive-init.ts
1269
1281
  import * as readline from "readline";
1282
+ import * as fs5 from "fs";
1283
+ import "path";
1270
1284
  init_probes();
1271
1285
  function formatUnits(n) {
1272
1286
  if (n >= 1e6) return `${(n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1)}M`;
@@ -1313,11 +1327,27 @@ function ask(rl, question) {
1313
1327
  });
1314
1328
  });
1315
1329
  }
1316
- function findEnvKey(service) {
1330
+ function findEnvKey(service, projectRoot = process.cwd()) {
1317
1331
  for (const pattern of service.envPatterns) {
1318
1332
  const val = process.env[pattern];
1319
1333
  if (val && val.length > 0) return val;
1320
1334
  }
1335
+ if (projectRoot) {
1336
+ const envFiles = findEnvFiles(projectRoot, 3);
1337
+ for (const filePath of envFiles) {
1338
+ try {
1339
+ const content = fs5.readFileSync(filePath, "utf-8");
1340
+ for (const pattern of service.envPatterns) {
1341
+ const regex = new RegExp(`^(?:export\\s+)?${pattern}\\s*=\\s*(.+)$`, "m");
1342
+ const match = content.match(regex);
1343
+ if (match?.[1]) {
1344
+ return match[1].trim().replace(/^["']|["']$/g, "");
1345
+ }
1346
+ }
1347
+ } catch {
1348
+ }
1349
+ }
1350
+ }
1321
1351
  return void 0;
1322
1352
  }
1323
1353
  async function autoConfigureServices(detected) {
@@ -1592,6 +1622,439 @@ async function runInteractiveInit(detected) {
1592
1622
  return { services };
1593
1623
  }
1594
1624
 
1625
+ // src/utilization.ts
1626
+ import * as fs6 from "fs";
1627
+ import * as path6 from "path";
1628
+
1629
+ // src/cost-impact.ts
1630
+ var SERVICE_CALL_PATTERNS = {
1631
+ anthropic: [
1632
+ /\.messages\.create\s*\(/g,
1633
+ /\.completions\.create\s*\(/g,
1634
+ /anthropic\.\w+\.create\s*\(/g
1635
+ ],
1636
+ openai: [
1637
+ /\.chat\.completions\.create\s*\(/g,
1638
+ /\.completions\.create\s*\(/g,
1639
+ /\.images\.generate\s*\(/g,
1640
+ /\.embeddings\.create\s*\(/g,
1641
+ /openai\.\w+\.create\s*\(/g
1642
+ ],
1643
+ "google-gemini": [
1644
+ /\.generateContent\s*\(/g,
1645
+ /\.generateContentStream\s*\(/g,
1646
+ /model\.generate\w*\s*\(/g
1647
+ ],
1648
+ "voyage-ai": [
1649
+ /\.embed\s*\(/g,
1650
+ /voyageai\.embed\s*\(/g
1651
+ ],
1652
+ scrapfly: [
1653
+ /\.scrape\s*\(/g,
1654
+ /scrapfly\.scrape\s*\(/g,
1655
+ /\.async_scrape\s*\(/g,
1656
+ /ScrapeConfig\s*\(/g
1657
+ ],
1658
+ browserbase: [
1659
+ /\.createSession\s*\(/g,
1660
+ /\.sessions\.create\s*\(/g,
1661
+ /stagehand\.act\s*\(/g,
1662
+ /stagehand\.extract\s*\(/g
1663
+ ],
1664
+ upstash: [
1665
+ /redis\.\w+\s*\(/g,
1666
+ /\.set\s*\(/g,
1667
+ /\.get\s*\(/g,
1668
+ /\.incr\s*\(/g,
1669
+ /\.hset\s*\(/g
1670
+ ],
1671
+ resend: [
1672
+ /resend\.emails\.send\s*\(/g,
1673
+ /\.emails\.send\s*\(/g
1674
+ ],
1675
+ stripe: [
1676
+ /stripe\.charges\.create\s*\(/g,
1677
+ /stripe\.paymentIntents\.create\s*\(/g,
1678
+ /stripe\.checkout\.sessions\.create\s*\(/g
1679
+ ],
1680
+ supabase: [
1681
+ /supabase\.from\s*\(/g,
1682
+ /\.rpc\s*\(/g,
1683
+ /supabase\.storage/g
1684
+ ],
1685
+ inngest: [
1686
+ /inngest\.send\s*\(/g,
1687
+ /\.createFunction\s*\(/g
1688
+ ],
1689
+ posthog: [
1690
+ /posthog\.capture\s*\(/g,
1691
+ /\.capture\s*\(/g
1692
+ ],
1693
+ aws: [
1694
+ /\.send\s*\(new\s+\w+Command/g,
1695
+ /s3Client\.send\s*\(/g,
1696
+ /lambdaClient\.send\s*\(/g
1697
+ ],
1698
+ firebase: [
1699
+ /firestore\.\w+\(\s*["']/g,
1700
+ /\.collection\s*\(/g,
1701
+ /\.doc\s*\(/g,
1702
+ /admin\.firestore\(\)/g
1703
+ ],
1704
+ twilio: [
1705
+ /\.messages\.create\s*\(/g,
1706
+ /\.calls\.create\s*\(/g,
1707
+ /twilio\.messages/g
1708
+ ],
1709
+ sendgrid: [
1710
+ /sgMail\.send\s*\(/g,
1711
+ /\.send\s*\(\s*msg/g
1712
+ ],
1713
+ "mongodb-atlas": [
1714
+ /\.find\s*\(/g,
1715
+ /\.insertOne\s*\(/g,
1716
+ /\.insertMany\s*\(/g,
1717
+ /\.updateOne\s*\(/g,
1718
+ /\.aggregate\s*\(/g
1719
+ ],
1720
+ clerk: [
1721
+ /clerkClient\.\w+/g,
1722
+ /auth\(\)/g
1723
+ ],
1724
+ replicate: [
1725
+ /replicate\.run\s*\(/g,
1726
+ /replicate\.predictions\.create\s*\(/g
1727
+ ]
1728
+ };
1729
+ function detectMultipliers(content) {
1730
+ const multipliers = [];
1731
+ if (/for\s*\(.*;\s*\w+\s*<\s*(\w+)/g.test(content)) {
1732
+ const loopMatch = content.match(/for\s*\(.*;\s*\w+\s*<\s*(\d+)/);
1733
+ if (loopMatch) {
1734
+ const bound = parseInt(loopMatch[1]);
1735
+ if (bound > 1) {
1736
+ multipliers.push({ label: `for loop (${bound} iterations)`, factor: bound });
1737
+ }
1738
+ } else {
1739
+ multipliers.push({ label: "for loop (variable bound)", factor: 10 });
1740
+ }
1741
+ }
1742
+ if (/\.\s*map\s*\(\s*(async\s*)?\(/g.test(content)) {
1743
+ multipliers.push({ label: ".map() iteration", factor: 10 });
1744
+ }
1745
+ if (/\.\s*forEach\s*\(\s*(async\s*)?\(/g.test(content)) {
1746
+ multipliers.push({ label: ".forEach() iteration", factor: 10 });
1747
+ }
1748
+ if (/for\s*\(\s*(const|let|var)\s+\w+\s+(of|in)\s+/g.test(content)) {
1749
+ multipliers.push({ label: "for...of/in loop", factor: 10 });
1750
+ }
1751
+ if (/Promise\.all\s*\(/g.test(content)) {
1752
+ multipliers.push({ label: "Promise.all (parallel batch)", factor: 10 });
1753
+ }
1754
+ if (/cron|schedule|interval|setInterval|every\s+\d+\s*(min|hour|day|sec)/gi.test(content)) {
1755
+ if (/every\s+5\s*min/gi.test(content) || /\*\/5\s+\*\s+\*/g.test(content)) {
1756
+ multipliers.push({ label: "cron: every 5 minutes", factor: 8640 });
1757
+ } else if (/every\s+1?\s*hour/gi.test(content) || /0\s+\*\s+\*\s+\*/g.test(content)) {
1758
+ multipliers.push({ label: "cron: hourly", factor: 720 });
1759
+ } else if (/every\s+1?\s*day/gi.test(content) || /0\s+0\s+\*\s+\*/g.test(content)) {
1760
+ multipliers.push({ label: "cron: daily", factor: 30 });
1761
+ } else {
1762
+ multipliers.push({ label: "scheduled execution", factor: 30 });
1763
+ }
1764
+ }
1765
+ const batchMatch = content.match(/batch[_\s]?size\s*[=:]\s*(\d+)/i);
1766
+ if (batchMatch) {
1767
+ const batchSize = parseInt(batchMatch[1]);
1768
+ if (batchSize > 1) {
1769
+ multipliers.push({ label: `batch size: ${batchSize}`, factor: batchSize });
1770
+ }
1771
+ }
1772
+ return multipliers;
1773
+ }
1774
+ var GOTCHA_MULTIPLIERS = {
1775
+ scrapfly: {
1776
+ low: 1,
1777
+ high: 25,
1778
+ explanation: "anti-bot bypass consumes 5-25x base credits"
1779
+ },
1780
+ browserbase: {
1781
+ low: 1,
1782
+ high: 5,
1783
+ explanation: "session duration affects cost \u2014 long sessions burn more"
1784
+ },
1785
+ anthropic: {
1786
+ low: 1,
1787
+ high: 60,
1788
+ explanation: "Haiku ~$0.25/MTok vs Opus ~$15/MTok (60x range)"
1789
+ },
1790
+ openai: {
1791
+ low: 1,
1792
+ high: 30,
1793
+ explanation: "GPT-4 mini vs GPT-5 (30x cost range)"
1794
+ },
1795
+ stripe: {
1796
+ low: 1,
1797
+ high: 1.5,
1798
+ explanation: "international cards add 1-1.5% extra"
1799
+ }
1800
+ };
1801
+ function analyzeCostImpact(filePath, content, projectRoot) {
1802
+ if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath)) {
1803
+ return [];
1804
+ }
1805
+ const registry = loadRegistry(projectRoot);
1806
+ const impacts = [];
1807
+ const multipliers = detectMultipliers(content);
1808
+ for (const [serviceId, patterns] of Object.entries(SERVICE_CALL_PATTERNS)) {
1809
+ let totalCalls = 0;
1810
+ for (const pattern of patterns) {
1811
+ pattern.lastIndex = 0;
1812
+ const matches = content.match(pattern);
1813
+ if (matches) {
1814
+ totalCalls += matches.length;
1815
+ }
1816
+ }
1817
+ if (totalCalls === 0) continue;
1818
+ const service = registry.get(serviceId);
1819
+ if (!service) continue;
1820
+ const multiplierFactor = multipliers.length > 0 ? multipliers.reduce((max, m) => Math.max(max, m.factor), 1) : 1;
1821
+ const baseMonthlyRuns = multipliers.some((m) => m.label.startsWith("cron")) ? 1 : 50;
1822
+ const monthlyInvocations = totalCalls * multiplierFactor * baseMonthlyRuns;
1823
+ const gotcha = GOTCHA_MULTIPLIERS[serviceId];
1824
+ const unitRate = service.pricing?.unitRate ?? 0;
1825
+ let costLow;
1826
+ let costHigh;
1827
+ if (unitRate > 0) {
1828
+ costLow = monthlyInvocations * unitRate * (gotcha?.low ?? 1);
1829
+ costHigh = monthlyInvocations * unitRate * (gotcha?.high ?? 1);
1830
+ } else if (service.pricing?.monthlyBase !== void 0) {
1831
+ costLow = 0;
1832
+ costHigh = 0;
1833
+ } else {
1834
+ const typicalCallCosts = {
1835
+ anthropic: 3e-3,
1836
+ // ~$3/MTok * ~1K tokens average
1837
+ openai: 2e-3,
1838
+ "google-gemini": 1e-3,
1839
+ scrapfly: 15e-5,
1840
+ browserbase: 0.01,
1841
+ resend: 1e-3,
1842
+ stripe: 0.3
1843
+ };
1844
+ const perCall = typicalCallCosts[serviceId] ?? 1e-3;
1845
+ costLow = monthlyInvocations * perCall * (gotcha?.low ?? 1);
1846
+ costHigh = monthlyInvocations * perCall * (gotcha?.high ?? 1);
1847
+ }
1848
+ if (costLow === 0 && costHigh === 0) continue;
1849
+ impacts.push({
1850
+ serviceId,
1851
+ serviceName: service.name,
1852
+ filePath,
1853
+ callCount: totalCalls,
1854
+ multipliers: multipliers.map((m) => m.label),
1855
+ multiplierFactor,
1856
+ monthlyInvocations,
1857
+ costLow,
1858
+ costHigh,
1859
+ rangeExplanation: gotcha?.explanation
1860
+ });
1861
+ }
1862
+ return impacts;
1863
+ }
1864
+
1865
+ // src/utilization.ts
1866
+ function utilizationModelPath(projectRoot) {
1867
+ return path6.join(projectDataDir(projectRoot), "utilization.json");
1868
+ }
1869
+ function readUtilizationModel(projectRoot) {
1870
+ try {
1871
+ const raw = fs6.readFileSync(utilizationModelPath(projectRoot), "utf-8");
1872
+ return JSON.parse(raw);
1873
+ } catch {
1874
+ return {
1875
+ version: 1,
1876
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1877
+ lastFullScan: null,
1878
+ services: {}
1879
+ };
1880
+ }
1881
+ }
1882
+ function writeUtilizationModel(model, projectRoot) {
1883
+ const dir = projectDataDir(projectRoot);
1884
+ fs6.mkdirSync(dir, { recursive: true });
1885
+ model.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1886
+ fs6.writeFileSync(
1887
+ utilizationModelPath(projectRoot),
1888
+ JSON.stringify(model, null, 2) + "\n",
1889
+ "utf-8"
1890
+ );
1891
+ }
1892
+ function analyzeFileUtilization(filePath, content, projectRoot) {
1893
+ const impacts = analyzeCostImpact(filePath, content, projectRoot);
1894
+ return impacts.map((impact) => ({
1895
+ filePath,
1896
+ serviceId: impact.serviceId,
1897
+ callCount: impact.callCount,
1898
+ multipliers: impact.multipliers,
1899
+ multiplierFactor: impact.multiplierFactor,
1900
+ monthlyInvocations: impact.monthlyInvocations,
1901
+ costLow: impact.costLow,
1902
+ costHigh: impact.costHigh
1903
+ }));
1904
+ }
1905
+ function updateUtilizationModel(model, filePath, newCallSites, projectRoot) {
1906
+ const registry = loadRegistry(projectRoot);
1907
+ const affectedServices = /* @__PURE__ */ new Set();
1908
+ for (const [serviceId, svc] of Object.entries(model.services)) {
1909
+ const before = svc.callSites.length;
1910
+ svc.callSites = svc.callSites.filter((cs) => cs.filePath !== filePath);
1911
+ if (svc.callSites.length !== before) {
1912
+ affectedServices.add(serviceId);
1913
+ }
1914
+ }
1915
+ for (const cs of newCallSites) {
1916
+ affectedServices.add(cs.serviceId);
1917
+ if (!model.services[cs.serviceId]) {
1918
+ const def = registry.get(cs.serviceId);
1919
+ model.services[cs.serviceId] = {
1920
+ serviceId: cs.serviceId,
1921
+ serviceName: def?.name ?? cs.serviceId,
1922
+ callSites: [],
1923
+ totalMonthlyUnits: 0,
1924
+ unitName: def?.pricing?.unitName ?? "API calls",
1925
+ planIncluded: null,
1926
+ projectedOverage: 0,
1927
+ projectedOverageCost: 0,
1928
+ projectedTotalCost: 0,
1929
+ planBaseCost: def?.pricing?.monthlyBase ?? 0,
1930
+ unitRate: def?.pricing?.unitRate ?? 0
1931
+ };
1932
+ }
1933
+ model.services[cs.serviceId].callSites.push(cs);
1934
+ }
1935
+ for (const serviceId of affectedServices) {
1936
+ recalculateServiceTotals(model, serviceId);
1937
+ }
1938
+ for (const serviceId of Object.keys(model.services)) {
1939
+ if (model.services[serviceId].callSites.length === 0) {
1940
+ delete model.services[serviceId];
1941
+ }
1942
+ }
1943
+ }
1944
+ function recalculateServiceTotals(model, serviceId) {
1945
+ const svc = model.services[serviceId];
1946
+ if (!svc) return;
1947
+ svc.totalMonthlyUnits = svc.callSites.reduce(
1948
+ (sum, cs) => sum + cs.monthlyInvocations,
1949
+ 0
1950
+ );
1951
+ const included = svc.planIncluded ?? 0;
1952
+ svc.projectedOverage = Math.max(0, svc.totalMonthlyUnits - included);
1953
+ svc.projectedOverageCost = svc.projectedOverage * svc.unitRate;
1954
+ svc.projectedTotalCost = svc.planBaseCost + svc.projectedOverageCost;
1955
+ }
1956
+ var CODE_DIRS = ["src", "app", "lib", "pages", "components", "utils", "services", "hooks", "api", "functions"];
1957
+ function buildUtilizationModel(projectRoot) {
1958
+ const model = {
1959
+ version: 1,
1960
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1961
+ lastFullScan: (/* @__PURE__ */ new Date()).toISOString(),
1962
+ services: {}
1963
+ };
1964
+ const files = findSourceFiles(projectRoot);
1965
+ for (const file of files) {
1966
+ try {
1967
+ const content = fs6.readFileSync(file, "utf-8");
1968
+ const callSites = analyzeFileUtilization(file, content, projectRoot);
1969
+ if (callSites.length > 0) {
1970
+ const relPath = path6.relative(projectRoot, file);
1971
+ const relativeSites = callSites.map((cs) => ({
1972
+ ...cs,
1973
+ filePath: relPath
1974
+ }));
1975
+ updateUtilizationModel(model, relPath, relativeSites, projectRoot);
1976
+ }
1977
+ } catch {
1978
+ }
1979
+ }
1980
+ return model;
1981
+ }
1982
+ function findSourceFiles(projectRoot) {
1983
+ const files = [];
1984
+ const dirsToScan = [];
1985
+ for (const dir of CODE_DIRS) {
1986
+ const fullPath = path6.join(projectRoot, dir);
1987
+ if (fs6.existsSync(fullPath)) {
1988
+ dirsToScan.push(fullPath);
1989
+ }
1990
+ }
1991
+ try {
1992
+ const entries = fs6.readdirSync(projectRoot, { withFileTypes: true });
1993
+ for (const entry of entries) {
1994
+ if (!entry.isDirectory()) continue;
1995
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist" || entry.name.startsWith(".")) continue;
1996
+ const subPkgPath = path6.join(projectRoot, entry.name, "package.json");
1997
+ if (fs6.existsSync(subPkgPath)) {
1998
+ for (const dir of CODE_DIRS) {
1999
+ const fullPath = path6.join(projectRoot, entry.name, dir);
2000
+ if (fs6.existsSync(fullPath)) {
2001
+ dirsToScan.push(fullPath);
2002
+ }
2003
+ }
2004
+ }
2005
+ }
2006
+ } catch {
2007
+ }
2008
+ for (const dir of dirsToScan) {
2009
+ walkDir2(dir, /\.(ts|tsx|js|jsx|mjs|cjs)$/, files);
2010
+ }
2011
+ return files;
2012
+ }
2013
+ function walkDir2(dir, pattern, results, maxDepth = 5) {
2014
+ if (maxDepth <= 0) return;
2015
+ try {
2016
+ const entries = fs6.readdirSync(dir, { withFileTypes: true });
2017
+ for (const entry of entries) {
2018
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
2019
+ const fullPath = path6.join(dir, entry.name);
2020
+ if (entry.isDirectory()) {
2021
+ walkDir2(fullPath, pattern, results, maxDepth - 1);
2022
+ } else if (pattern.test(entry.name)) {
2023
+ results.push(fullPath);
2024
+ }
2025
+ }
2026
+ } catch {
2027
+ }
2028
+ }
2029
+ function formatUtilizationSummary(model) {
2030
+ const services = Object.values(model.services).filter(
2031
+ (s) => s.totalMonthlyUnits > 0
2032
+ );
2033
+ if (services.length === 0) {
2034
+ return "\u{1F4CA} No utilization-tracked call sites found in the project.";
2035
+ }
2036
+ const lines = [];
2037
+ lines.push("\u{1F4CA} Utilization scan complete\n");
2038
+ lines.push("| Service | Call Sites | Monthly Units | Plan Included | Overage Cost |");
2039
+ lines.push("|---------|-----------|---------------|---------------|-------------|");
2040
+ for (const svc of services) {
2041
+ const fileCount = new Set(svc.callSites.map((cs) => cs.filePath)).size;
2042
+ const filesStr = `${fileCount} file${fileCount > 1 ? "s" : ""}`;
2043
+ const unitsStr = `${formatCompact2(svc.totalMonthlyUnits)} ${svc.unitName}`;
2044
+ const includedStr = svc.planIncluded !== null ? formatCompact2(svc.planIncluded) : "\u2014";
2045
+ const overageStr = svc.projectedOverageCost > 0 ? `~$${svc.projectedOverageCost.toFixed(2)}/mo` : "$0";
2046
+ lines.push(
2047
+ `| ${svc.serviceName} | ${filesStr} | ${unitsStr} | ${includedStr} | ${overageStr} |`
2048
+ );
2049
+ }
2050
+ return lines.join("\n");
2051
+ }
2052
+ function formatCompact2(n) {
2053
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1)}M`;
2054
+ if (n >= 1e3) return `${(n / 1e3).toFixed(n % 1e3 === 0 ? 0 : 1)}K`;
2055
+ return String(Math.round(n));
2056
+ }
2057
+
1595
2058
  // src/cli.ts
1596
2059
  var args = process.argv.slice(2);
1597
2060
  var command = args[0];
@@ -1620,6 +2083,9 @@ async function main() {
1620
2083
  case "configure":
1621
2084
  await cmdConfigure();
1622
2085
  break;
2086
+ case "scan":
2087
+ cmdScan();
2088
+ break;
1623
2089
  case "reset":
1624
2090
  cmdReset();
1625
2091
  break;
@@ -1646,10 +2112,10 @@ async function cmdInit() {
1646
2112
  const projectRoot = process.cwd();
1647
2113
  const nonInteractive = flags.has("--non-interactive") || flags.has("--ni");
1648
2114
  const alreadyInitialized = isInitialized(projectRoot);
1649
- let projectName = path5.basename(projectRoot);
2115
+ let projectName = path7.basename(projectRoot);
1650
2116
  try {
1651
- const pkgPath = path5.join(projectRoot, "package.json");
1652
- const pkg = JSON.parse(fs5.readFileSync(pkgPath, "utf-8"));
2117
+ const pkgPath = path7.join(projectRoot, "package.json");
2118
+ const pkg = JSON.parse(fs7.readFileSync(pkgPath, "utf-8"));
1653
2119
  if (pkg.name) projectName = pkg.name;
1654
2120
  } catch {
1655
2121
  }
@@ -1688,8 +2154,8 @@ async function cmdInit() {
1688
2154
  config.services = result.services;
1689
2155
  }
1690
2156
  writeProjectConfig(config, projectRoot);
1691
- const gitignorePath = path5.join(projectConfigDir(projectRoot), ".gitignore");
1692
- fs5.writeFileSync(
2157
+ const gitignorePath = path7.join(projectConfigDir(projectRoot), ".gitignore");
2158
+ fs7.writeFileSync(
1693
2159
  gitignorePath,
1694
2160
  [
1695
2161
  "# Burnwatch \u2014 ignore cache and snapshots, keep ledger and config",
@@ -1700,7 +2166,21 @@ async function cmdInit() {
1700
2166
  ].join("\n"),
1701
2167
  "utf-8"
1702
2168
  );
1703
- console.log("\n\u{1F517} Registering Claude Code hooks...\n");
2169
+ console.log("\u{1F4CA} Scanning for utilization patterns...");
2170
+ try {
2171
+ const model = buildUtilizationModel(projectRoot);
2172
+ writeUtilizationModel(model, projectRoot);
2173
+ const serviceCount = Object.keys(model.services).length;
2174
+ if (serviceCount > 0) {
2175
+ console.log(` Found call sites for ${serviceCount} service(s).
2176
+ `);
2177
+ } else {
2178
+ console.log(" No SDK call sites detected yet.\n");
2179
+ }
2180
+ } catch {
2181
+ console.log(" Utilization scan skipped.\n");
2182
+ }
2183
+ console.log("\u{1F517} Registering Claude Code hooks...\n");
1704
2184
  registerHooks(projectRoot);
1705
2185
  console.log("\nburnwatch initialized.\n");
1706
2186
  if (process.stdin.isTTY) {
@@ -1719,10 +2199,10 @@ async function cmdInterview() {
1719
2199
  if (!isInitialized(projectRoot)) {
1720
2200
  ensureProjectDirs(projectRoot);
1721
2201
  const detected = detectServices(projectRoot);
1722
- let projectName = path5.basename(projectRoot);
2202
+ let projectName = path7.basename(projectRoot);
1723
2203
  try {
1724
2204
  const pkg = JSON.parse(
1725
- fs5.readFileSync(path5.join(projectRoot, "package.json"), "utf-8")
2205
+ fs7.readFileSync(path7.join(projectRoot, "package.json"), "utf-8")
1726
2206
  );
1727
2207
  if (pkg.name) projectName = pkg.name;
1728
2208
  } catch {
@@ -1754,20 +2234,19 @@ async function cmdInterview() {
1754
2234
  if (!keySource && !globalKey) keySource = `env:${pattern}`;
1755
2235
  }
1756
2236
  }
1757
- const envFiles = [".env", ".env.local", ".env.development"];
1758
- for (const envFile of envFiles) {
2237
+ const envFiles = findEnvFiles(projectRoot, 3);
2238
+ for (const envFilePath of envFiles) {
1759
2239
  try {
1760
- const envPath = path5.join(projectRoot, envFile);
1761
- const envContent = fs5.readFileSync(envPath, "utf-8");
2240
+ const envContent = fs7.readFileSync(envFilePath, "utf-8");
2241
+ const envKeys = parseEnvKeys(envContent);
2242
+ const envFileName = path7.relative(projectRoot, envFilePath);
1762
2243
  for (const pattern of definition.envPatterns) {
1763
- const regex = new RegExp(`^${pattern}=(.+)$`, "m");
1764
- const match = envContent.match(regex);
1765
- if (match?.[1]) {
1766
- const label = `${pattern} (in ${envFile})`;
2244
+ if (envKeys.has(pattern)) {
2245
+ const label = `${pattern} (in ${envFileName})`;
1767
2246
  if (!envKeysFound.some((k) => k.startsWith(pattern))) {
1768
2247
  envKeysFound.push(label);
1769
2248
  }
1770
- if (!keySource && !globalKey) keySource = `file:${envFile}:${pattern}`;
2249
+ if (!keySource && !globalKey) keySource = `file:${envFileName}:${pattern}`;
1771
2250
  }
1772
2251
  }
1773
2252
  } catch {
@@ -1783,8 +2262,9 @@ async function cmdInterview() {
1783
2262
  const envFile = parts[1];
1784
2263
  const envVar = parts[2];
1785
2264
  try {
1786
- const envContent = fs5.readFileSync(path5.join(projectRoot, envFile), "utf-8");
1787
- const match = envContent.match(new RegExp(`^${envVar}=(.+)$`, "m"));
2265
+ const envContent = fs7.readFileSync(path7.join(projectRoot, envFile), "utf-8");
2266
+ const regex = new RegExp(`^(?:export\\s+)?${envVar}\\s*=\\s*(.+)$`, "m");
2267
+ const match = envContent.match(regex);
1788
2268
  if (match?.[1]) apiKey = match[1].trim().replace(/^["']|["']$/g, "");
1789
2269
  } catch {
1790
2270
  }
@@ -1866,6 +2346,19 @@ async function cmdInterview() {
1866
2346
  serviceStates.sort(
1867
2347
  (a, b) => riskOrder.indexOf(a.riskCategory) - riskOrder.indexOf(b.riskCategory)
1868
2348
  );
2349
+ const utilizationModel = readUtilizationModel(projectRoot);
2350
+ const utilizationSummary = {};
2351
+ for (const [serviceId, svc] of Object.entries(utilizationModel.services)) {
2352
+ if (svc.totalMonthlyUnits > 0) {
2353
+ utilizationSummary[serviceId] = {
2354
+ totalMonthlyUnits: svc.totalMonthlyUnits,
2355
+ unitName: svc.unitName,
2356
+ planIncluded: svc.planIncluded,
2357
+ projectedOverageCost: svc.projectedOverageCost,
2358
+ topCallSites: svc.callSites.sort((a, b) => b.monthlyInvocations - a.monthlyInvocations).slice(0, 5).map((cs) => `${cs.filePath} (${cs.monthlyInvocations})`)
2359
+ };
2360
+ }
2361
+ }
1869
2362
  const output = {
1870
2363
  projectName: config.projectName,
1871
2364
  serviceCount: serviceStates.length,
@@ -1874,11 +2367,13 @@ async function cmdInterview() {
1874
2367
  blindCount: serviceStates.filter((s) => s.tier === "blind").length,
1875
2368
  canGoLiveCount: serviceStates.filter((s) => s.canGoLive).length,
1876
2369
  keysFoundInEnv: serviceStates.filter((s) => s.envKeysFound.length > 0).length,
2370
+ utilization: utilizationSummary,
1877
2371
  services: serviceStates,
1878
2372
  instructions: {
1879
2373
  keyStorage: "burnwatch stores API keys in ~/.config/burnwatch/ (chmod 600) \u2014 never in the project directory. Use --key flag with configure to save keys securely.",
1880
2374
  liveTracking: "Services with hasConnector=true can do LIVE billing tracking when given an API key. Services without a connector can only do CALC (budget threshold alerts).",
1881
- configureCommand: "burnwatch configure --service <id> [--plan <name>] [--budget <N>] [--key <KEY>] [--exclude]"
2375
+ configureCommand: "burnwatch configure --service <id> [--plan <name>] [--budget <N>] [--key <KEY>] [--exclude]",
2376
+ utilizationNote: "The 'utilization' field shows code-projected usage from SDK call sites. Services with high projectedOverageCost may need plan upgrades or budget alerts."
1882
2377
  }
1883
2378
  };
1884
2379
  if (flags.has("--json")) {
@@ -2182,28 +2677,74 @@ async function cmdReconcile() {
2182
2677
  }
2183
2678
  console.log("");
2184
2679
  }
2680
+ function cmdScan() {
2681
+ const projectRoot = process.cwd();
2682
+ if (!isInitialized(projectRoot)) {
2683
+ console.error("burnwatch is not initialized. Run 'burnwatch init' first.");
2684
+ process.exit(1);
2685
+ return;
2686
+ }
2687
+ console.log("\u{1F4CA} Scanning project for utilization patterns...\n");
2688
+ const model = buildUtilizationModel(projectRoot);
2689
+ const config = readProjectConfig(projectRoot);
2690
+ for (const [serviceId, svc] of Object.entries(model.services)) {
2691
+ const tracked = config.services[serviceId];
2692
+ const definition = getService(serviceId, projectRoot);
2693
+ if (tracked?.allowance) {
2694
+ svc.planIncluded = tracked.allowance.included;
2695
+ svc.unitName = tracked.allowance.unitName;
2696
+ }
2697
+ if (definition?.pricing?.unitRate) {
2698
+ svc.unitRate = definition.pricing.unitRate;
2699
+ }
2700
+ if (tracked?.planCost !== void 0) {
2701
+ svc.planBaseCost = tracked.planCost;
2702
+ }
2703
+ svc.projectedOverage = Math.max(0, svc.totalMonthlyUnits - (svc.planIncluded ?? 0));
2704
+ svc.projectedOverageCost = svc.projectedOverage * svc.unitRate;
2705
+ svc.projectedTotalCost = svc.planBaseCost + svc.projectedOverageCost;
2706
+ }
2707
+ writeUtilizationModel(model, projectRoot);
2708
+ console.log(formatUtilizationSummary(model));
2709
+ if (flags.has("--verbose")) {
2710
+ console.log("\nDetailed call sites:\n");
2711
+ for (const svc of Object.values(model.services)) {
2712
+ if (svc.callSites.length === 0) continue;
2713
+ console.log(` ${svc.serviceName}:`);
2714
+ for (const cs of svc.callSites) {
2715
+ console.log(
2716
+ ` ${cs.filePath} \u2014 ${cs.callCount} call(s) \xD7 ${cs.multiplierFactor}x \u2192 ~${cs.monthlyInvocations} invocations/mo`
2717
+ );
2718
+ if (cs.multipliers.length > 0) {
2719
+ console.log(` Multipliers: ${cs.multipliers.join(", ")}`);
2720
+ }
2721
+ }
2722
+ console.log("");
2723
+ }
2724
+ }
2725
+ }
2185
2726
  function cmdReset() {
2186
2727
  const projectRoot = process.cwd();
2187
- const burnwatchDir = path5.join(projectRoot, ".burnwatch");
2188
- const claudeSkillsDir = path5.join(projectRoot, ".claude", "skills");
2189
- if (!fs5.existsSync(burnwatchDir)) {
2728
+ const burnwatchDir = path7.join(projectRoot, ".burnwatch");
2729
+ const claudeSkillsDir = path7.join(projectRoot, ".claude", "skills");
2730
+ if (!fs7.existsSync(burnwatchDir)) {
2190
2731
  console.log("burnwatch is not initialized in this project.");
2191
2732
  return;
2192
2733
  }
2193
- fs5.rmSync(burnwatchDir, { recursive: true, force: true });
2734
+ fs7.rmSync(burnwatchDir, { recursive: true, force: true });
2194
2735
  console.log(`\u{1F5D1}\uFE0F Removed ${burnwatchDir}`);
2195
2736
  const skillNames = ["setup-burnwatch", "burnwatch-interview", "spend"];
2196
2737
  for (const skill of skillNames) {
2197
- const skillDir = path5.join(claudeSkillsDir, skill);
2198
- if (fs5.existsSync(skillDir)) {
2199
- fs5.rmSync(skillDir, { recursive: true, force: true });
2738
+ const skillDir = path7.join(claudeSkillsDir, skill);
2739
+ if (fs7.existsSync(skillDir)) {
2740
+ fs7.rmSync(skillDir, { recursive: true, force: true });
2200
2741
  }
2201
2742
  }
2202
2743
  console.log("\u{1F5D1}\uFE0F Removed burnwatch skills from .claude/skills/");
2203
- const settingsPath = path5.join(projectRoot, ".claude", "settings.json");
2204
- if (fs5.existsSync(settingsPath)) {
2744
+ const settingsPath = path7.join(projectRoot, ".claude", "settings.json");
2745
+ if (fs7.existsSync(settingsPath)) {
2205
2746
  try {
2206
- const settings = JSON.parse(fs5.readFileSync(settingsPath, "utf-8"));
2747
+ const settings = JSON.parse(fs7.readFileSync(settingsPath, "utf-8"));
2207
2748
  const hooks = settings["hooks"];
2208
2749
  if (hooks) {
2209
2750
  for (const [event, hookList] of Object.entries(hooks)) {
@@ -2213,7 +2754,7 @@ function cmdReset() {
2213
2754
  if (hooks[event].length === 0) delete hooks[event];
2214
2755
  }
2215
2756
  if (Object.keys(hooks).length === 0) delete settings["hooks"];
2216
- fs5.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2757
+ fs7.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2217
2758
  console.log("\u{1F5D1}\uFE0F Removed burnwatch hooks from .claude/settings.json");
2218
2759
  }
2219
2760
  } catch {
@@ -2237,6 +2778,7 @@ Usage:
2237
2778
  burnwatch reconcile Scan for untracked services
2238
2779
  burnwatch interview --json Export state for agent-driven interview
2239
2780
  burnwatch configure --service <id> [opts] Agent writes back interview answers
2781
+ burnwatch scan [--verbose] Scan project for utilization patterns
2240
2782
  burnwatch reset Remove all burnwatch config from this project
2241
2783
 
2242
2784
  Options for 'configure':
@@ -2263,23 +2805,23 @@ Examples:
2263
2805
  }
2264
2806
  function cmdVersion() {
2265
2807
  try {
2266
- const pkgPath = path5.resolve(
2267
- path5.dirname(new URL(import.meta.url).pathname),
2808
+ const pkgPath = path7.resolve(
2809
+ path7.dirname(new URL(import.meta.url).pathname),
2268
2810
  "../package.json"
2269
2811
  );
2270
- const pkg = JSON.parse(fs5.readFileSync(pkgPath, "utf-8"));
2812
+ const pkg = JSON.parse(fs7.readFileSync(pkgPath, "utf-8"));
2271
2813
  console.log(`burnwatch v${pkg.version}`);
2272
2814
  } catch {
2273
2815
  console.log("burnwatch v0.1.0");
2274
2816
  }
2275
2817
  }
2276
2818
  function registerHooks(projectRoot) {
2277
- const sourceHooksDir = path5.resolve(
2278
- path5.dirname(new URL(import.meta.url).pathname),
2819
+ const sourceHooksDir = path7.resolve(
2820
+ path7.dirname(new URL(import.meta.url).pathname),
2279
2821
  "hooks"
2280
2822
  );
2281
- const localHooksDir = path5.join(projectRoot, ".burnwatch", "hooks");
2282
- fs5.mkdirSync(localHooksDir, { recursive: true });
2823
+ const localHooksDir = path7.join(projectRoot, ".burnwatch", "hooks");
2824
+ fs7.mkdirSync(localHooksDir, { recursive: true });
2283
2825
  const hookFiles = [
2284
2826
  "on-session-start.js",
2285
2827
  "on-prompt.js",
@@ -2287,45 +2829,45 @@ function registerHooks(projectRoot) {
2287
2829
  "on-stop.js"
2288
2830
  ];
2289
2831
  for (const file of hookFiles) {
2290
- const src = path5.join(sourceHooksDir, file);
2291
- const dest = path5.join(localHooksDir, file);
2832
+ const src = path7.join(sourceHooksDir, file);
2833
+ const dest = path7.join(localHooksDir, file);
2292
2834
  try {
2293
- fs5.copyFileSync(src, dest);
2835
+ fs7.copyFileSync(src, dest);
2294
2836
  const mapSrc = src + ".map";
2295
- if (fs5.existsSync(mapSrc)) {
2296
- fs5.copyFileSync(mapSrc, dest + ".map");
2837
+ if (fs7.existsSync(mapSrc)) {
2838
+ fs7.copyFileSync(mapSrc, dest + ".map");
2297
2839
  }
2298
2840
  } catch (err) {
2299
2841
  console.error(` Warning: Could not copy hook ${file}: ${err instanceof Error ? err.message : err}`);
2300
2842
  }
2301
2843
  }
2302
2844
  console.log(` Hook scripts copied to ${localHooksDir}`);
2303
- const sourceSkillsDir = path5.resolve(
2304
- path5.dirname(new URL(import.meta.url).pathname),
2845
+ const sourceSkillsDir = path7.resolve(
2846
+ path7.dirname(new URL(import.meta.url).pathname),
2305
2847
  "../skills"
2306
2848
  );
2307
2849
  const skillNames = ["setup-burnwatch", "burnwatch-interview", "spend"];
2308
- const claudeSkillsDir = path5.join(projectRoot, ".claude", "skills");
2850
+ const claudeSkillsDir = path7.join(projectRoot, ".claude", "skills");
2309
2851
  for (const skillName of skillNames) {
2310
- const srcSkill = path5.join(sourceSkillsDir, skillName, "SKILL.md");
2311
- const destDir = path5.join(claudeSkillsDir, skillName);
2312
- const destSkill = path5.join(destDir, "SKILL.md");
2852
+ const srcSkill = path7.join(sourceSkillsDir, skillName, "SKILL.md");
2853
+ const destDir = path7.join(claudeSkillsDir, skillName);
2854
+ const destSkill = path7.join(destDir, "SKILL.md");
2313
2855
  try {
2314
- if (fs5.existsSync(srcSkill)) {
2315
- fs5.mkdirSync(destDir, { recursive: true });
2316
- fs5.copyFileSync(srcSkill, destSkill);
2856
+ if (fs7.existsSync(srcSkill)) {
2857
+ fs7.mkdirSync(destDir, { recursive: true });
2858
+ fs7.copyFileSync(srcSkill, destSkill);
2317
2859
  }
2318
2860
  } catch (err) {
2319
2861
  console.error(` Warning: Could not copy skill ${skillName}: ${err instanceof Error ? err.message : err}`);
2320
2862
  }
2321
2863
  }
2322
2864
  console.log(` Skills installed to ${claudeSkillsDir}`);
2323
- const claudeDir = path5.join(projectRoot, ".claude");
2324
- const settingsPath = path5.join(claudeDir, "settings.json");
2325
- fs5.mkdirSync(claudeDir, { recursive: true });
2865
+ const claudeDir = path7.join(projectRoot, ".claude");
2866
+ const settingsPath = path7.join(claudeDir, "settings.json");
2867
+ fs7.mkdirSync(claudeDir, { recursive: true });
2326
2868
  let settings = {};
2327
2869
  try {
2328
- const existing = fs5.readFileSync(settingsPath, "utf-8");
2870
+ const existing = fs7.readFileSync(settingsPath, "utf-8");
2329
2871
  settings = JSON.parse(existing);
2330
2872
  console.log(` Merging into existing ${settingsPath}`);
2331
2873
  } catch {
@@ -2341,7 +2883,7 @@ function registerHooks(projectRoot) {
2341
2883
  hooks: [
2342
2884
  {
2343
2885
  type: "command",
2344
- command: `node "${path5.join(hooksDir, "on-session-start.js")}"`,
2886
+ command: `node "${path7.join(hooksDir, "on-session-start.js")}"`,
2345
2887
  timeout: 15
2346
2888
  }
2347
2889
  ]
@@ -2354,7 +2896,7 @@ function registerHooks(projectRoot) {
2354
2896
  hooks: [
2355
2897
  {
2356
2898
  type: "command",
2357
- command: `node "${path5.join(hooksDir, "on-prompt.js")}"`,
2899
+ command: `node "${path7.join(hooksDir, "on-prompt.js")}"`,
2358
2900
  timeout: 5
2359
2901
  }
2360
2902
  ]
@@ -2366,7 +2908,7 @@ function registerHooks(projectRoot) {
2366
2908
  hooks: [
2367
2909
  {
2368
2910
  type: "command",
2369
- command: `node "${path5.join(hooksDir, "on-file-change.js")}"`,
2911
+ command: `node "${path7.join(hooksDir, "on-file-change.js")}"`,
2370
2912
  timeout: 5
2371
2913
  }
2372
2914
  ]
@@ -2376,14 +2918,14 @@ function registerHooks(projectRoot) {
2376
2918
  hooks: [
2377
2919
  {
2378
2920
  type: "command",
2379
- command: `node "${path5.join(hooksDir, "on-stop.js")}"`,
2921
+ command: `node "${path7.join(hooksDir, "on-stop.js")}"`,
2380
2922
  timeout: 15,
2381
2923
  async: true
2382
2924
  }
2383
2925
  ]
2384
2926
  });
2385
2927
  settings["hooks"] = hooks;
2386
- fs5.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2928
+ fs7.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2387
2929
  console.log(` Hooks registered in ${settingsPath}`);
2388
2930
  }
2389
2931
  function addHookIfMissing(hookArray, _eventName, hookConfig) {