burnwatch 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to burnwatch will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-03-24
9
+
10
+ ### Added
11
+
12
+ - **Interactive init with plan tiers**: `burnwatch init` now walks through each detected service interactively, grouped by cost risk (LLMs first, then usage-based, infra, flat-rate). Users pick from known plan tiers per service (e.g., Anthropic API Usage, Max $100/mo, Pro $20/mo, or "Don't track").
13
+ - **Plan tiers for all 14 services**: Registry now includes plan options for Anthropic, OpenAI, Google Gemini, Voyage AI, Vercel, Supabase, Stripe, Scrapfly, Browserbase, Upstash, Resend, Inngest, PostHog, and AWS.
14
+ - **Smart defaults**: Each service has a recommended default plan. Flat plans auto-set the budget to the plan cost. API Usage plans prompt for keys and budgets.
15
+ - **Exclude option**: "Don't track for this project" explicitly excludes a service (shows as "excluded", not BLIND).
16
+ - **Auto-detect plan**: Scrapfly plan can be auto-detected from API key via the /account endpoint.
17
+ - **Non-interactive fallback**: `burnwatch init --non-interactive` preserves the original auto-detect behavior for CI/scripted use.
18
+ - **Predictive cost impact analysis**: PostToolUse hook now analyzes file writes for SDK call sites, detects multipliers (loops, .map(), Promise.all, cron schedules, batch sizes), and projects monthly cost ranges using registry pricing data and gotcha-based multipliers.
19
+ - **Cost impact cards**: When a file write contains tracked service SDK calls, a cost impact card is injected into Claude's context with estimated monthly cost, current budget status, and cheaper alternatives.
20
+ - **Cumulative session cost tracking**: Session cost impacts are accumulated across file changes and reported in the Stop hook.
21
+ - **Projected impact in ledger**: The spend ledger now includes a "projected impact" row showing session cost estimates.
22
+ - **New `excluded` confidence tier**: Services explicitly excluded by the user show ⬚ SKIP instead of 🔴 BLIND.
23
+
24
+ ### Changed
25
+
26
+ - Registry version bumped to 0.2.0 with plan tier data.
27
+ - CLI now parses `--non-interactive` and `--ni` flags.
28
+ - PostToolUse hook expanded from detection-only to detection + cost impact analysis.
29
+ - Stop hook now reads and reports cumulative session cost impacts.
30
+
8
31
  ## [0.1.0] - 2026-03-24
9
32
 
10
33
  ### Added
@@ -20,4 +43,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
20
43
  - Snapshot system for delta computation across sessions
21
44
  - Claude Code skills: `/spend` (on-demand brief), `/setup-burnwatch` (guided onboarding)
22
45
 
46
+ [0.4.0]: https://github.com/RaleighSF/burnwatch/compare/v0.1.0...v0.4.0
23
47
  [0.1.0]: https://github.com/RaleighSF/burnwatch/releases/tag/v0.1.0
package/dist/cli.js CHANGED
@@ -579,7 +579,8 @@ var CONFIDENCE_BADGES = {
579
579
  live: "\u2705 LIVE",
580
580
  calc: "\u{1F7E1} CALC",
581
581
  est: "\u{1F7E0} EST",
582
- blind: "\u{1F534} BLIND"
582
+ blind: "\u{1F534} BLIND",
583
+ excluded: "\u2B1A SKIP"
583
584
  };
584
585
 
585
586
  // src/core/brief.ts
@@ -744,6 +745,14 @@ function writeLedger(brief, projectRoot) {
744
745
  `| ${svc.serviceId} | ${spendStr} | ${badge} | ${budgetStr} | ${svc.statusLabel} |`
745
746
  );
746
747
  }
748
+ const impactAlert = brief.alerts.find(
749
+ (a) => a.serviceId === "_session_impact"
750
+ );
751
+ if (impactAlert) {
752
+ lines.push(
753
+ `| _projected impact_ | \u2014 | \u{1F4C8} EST | \u2014 | ${impactAlert.message} |`
754
+ );
755
+ }
747
756
  lines.push("");
748
757
  const totalStr = brief.totalIsEstimate ? `~$${brief.totalSpend.toFixed(2)}` : `$${brief.totalSpend.toFixed(2)}`;
749
758
  const marginStr = brief.estimateMargin > 0 ? ` (\xB1$${brief.estimateMargin.toFixed(0)} estimated margin)` : "";
@@ -776,9 +785,185 @@ function saveSnapshot(brief, projectRoot) {
776
785
  );
777
786
  }
778
787
 
788
+ // src/interactive-init.ts
789
+ import * as readline from "readline";
790
+ var RISK_ORDER = ["llm", "usage", "infra", "flat"];
791
+ var RISK_LABELS = {
792
+ llm: "\u{1F916} LLM / AI Services (highest variable cost)",
793
+ usage: "\u{1F4CA} Usage-Based Services",
794
+ infra: "\u{1F3D7}\uFE0F Infrastructure & Compute",
795
+ flat: "\u{1F4E6} Flat-Rate / Free Tier Services"
796
+ };
797
+ function classifyRisk(service) {
798
+ if (service.billingModel === "token_usage") return "llm";
799
+ if (service.billingModel === "credit_pool" || service.billingModel === "percentage" || service.billingModel === "per_unit")
800
+ return "usage";
801
+ if (service.billingModel === "compute") return "infra";
802
+ return "flat";
803
+ }
804
+ function groupByRisk(detected) {
805
+ const groups = /* @__PURE__ */ new Map();
806
+ for (const cat of RISK_ORDER) {
807
+ groups.set(cat, []);
808
+ }
809
+ for (const det of detected) {
810
+ const cat = classifyRisk(det.service);
811
+ groups.get(cat).push(det);
812
+ }
813
+ return groups;
814
+ }
815
+ function ask(rl, question) {
816
+ return new Promise((resolve3) => {
817
+ rl.question(question, (answer) => {
818
+ resolve3(answer.trim());
819
+ });
820
+ });
821
+ }
822
+ async function autoDetectScrapflyPlan(apiKey) {
823
+ try {
824
+ const result = await fetchJson(`https://api.scrapfly.io/account?key=${apiKey}`);
825
+ if (result.ok && result.data?.subscription?.plan?.name) {
826
+ return result.data.subscription.plan.name;
827
+ }
828
+ } catch {
829
+ }
830
+ return null;
831
+ }
832
+ async function runInteractiveInit(detected) {
833
+ const rl = readline.createInterface({
834
+ input: process.stdin,
835
+ output: process.stdout
836
+ });
837
+ const services = {};
838
+ const groups = groupByRisk(detected);
839
+ const globalConfig = readGlobalConfig();
840
+ console.log(
841
+ "\n\u{1F4CB} Let's configure each detected service. Services are grouped by cost risk.\n"
842
+ );
843
+ for (const category of RISK_ORDER) {
844
+ const group = groups.get(category);
845
+ if (group.length === 0) continue;
846
+ console.log(`
847
+ ${RISK_LABELS[category]}`);
848
+ console.log("\u2500".repeat(50));
849
+ for (const det of group) {
850
+ const service = det.service;
851
+ const plans = service.plans;
852
+ console.log(`
853
+ ${service.name}`);
854
+ console.log(` Detected via: ${det.details.join(", ")}`);
855
+ if (!plans || plans.length === 0) {
856
+ services[service.id] = {
857
+ serviceId: service.id,
858
+ detectedVia: det.sources,
859
+ hasApiKey: false,
860
+ firstDetected: (/* @__PURE__ */ new Date()).toISOString()
861
+ };
862
+ console.log(" \u2192 Auto-configured (no plan tiers available)");
863
+ continue;
864
+ }
865
+ const defaultIndex = plans.findIndex((p) => p.default);
866
+ console.log("");
867
+ for (let i = 0; i < plans.length; i++) {
868
+ const plan = plans[i];
869
+ const marker = i === defaultIndex ? " (recommended)" : "";
870
+ const costStr = plan.type === "exclude" ? "" : plan.monthlyBase !== void 0 ? ` \u2014 $${plan.monthlyBase}/mo` : " \u2014 variable";
871
+ console.log(` ${i + 1}) ${plan.name}${costStr}${marker}`);
872
+ }
873
+ const defaultChoice = defaultIndex >= 0 ? String(defaultIndex + 1) : "1";
874
+ const answer = await ask(
875
+ rl,
876
+ ` Choose [${defaultChoice}]: `
877
+ );
878
+ const choiceIndex = (answer === "" ? parseInt(defaultChoice) : parseInt(answer)) - 1;
879
+ const chosen = plans[choiceIndex] ?? plans[defaultIndex >= 0 ? defaultIndex : 0];
880
+ if (chosen.type === "exclude") {
881
+ services[service.id] = {
882
+ serviceId: service.id,
883
+ detectedVia: det.sources,
884
+ hasApiKey: false,
885
+ firstDetected: (/* @__PURE__ */ new Date()).toISOString(),
886
+ excluded: true,
887
+ planName: chosen.name
888
+ };
889
+ console.log(` \u2192 ${service.name}: excluded from tracking`);
890
+ continue;
891
+ }
892
+ const tracked = {
893
+ serviceId: service.id,
894
+ detectedVia: det.sources,
895
+ hasApiKey: false,
896
+ firstDetected: (/* @__PURE__ */ new Date()).toISOString(),
897
+ planName: chosen.name
898
+ };
899
+ if (chosen.type === "flat" && chosen.monthlyBase !== void 0) {
900
+ tracked.budget = chosen.monthlyBase;
901
+ tracked.planCost = chosen.monthlyBase;
902
+ }
903
+ if (chosen.requiresKey) {
904
+ const existingKey = globalConfig.services[service.id]?.apiKey;
905
+ if (existingKey) {
906
+ console.log(` \u{1F510} Using existing API key from global config`);
907
+ tracked.hasApiKey = true;
908
+ if (service.autoDetectPlan && service.id === "scrapfly") {
909
+ console.log(" \u{1F50D} Auto-detecting plan from API...");
910
+ const planName = await autoDetectScrapflyPlan(existingKey);
911
+ if (planName) {
912
+ console.log(` \u2192 Detected plan: ${planName}`);
913
+ tracked.planName = planName;
914
+ }
915
+ }
916
+ } else {
917
+ const keyAnswer = await ask(
918
+ rl,
919
+ ` Enter API key (or press Enter to skip): `
920
+ );
921
+ if (keyAnswer) {
922
+ tracked.hasApiKey = true;
923
+ if (!globalConfig.services[service.id]) {
924
+ globalConfig.services[service.id] = {};
925
+ }
926
+ globalConfig.services[service.id].apiKey = keyAnswer;
927
+ if (service.autoDetectPlan && service.id === "scrapfly") {
928
+ console.log(" \u{1F50D} Auto-detecting plan from API...");
929
+ const planName = await autoDetectScrapflyPlan(keyAnswer);
930
+ if (planName) {
931
+ console.log(` \u2192 Detected plan: ${planName}`);
932
+ tracked.planName = planName;
933
+ }
934
+ }
935
+ }
936
+ }
937
+ if (tracked.budget === void 0) {
938
+ const budgetAnswer = await ask(
939
+ rl,
940
+ ` Monthly budget in USD (or press Enter to skip): $`
941
+ );
942
+ if (budgetAnswer) {
943
+ const budget = parseFloat(budgetAnswer);
944
+ if (!isNaN(budget)) {
945
+ tracked.budget = budget;
946
+ }
947
+ }
948
+ }
949
+ }
950
+ services[service.id] = tracked;
951
+ const tierLabel = tracked.hasApiKey ? "\u2705 LIVE" : tracked.planCost !== void 0 ? "\u{1F7E1} CALC" : "\u{1F534} BLIND";
952
+ const budgetStr = tracked.budget !== void 0 ? ` | Budget: $${tracked.budget}/mo` : "";
953
+ console.log(
954
+ ` \u2192 ${service.name}: ${chosen.name} (${tierLabel}${budgetStr})`
955
+ );
956
+ }
957
+ }
958
+ writeGlobalConfig(globalConfig);
959
+ rl.close();
960
+ return { services };
961
+ }
962
+
779
963
  // src/cli.ts
780
964
  var args = process.argv.slice(2);
781
965
  var command = args[0];
966
+ var flags = new Set(args.slice(1));
782
967
  async function main() {
783
968
  switch (command) {
784
969
  case "init":
@@ -818,6 +1003,7 @@ async function main() {
818
1003
  }
819
1004
  async function cmdInit() {
820
1005
  const projectRoot = process.cwd();
1006
+ const nonInteractive = flags.has("--non-interactive") || flags.has("--ni");
821
1007
  if (isInitialized(projectRoot)) {
822
1008
  console.log("\u2705 burnwatch is already initialized in this project.");
823
1009
  console.log(` Config: ${projectConfigDir(projectRoot)}/config.json`);
@@ -839,14 +1025,32 @@ async function cmdInit() {
839
1025
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
840
1026
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
841
1027
  };
842
- for (const det of detected) {
843
- const tracked = {
844
- serviceId: det.service.id,
845
- detectedVia: det.sources,
846
- hasApiKey: false,
847
- firstDetected: (/* @__PURE__ */ new Date()).toISOString()
848
- };
849
- config.services[det.service.id] = tracked;
1028
+ if (!nonInteractive && detected.length > 0 && process.stdin.isTTY) {
1029
+ const result = await runInteractiveInit(detected);
1030
+ config.services = result.services;
1031
+ } else {
1032
+ for (const det of detected) {
1033
+ const tracked2 = {
1034
+ serviceId: det.service.id,
1035
+ detectedVia: det.sources,
1036
+ hasApiKey: false,
1037
+ firstDetected: (/* @__PURE__ */ new Date()).toISOString()
1038
+ };
1039
+ config.services[det.service.id] = tracked2;
1040
+ }
1041
+ if (detected.length === 0) {
1042
+ console.log(" No paid services detected yet.");
1043
+ console.log(" Services will be detected as they enter your project.\n");
1044
+ } else {
1045
+ console.log(` Found ${detected.length} paid service${detected.length > 1 ? "s" : ""}:
1046
+ `);
1047
+ for (const det of detected) {
1048
+ const tierBadge = det.service.apiTier === "live" ? "\u2705 LIVE API available" : det.service.apiTier === "calc" ? "\u{1F7E1} Flat-rate tracking" : det.service.apiTier === "est" ? "\u{1F7E0} Estimate tracking" : "\u{1F534} Detection only";
1049
+ console.log(` \u2022 ${det.service.name} (${tierBadge})`);
1050
+ console.log(` Detected via: ${det.details.join(", ")}`);
1051
+ }
1052
+ console.log("");
1053
+ }
850
1054
  }
851
1055
  writeProjectConfig(config, projectRoot);
852
1056
  const gitignorePath = path5.join(projectConfigDir(projectRoot), ".gitignore");
@@ -861,29 +1065,29 @@ async function cmdInit() {
861
1065
  ].join("\n"),
862
1066
  "utf-8"
863
1067
  );
864
- if (detected.length === 0) {
865
- console.log(" No paid services detected yet.");
866
- console.log(" Services will be detected as they enter your project.\n");
867
- } else {
868
- console.log(` Found ${detected.length} paid service${detected.length > 1 ? "s" : ""}:
869
- `);
870
- for (const det of detected) {
871
- const tierBadge = det.service.apiTier === "live" ? "\u2705 LIVE API available" : det.service.apiTier === "calc" ? "\u{1F7E1} Flat-rate tracking" : det.service.apiTier === "est" ? "\u{1F7E0} Estimate tracking" : "\u{1F534} Detection only";
872
- console.log(` \u2022 ${det.service.name} (${tierBadge})`);
873
- console.log(` Detected via: ${det.details.join(", ")}`);
874
- }
875
- console.log("");
876
- }
877
- console.log("\u{1F517} Registering Claude Code hooks...\n");
1068
+ console.log("\n\u{1F517} Registering Claude Code hooks...\n");
878
1069
  registerHooks(projectRoot);
1070
+ const excluded = Object.values(config.services).filter((s) => s.excluded);
1071
+ const tracked = Object.values(config.services).filter((s) => !s.excluded);
879
1072
  console.log("\u2705 burnwatch initialized!\n");
880
- console.log("Next steps:");
881
- console.log(" 1. Add API keys for LIVE tracking:");
882
- console.log(" burnwatch add anthropic --key $ANTHROPIC_ADMIN_KEY --budget 100");
883
- console.log(" 2. Set budgets for detected services:");
884
- console.log(" burnwatch add scrapfly --key $SCRAPFLY_KEY --budget 50");
885
- console.log(" 3. Check your spend:");
886
- console.log(" burnwatch status\n");
1073
+ if (tracked.length > 0) {
1074
+ console.log(` Tracking ${tracked.length} service${tracked.length > 1 ? "s" : ""}`);
1075
+ for (const svc of tracked) {
1076
+ const planStr = svc.planName ? ` (${svc.planName})` : "";
1077
+ const budgetStr = svc.budget !== void 0 ? ` \u2014 $${svc.budget}/mo budget` : "";
1078
+ console.log(` \u2022 ${svc.serviceId}${planStr}${budgetStr}`);
1079
+ }
1080
+ }
1081
+ if (excluded.length > 0) {
1082
+ console.log(`
1083
+ Excluded ${excluded.length} service${excluded.length > 1 ? "s" : ""}:`);
1084
+ for (const svc of excluded) {
1085
+ console.log(` \u2022 ${svc.serviceId}`);
1086
+ }
1087
+ }
1088
+ console.log("\nNext steps:");
1089
+ console.log(" burnwatch status \u2014 Check your spend");
1090
+ console.log(" burnwatch add <svc> \u2014 Configure additional services\n");
887
1091
  }
888
1092
  async function cmdAdd() {
889
1093
  const projectRoot = process.cwd();
@@ -1045,7 +1249,8 @@ function cmdHelp() {
1045
1249
  burnwatch \u2014 Passive cost memory for vibe coding
1046
1250
 
1047
1251
  Usage:
1048
- burnwatch init Initialize in current project
1252
+ burnwatch init Interactive setup \u2014 pick plans per service
1253
+ burnwatch init --non-interactive Auto-detect services, no prompts
1049
1254
  burnwatch setup Init + auto-configure all detected services
1050
1255
  burnwatch add <service> [options] Register a service for tracking
1051
1256
  burnwatch status Show current spend brief
@@ -1060,6 +1265,7 @@ Options for 'add':
1060
1265
 
1061
1266
  Examples:
1062
1267
  burnwatch init
1268
+ burnwatch init --non-interactive
1063
1269
  burnwatch add anthropic --key sk-ant-admin-xxx --budget 100
1064
1270
  burnwatch add scrapfly --key scp-xxx --budget 50
1065
1271
  burnwatch add posthog --plan-cost 0 --budget 0