burnwatch 0.12.1 → 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 fs6 from "fs";
291
- import * as path6 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";
@@ -1622,6 +1622,439 @@ async function runInteractiveInit(detected) {
1622
1622
  return { services };
1623
1623
  }
1624
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
+
1625
2058
  // src/cli.ts
1626
2059
  var args = process.argv.slice(2);
1627
2060
  var command = args[0];
@@ -1650,6 +2083,9 @@ async function main() {
1650
2083
  case "configure":
1651
2084
  await cmdConfigure();
1652
2085
  break;
2086
+ case "scan":
2087
+ cmdScan();
2088
+ break;
1653
2089
  case "reset":
1654
2090
  cmdReset();
1655
2091
  break;
@@ -1676,10 +2112,10 @@ async function cmdInit() {
1676
2112
  const projectRoot = process.cwd();
1677
2113
  const nonInteractive = flags.has("--non-interactive") || flags.has("--ni");
1678
2114
  const alreadyInitialized = isInitialized(projectRoot);
1679
- let projectName = path6.basename(projectRoot);
2115
+ let projectName = path7.basename(projectRoot);
1680
2116
  try {
1681
- const pkgPath = path6.join(projectRoot, "package.json");
1682
- const pkg = JSON.parse(fs6.readFileSync(pkgPath, "utf-8"));
2117
+ const pkgPath = path7.join(projectRoot, "package.json");
2118
+ const pkg = JSON.parse(fs7.readFileSync(pkgPath, "utf-8"));
1683
2119
  if (pkg.name) projectName = pkg.name;
1684
2120
  } catch {
1685
2121
  }
@@ -1718,8 +2154,8 @@ async function cmdInit() {
1718
2154
  config.services = result.services;
1719
2155
  }
1720
2156
  writeProjectConfig(config, projectRoot);
1721
- const gitignorePath = path6.join(projectConfigDir(projectRoot), ".gitignore");
1722
- fs6.writeFileSync(
2157
+ const gitignorePath = path7.join(projectConfigDir(projectRoot), ".gitignore");
2158
+ fs7.writeFileSync(
1723
2159
  gitignorePath,
1724
2160
  [
1725
2161
  "# Burnwatch \u2014 ignore cache and snapshots, keep ledger and config",
@@ -1730,7 +2166,21 @@ async function cmdInit() {
1730
2166
  ].join("\n"),
1731
2167
  "utf-8"
1732
2168
  );
1733
- 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");
1734
2184
  registerHooks(projectRoot);
1735
2185
  console.log("\nburnwatch initialized.\n");
1736
2186
  if (process.stdin.isTTY) {
@@ -1749,10 +2199,10 @@ async function cmdInterview() {
1749
2199
  if (!isInitialized(projectRoot)) {
1750
2200
  ensureProjectDirs(projectRoot);
1751
2201
  const detected = detectServices(projectRoot);
1752
- let projectName = path6.basename(projectRoot);
2202
+ let projectName = path7.basename(projectRoot);
1753
2203
  try {
1754
2204
  const pkg = JSON.parse(
1755
- fs6.readFileSync(path6.join(projectRoot, "package.json"), "utf-8")
2205
+ fs7.readFileSync(path7.join(projectRoot, "package.json"), "utf-8")
1756
2206
  );
1757
2207
  if (pkg.name) projectName = pkg.name;
1758
2208
  } catch {
@@ -1787,9 +2237,9 @@ async function cmdInterview() {
1787
2237
  const envFiles = findEnvFiles(projectRoot, 3);
1788
2238
  for (const envFilePath of envFiles) {
1789
2239
  try {
1790
- const envContent = fs6.readFileSync(envFilePath, "utf-8");
2240
+ const envContent = fs7.readFileSync(envFilePath, "utf-8");
1791
2241
  const envKeys = parseEnvKeys(envContent);
1792
- const envFileName = path6.relative(projectRoot, envFilePath);
2242
+ const envFileName = path7.relative(projectRoot, envFilePath);
1793
2243
  for (const pattern of definition.envPatterns) {
1794
2244
  if (envKeys.has(pattern)) {
1795
2245
  const label = `${pattern} (in ${envFileName})`;
@@ -1812,7 +2262,7 @@ async function cmdInterview() {
1812
2262
  const envFile = parts[1];
1813
2263
  const envVar = parts[2];
1814
2264
  try {
1815
- const envContent = fs6.readFileSync(path6.join(projectRoot, envFile), "utf-8");
2265
+ const envContent = fs7.readFileSync(path7.join(projectRoot, envFile), "utf-8");
1816
2266
  const regex = new RegExp(`^(?:export\\s+)?${envVar}\\s*=\\s*(.+)$`, "m");
1817
2267
  const match = envContent.match(regex);
1818
2268
  if (match?.[1]) apiKey = match[1].trim().replace(/^["']|["']$/g, "");
@@ -1896,6 +2346,19 @@ async function cmdInterview() {
1896
2346
  serviceStates.sort(
1897
2347
  (a, b) => riskOrder.indexOf(a.riskCategory) - riskOrder.indexOf(b.riskCategory)
1898
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
+ }
1899
2362
  const output = {
1900
2363
  projectName: config.projectName,
1901
2364
  serviceCount: serviceStates.length,
@@ -1904,11 +2367,13 @@ async function cmdInterview() {
1904
2367
  blindCount: serviceStates.filter((s) => s.tier === "blind").length,
1905
2368
  canGoLiveCount: serviceStates.filter((s) => s.canGoLive).length,
1906
2369
  keysFoundInEnv: serviceStates.filter((s) => s.envKeysFound.length > 0).length,
2370
+ utilization: utilizationSummary,
1907
2371
  services: serviceStates,
1908
2372
  instructions: {
1909
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.",
1910
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).",
1911
- 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."
1912
2377
  }
1913
2378
  };
1914
2379
  if (flags.has("--json")) {
@@ -2212,28 +2677,74 @@ async function cmdReconcile() {
2212
2677
  }
2213
2678
  console.log("");
2214
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
+ }
2215
2726
  function cmdReset() {
2216
2727
  const projectRoot = process.cwd();
2217
- const burnwatchDir = path6.join(projectRoot, ".burnwatch");
2218
- const claudeSkillsDir = path6.join(projectRoot, ".claude", "skills");
2219
- if (!fs6.existsSync(burnwatchDir)) {
2728
+ const burnwatchDir = path7.join(projectRoot, ".burnwatch");
2729
+ const claudeSkillsDir = path7.join(projectRoot, ".claude", "skills");
2730
+ if (!fs7.existsSync(burnwatchDir)) {
2220
2731
  console.log("burnwatch is not initialized in this project.");
2221
2732
  return;
2222
2733
  }
2223
- fs6.rmSync(burnwatchDir, { recursive: true, force: true });
2734
+ fs7.rmSync(burnwatchDir, { recursive: true, force: true });
2224
2735
  console.log(`\u{1F5D1}\uFE0F Removed ${burnwatchDir}`);
2225
2736
  const skillNames = ["setup-burnwatch", "burnwatch-interview", "spend"];
2226
2737
  for (const skill of skillNames) {
2227
- const skillDir = path6.join(claudeSkillsDir, skill);
2228
- if (fs6.existsSync(skillDir)) {
2229
- fs6.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 });
2230
2741
  }
2231
2742
  }
2232
2743
  console.log("\u{1F5D1}\uFE0F Removed burnwatch skills from .claude/skills/");
2233
- const settingsPath = path6.join(projectRoot, ".claude", "settings.json");
2234
- if (fs6.existsSync(settingsPath)) {
2744
+ const settingsPath = path7.join(projectRoot, ".claude", "settings.json");
2745
+ if (fs7.existsSync(settingsPath)) {
2235
2746
  try {
2236
- const settings = JSON.parse(fs6.readFileSync(settingsPath, "utf-8"));
2747
+ const settings = JSON.parse(fs7.readFileSync(settingsPath, "utf-8"));
2237
2748
  const hooks = settings["hooks"];
2238
2749
  if (hooks) {
2239
2750
  for (const [event, hookList] of Object.entries(hooks)) {
@@ -2243,7 +2754,7 @@ function cmdReset() {
2243
2754
  if (hooks[event].length === 0) delete hooks[event];
2244
2755
  }
2245
2756
  if (Object.keys(hooks).length === 0) delete settings["hooks"];
2246
- fs6.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2757
+ fs7.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2247
2758
  console.log("\u{1F5D1}\uFE0F Removed burnwatch hooks from .claude/settings.json");
2248
2759
  }
2249
2760
  } catch {
@@ -2267,6 +2778,7 @@ Usage:
2267
2778
  burnwatch reconcile Scan for untracked services
2268
2779
  burnwatch interview --json Export state for agent-driven interview
2269
2780
  burnwatch configure --service <id> [opts] Agent writes back interview answers
2781
+ burnwatch scan [--verbose] Scan project for utilization patterns
2270
2782
  burnwatch reset Remove all burnwatch config from this project
2271
2783
 
2272
2784
  Options for 'configure':
@@ -2293,23 +2805,23 @@ Examples:
2293
2805
  }
2294
2806
  function cmdVersion() {
2295
2807
  try {
2296
- const pkgPath = path6.resolve(
2297
- path6.dirname(new URL(import.meta.url).pathname),
2808
+ const pkgPath = path7.resolve(
2809
+ path7.dirname(new URL(import.meta.url).pathname),
2298
2810
  "../package.json"
2299
2811
  );
2300
- const pkg = JSON.parse(fs6.readFileSync(pkgPath, "utf-8"));
2812
+ const pkg = JSON.parse(fs7.readFileSync(pkgPath, "utf-8"));
2301
2813
  console.log(`burnwatch v${pkg.version}`);
2302
2814
  } catch {
2303
2815
  console.log("burnwatch v0.1.0");
2304
2816
  }
2305
2817
  }
2306
2818
  function registerHooks(projectRoot) {
2307
- const sourceHooksDir = path6.resolve(
2308
- path6.dirname(new URL(import.meta.url).pathname),
2819
+ const sourceHooksDir = path7.resolve(
2820
+ path7.dirname(new URL(import.meta.url).pathname),
2309
2821
  "hooks"
2310
2822
  );
2311
- const localHooksDir = path6.join(projectRoot, ".burnwatch", "hooks");
2312
- fs6.mkdirSync(localHooksDir, { recursive: true });
2823
+ const localHooksDir = path7.join(projectRoot, ".burnwatch", "hooks");
2824
+ fs7.mkdirSync(localHooksDir, { recursive: true });
2313
2825
  const hookFiles = [
2314
2826
  "on-session-start.js",
2315
2827
  "on-prompt.js",
@@ -2317,45 +2829,45 @@ function registerHooks(projectRoot) {
2317
2829
  "on-stop.js"
2318
2830
  ];
2319
2831
  for (const file of hookFiles) {
2320
- const src = path6.join(sourceHooksDir, file);
2321
- const dest = path6.join(localHooksDir, file);
2832
+ const src = path7.join(sourceHooksDir, file);
2833
+ const dest = path7.join(localHooksDir, file);
2322
2834
  try {
2323
- fs6.copyFileSync(src, dest);
2835
+ fs7.copyFileSync(src, dest);
2324
2836
  const mapSrc = src + ".map";
2325
- if (fs6.existsSync(mapSrc)) {
2326
- fs6.copyFileSync(mapSrc, dest + ".map");
2837
+ if (fs7.existsSync(mapSrc)) {
2838
+ fs7.copyFileSync(mapSrc, dest + ".map");
2327
2839
  }
2328
2840
  } catch (err) {
2329
2841
  console.error(` Warning: Could not copy hook ${file}: ${err instanceof Error ? err.message : err}`);
2330
2842
  }
2331
2843
  }
2332
2844
  console.log(` Hook scripts copied to ${localHooksDir}`);
2333
- const sourceSkillsDir = path6.resolve(
2334
- path6.dirname(new URL(import.meta.url).pathname),
2845
+ const sourceSkillsDir = path7.resolve(
2846
+ path7.dirname(new URL(import.meta.url).pathname),
2335
2847
  "../skills"
2336
2848
  );
2337
2849
  const skillNames = ["setup-burnwatch", "burnwatch-interview", "spend"];
2338
- const claudeSkillsDir = path6.join(projectRoot, ".claude", "skills");
2850
+ const claudeSkillsDir = path7.join(projectRoot, ".claude", "skills");
2339
2851
  for (const skillName of skillNames) {
2340
- const srcSkill = path6.join(sourceSkillsDir, skillName, "SKILL.md");
2341
- const destDir = path6.join(claudeSkillsDir, skillName);
2342
- const destSkill = path6.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");
2343
2855
  try {
2344
- if (fs6.existsSync(srcSkill)) {
2345
- fs6.mkdirSync(destDir, { recursive: true });
2346
- fs6.copyFileSync(srcSkill, destSkill);
2856
+ if (fs7.existsSync(srcSkill)) {
2857
+ fs7.mkdirSync(destDir, { recursive: true });
2858
+ fs7.copyFileSync(srcSkill, destSkill);
2347
2859
  }
2348
2860
  } catch (err) {
2349
2861
  console.error(` Warning: Could not copy skill ${skillName}: ${err instanceof Error ? err.message : err}`);
2350
2862
  }
2351
2863
  }
2352
2864
  console.log(` Skills installed to ${claudeSkillsDir}`);
2353
- const claudeDir = path6.join(projectRoot, ".claude");
2354
- const settingsPath = path6.join(claudeDir, "settings.json");
2355
- fs6.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 });
2356
2868
  let settings = {};
2357
2869
  try {
2358
- const existing = fs6.readFileSync(settingsPath, "utf-8");
2870
+ const existing = fs7.readFileSync(settingsPath, "utf-8");
2359
2871
  settings = JSON.parse(existing);
2360
2872
  console.log(` Merging into existing ${settingsPath}`);
2361
2873
  } catch {
@@ -2371,7 +2883,7 @@ function registerHooks(projectRoot) {
2371
2883
  hooks: [
2372
2884
  {
2373
2885
  type: "command",
2374
- command: `node "${path6.join(hooksDir, "on-session-start.js")}"`,
2886
+ command: `node "${path7.join(hooksDir, "on-session-start.js")}"`,
2375
2887
  timeout: 15
2376
2888
  }
2377
2889
  ]
@@ -2384,7 +2896,7 @@ function registerHooks(projectRoot) {
2384
2896
  hooks: [
2385
2897
  {
2386
2898
  type: "command",
2387
- command: `node "${path6.join(hooksDir, "on-prompt.js")}"`,
2899
+ command: `node "${path7.join(hooksDir, "on-prompt.js")}"`,
2388
2900
  timeout: 5
2389
2901
  }
2390
2902
  ]
@@ -2396,7 +2908,7 @@ function registerHooks(projectRoot) {
2396
2908
  hooks: [
2397
2909
  {
2398
2910
  type: "command",
2399
- command: `node "${path6.join(hooksDir, "on-file-change.js")}"`,
2911
+ command: `node "${path7.join(hooksDir, "on-file-change.js")}"`,
2400
2912
  timeout: 5
2401
2913
  }
2402
2914
  ]
@@ -2406,14 +2918,14 @@ function registerHooks(projectRoot) {
2406
2918
  hooks: [
2407
2919
  {
2408
2920
  type: "command",
2409
- command: `node "${path6.join(hooksDir, "on-stop.js")}"`,
2921
+ command: `node "${path7.join(hooksDir, "on-stop.js")}"`,
2410
2922
  timeout: 15,
2411
2923
  async: true
2412
2924
  }
2413
2925
  ]
2414
2926
  });
2415
2927
  settings["hooks"] = hooks;
2416
- fs6.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2928
+ fs7.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2417
2929
  console.log(` Hooks registered in ${settingsPath}`);
2418
2930
  }
2419
2931
  function addHookIfMissing(hookArray, _eventName, hookConfig) {