burnwatch 0.10.0 → 0.11.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
@@ -925,9 +925,37 @@ async function pollService(tracked) {
925
925
  serviceConfig
926
926
  );
927
927
  if (!result.error) return result;
928
- } catch {
928
+ return {
929
+ serviceId: tracked.serviceId,
930
+ spend: tracked.planCost ?? 0,
931
+ isEstimate: true,
932
+ tier: tracked.planCost !== void 0 ? "calc" : "blind",
933
+ error: `LIVE failed (${result.error}) \u2014 showing ${tracked.planCost !== void 0 ? "CALC" : "BLIND"} fallback`
934
+ };
935
+ } catch (err) {
936
+ return {
937
+ serviceId: tracked.serviceId,
938
+ spend: tracked.planCost ?? 0,
939
+ isEstimate: true,
940
+ tier: tracked.planCost !== void 0 ? "calc" : "blind",
941
+ error: `LIVE failed (${err instanceof Error ? err.message : "unknown"}) \u2014 showing ${tracked.planCost !== void 0 ? "CALC" : "BLIND"} fallback`
942
+ };
929
943
  }
930
944
  }
945
+ if (connector && tracked.hasApiKey && !serviceConfig?.apiKey) {
946
+ const projectedSpend = tracked.planCost !== void 0 ? (() => {
947
+ const now = /* @__PURE__ */ new Date();
948
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
949
+ return tracked.planCost / daysInMonth * now.getDate();
950
+ })() : 0;
951
+ return {
952
+ serviceId: tracked.serviceId,
953
+ spend: projectedSpend,
954
+ isEstimate: true,
955
+ tier: tracked.planCost !== void 0 ? "calc" : "blind",
956
+ error: "API key marked as configured but not found in ~/.config/burnwatch/ \u2014 re-run configure with --key"
957
+ };
958
+ }
931
959
  if (tracked.planCost !== void 0) {
932
960
  const now = /* @__PURE__ */ new Date();
933
961
  const daysInMonth = new Date(
@@ -999,7 +1027,7 @@ function formatBrief(brief) {
999
1027
  formatRow("Service", "Spend", "Conf", "Budget", "Left", width)
1000
1028
  );
1001
1029
  lines.push(`\u2551 ${hrSingle} \u2551`);
1002
- for (const svc of brief.services) {
1030
+ for (const svc of brief.services.filter((s) => s.tier !== "excluded")) {
1003
1031
  const spendStr = svc.tier === "blind" && svc.spend === 0 ? "\u2014" : svc.isEstimate ? `~$${svc.spend.toFixed(2)}` : `$${svc.spend.toFixed(2)}`;
1004
1032
  const badge = CONFIDENCE_BADGES[svc.tier];
1005
1033
  const budgetStr = svc.budget ? `$${svc.budget}` : "\u2014";
@@ -1568,6 +1596,9 @@ async function main() {
1568
1596
  case "configure":
1569
1597
  await cmdConfigure();
1570
1598
  break;
1599
+ case "reset":
1600
+ cmdReset();
1601
+ break;
1571
1602
  case "help":
1572
1603
  case "--help":
1573
1604
  case "-h":
@@ -1685,24 +1716,57 @@ async function cmdInterview() {
1685
1716
  const config = readProjectConfig(projectRoot);
1686
1717
  const globalConfig = readGlobalConfig();
1687
1718
  const allRegistryServices = getAllServices(projectRoot);
1719
+ const connectorServices = ["anthropic", "openai", "vercel", "scrapfly", "supabase", "browserbase"];
1688
1720
  const serviceStates = [];
1689
1721
  for (const [serviceId, tracked] of Object.entries(config.services)) {
1690
1722
  const definition = allRegistryServices.find((s) => s.id === serviceId);
1691
1723
  if (!definition) continue;
1692
1724
  let keySource = null;
1725
+ const envKeysFound = [];
1693
1726
  const globalKey = globalConfig.services[serviceId]?.apiKey;
1694
- if (globalKey) keySource = "global_config";
1695
- else {
1696
- for (const pattern of definition.envPatterns) {
1697
- if (process.env[pattern]) {
1698
- keySource = `env:${pattern}`;
1699
- break;
1727
+ for (const pattern of definition.envPatterns) {
1728
+ if (process.env[pattern]) {
1729
+ envKeysFound.push(pattern);
1730
+ if (!keySource && !globalKey) keySource = `env:${pattern}`;
1731
+ }
1732
+ }
1733
+ const envFiles = [".env", ".env.local", ".env.development"];
1734
+ for (const envFile of envFiles) {
1735
+ try {
1736
+ const envPath = path5.join(projectRoot, envFile);
1737
+ const envContent = fs5.readFileSync(envPath, "utf-8");
1738
+ for (const pattern of definition.envPatterns) {
1739
+ const regex = new RegExp(`^${pattern}=(.+)$`, "m");
1740
+ const match = envContent.match(regex);
1741
+ if (match?.[1]) {
1742
+ const label = `${pattern} (in ${envFile})`;
1743
+ if (!envKeysFound.some((k) => k.startsWith(pattern))) {
1744
+ envKeysFound.push(label);
1745
+ }
1746
+ if (!keySource && !globalKey) keySource = `file:${envFile}:${pattern}`;
1747
+ }
1700
1748
  }
1749
+ } catch {
1750
+ }
1751
+ }
1752
+ if (globalKey) keySource = "global_config";
1753
+ let apiKey = globalKey;
1754
+ if (!apiKey && keySource?.startsWith("env:")) {
1755
+ apiKey = process.env[keySource.slice(4)];
1756
+ }
1757
+ if (!apiKey && keySource?.startsWith("file:")) {
1758
+ const parts = keySource.split(":");
1759
+ const envFile = parts[1];
1760
+ const envVar = parts[2];
1761
+ try {
1762
+ const envContent = fs5.readFileSync(path5.join(projectRoot, envFile), "utf-8");
1763
+ const match = envContent.match(new RegExp(`^${envVar}=(.+)$`, "m"));
1764
+ if (match?.[1]) apiKey = match[1].trim().replace(/^["']|["']$/g, "");
1765
+ } catch {
1701
1766
  }
1702
1767
  }
1703
1768
  let probeResult = null;
1704
1769
  const { probeService: probe, hasProbe: checkProbe } = await Promise.resolve().then(() => (init_probes(), probes_exports));
1705
- const apiKey = globalKey ?? (keySource?.startsWith("env:") ? process.env[keySource.slice(4)] : void 0);
1706
1770
  if (apiKey && checkProbe(serviceId)) {
1707
1771
  probeResult = await probe(serviceId, apiKey, definition.plans ?? []);
1708
1772
  }
@@ -1725,6 +1789,24 @@ async function cmdInterview() {
1725
1789
  upstash: "email:api_key from console.upstash.com \u2192 Account \u2192 Management API",
1726
1790
  posthog: "Personal API key from posthog.com \u2192 Settings \u2192 Personal API Keys"
1727
1791
  };
1792
+ const hasConnector = connectorServices.includes(serviceId);
1793
+ const canGoLive = hasConnector && !tracked.hasApiKey && !apiKey;
1794
+ let suggestedAction;
1795
+ if (tracked.excluded) {
1796
+ suggestedAction = "excluded \u2014 skip";
1797
+ } else if (probeResult && probeResult.confidence === "high") {
1798
+ suggestedAction = `confirm \u2014 probe detected ${probeResult.summary}`;
1799
+ } else if (apiKey && hasConnector) {
1800
+ suggestedAction = "configure with found key \u2014 LIVE tracking available";
1801
+ } else if (apiKey && !hasConnector) {
1802
+ suggestedAction = "key found but no billing connector \u2014 set plan cost for CALC tracking";
1803
+ } else if (canGoLive) {
1804
+ suggestedAction = `ask for API key \u2014 LIVE tracking possible (${keyHints[serviceId] ?? "check service dashboard"})`;
1805
+ } else if (!hasConnector) {
1806
+ suggestedAction = "no billing API \u2014 ask for plan tier and set budget as alert threshold";
1807
+ } else {
1808
+ suggestedAction = "ask user for plan details";
1809
+ }
1728
1810
  serviceStates.push({
1729
1811
  serviceId,
1730
1812
  serviceName: definition.name,
@@ -1735,6 +1817,10 @@ async function cmdInterview() {
1735
1817
  tier,
1736
1818
  excluded: tracked.excluded ?? false,
1737
1819
  hasProbe: checkProbe(serviceId),
1820
+ hasConnector,
1821
+ canGoLive,
1822
+ envKeysFound,
1823
+ suggestedAction,
1738
1824
  probeResult,
1739
1825
  availablePlans: (definition.plans ?? []).map((p, i) => ({
1740
1826
  index: i + 1,
@@ -1762,8 +1848,14 @@ async function cmdInterview() {
1762
1848
  totalBudget: serviceStates.reduce((sum, s) => sum + (s.currentBudget ?? 0), 0),
1763
1849
  liveCount: serviceStates.filter((s) => s.tier === "live").length,
1764
1850
  blindCount: serviceStates.filter((s) => s.tier === "blind").length,
1851
+ canGoLiveCount: serviceStates.filter((s) => s.canGoLive).length,
1852
+ keysFoundInEnv: serviceStates.filter((s) => s.envKeysFound.length > 0).length,
1765
1853
  services: serviceStates,
1766
- configureCommand: "burnwatch configure --service <id> [--plan <name>] [--budget <N>] [--key <KEY>] [--exclude]"
1854
+ instructions: {
1855
+ 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.",
1856
+ 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).",
1857
+ configureCommand: "burnwatch configure --service <id> [--plan <name>] [--budget <N>] [--key <KEY>] [--exclude]"
1858
+ }
1767
1859
  };
1768
1860
  if (flags.has("--json")) {
1769
1861
  console.log(JSON.stringify(output, null, 2));
@@ -2066,6 +2158,47 @@ async function cmdReconcile() {
2066
2158
  }
2067
2159
  console.log("");
2068
2160
  }
2161
+ function cmdReset() {
2162
+ const projectRoot = process.cwd();
2163
+ const burnwatchDir = path5.join(projectRoot, ".burnwatch");
2164
+ const claudeSkillsDir = path5.join(projectRoot, ".claude", "skills");
2165
+ if (!fs5.existsSync(burnwatchDir)) {
2166
+ console.log("burnwatch is not initialized in this project.");
2167
+ return;
2168
+ }
2169
+ fs5.rmSync(burnwatchDir, { recursive: true, force: true });
2170
+ console.log(`\u{1F5D1}\uFE0F Removed ${burnwatchDir}`);
2171
+ const skillNames = ["setup-burnwatch", "burnwatch-interview", "spend"];
2172
+ for (const skill of skillNames) {
2173
+ const skillDir = path5.join(claudeSkillsDir, skill);
2174
+ if (fs5.existsSync(skillDir)) {
2175
+ fs5.rmSync(skillDir, { recursive: true, force: true });
2176
+ }
2177
+ }
2178
+ console.log("\u{1F5D1}\uFE0F Removed burnwatch skills from .claude/skills/");
2179
+ const settingsPath = path5.join(projectRoot, ".claude", "settings.json");
2180
+ if (fs5.existsSync(settingsPath)) {
2181
+ try {
2182
+ const settings = JSON.parse(fs5.readFileSync(settingsPath, "utf-8"));
2183
+ const hooks = settings["hooks"];
2184
+ if (hooks) {
2185
+ for (const [event, hookList] of Object.entries(hooks)) {
2186
+ hooks[event] = hookList.filter(
2187
+ (h) => !h.hooks?.some((inner) => inner.command?.includes("burnwatch"))
2188
+ );
2189
+ if (hooks[event].length === 0) delete hooks[event];
2190
+ }
2191
+ if (Object.keys(hooks).length === 0) delete settings["hooks"];
2192
+ fs5.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2193
+ console.log("\u{1F5D1}\uFE0F Removed burnwatch hooks from .claude/settings.json");
2194
+ }
2195
+ } catch {
2196
+ console.log("\u26A0\uFE0F Could not clean .claude/settings.json \u2014 remove burnwatch hooks manually");
2197
+ }
2198
+ }
2199
+ console.log("\n\u2705 burnwatch fully reset. Global API keys in ~/.config/burnwatch/ were preserved.");
2200
+ console.log(" Run 'burnwatch init' to set up again.\n");
2201
+ }
2069
2202
  function cmdHelp() {
2070
2203
  console.log(`
2071
2204
  burnwatch \u2014 Passive cost memory for AI-assisted development
@@ -2080,6 +2213,7 @@ Usage:
2080
2213
  burnwatch reconcile Scan for untracked services
2081
2214
  burnwatch interview --json Export state for agent-driven interview
2082
2215
  burnwatch configure --service <id> [opts] Agent writes back interview answers
2216
+ burnwatch reset Remove all burnwatch config from this project
2083
2217
 
2084
2218
  Options for 'configure':
2085
2219
  --service <ID> Service to configure (required)