burnwatch 0.4.2 → 0.5.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,13 +5,28 @@ 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.5.0] - 2026-03-24
9
+
10
+ ### Changed
11
+
12
+ - **Interactive init rewritten as proper interview**: Each service gets a structured conversation - plan selection, API key collection with hints on where to find them, and budget. Budgets are never skipped - pressing Enter applies the plan cost (or $0 for free tiers) instead of leaving budget undefined. Summary table at the end shows total monthly budget across all services.
13
+ - **Env var auto-detection for API keys**: Init checks `process.env` for known key patterns (e.g., `ANTHROPIC_ADMIN_KEY`, `SCRAPFLY_KEY`) and auto-imports them to global config. No need to re-enter keys that are already in your environment.
14
+ - **API key prompts for all LIVE services**: Previously only fired when the chosen plan had `requiresKey: true`. Now any LIVE-capable service gets the key prompt regardless of plan, with a hint about where to find the key (e.g., "Admin key: console.anthropic.com -> Settings -> Admin API Keys").
15
+ - **Non-interactive init always sets budget**: Every service gets `budget: 0` at minimum (flat-rate services get budget = plan cost). No more undefined budgets showing as "-" in status output.
16
+ - **Non-interactive env var key detection**: Checks environment for API keys matching service patterns, auto-saves to global config for LIVE tracking.
17
+
18
+ ### Fixed
19
+
20
+ - **13/14 services had no budget after init**: Root cause was budget prompt saying "press Enter to skip". Now pressing Enter applies the default budget instead of skipping.
21
+ - **Untracked message was circular**: Changed "run burnwatch status" to "run burnwatch init to configure".
22
+
8
23
  ## [0.4.2] - 2026-03-24
9
24
 
10
25
  ### Fixed
11
26
 
12
- - **Init is re-runnable**: `burnwatch init` no longer early-returns on already-initialized projects. Re-running init re-detects services and walks through interactive setup again, so users who initialized before v0.4.0 can configure budgets without manually running `burnwatch add` 14 times.
13
- - **Budget prompt fires for all services**: Budget prompt was gated inside the `requiresKey` block - services without API key requirements never got asked. Now every non-excluded service gets a budget prompt during interactive init.
14
- - **Untracked message is actionable**: Changed circular "run burnwatch status" message to "run burnwatch init to configure" so users know what to do next.
27
+ - **Init is re-runnable**: `burnwatch init` no longer early-returns on already-initialized projects. Re-running init re-detects services and walks through interactive setup again.
28
+ - **Budget prompt fires for all services**: Budget prompt was gated inside the `requiresKey` block - now every non-excluded service gets a budget prompt during interactive init.
29
+ - **Untracked message fix**: Same as 0.4.3 (first shipped here).
15
30
 
16
31
  ## [0.4.0] - 2026-03-24
17
32
 
@@ -51,6 +66,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
51
66
  - Snapshot system for delta computation across sessions
52
67
  - Claude Code skills: `/spend` (on-demand brief), `/setup-burnwatch` (guided onboarding)
53
68
 
69
+ [0.5.0]: https://github.com/RaleighSF/burnwatch/compare/v0.4.3...v0.5.0
70
+ [0.4.3]: https://github.com/RaleighSF/burnwatch/compare/v0.4.2...v0.4.3
54
71
  [0.4.2]: https://github.com/RaleighSF/burnwatch/compare/v0.4.0...v0.4.2
55
72
  [0.4.0]: https://github.com/RaleighSF/burnwatch/compare/v0.1.0...v0.4.0
56
73
  [0.1.0]: https://github.com/RaleighSF/burnwatch/releases/tag/v0.1.0
package/dist/cli.js CHANGED
@@ -789,10 +789,18 @@ function saveSnapshot(brief, projectRoot) {
789
789
  import * as readline from "readline";
790
790
  var RISK_ORDER = ["llm", "usage", "infra", "flat"];
791
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"
792
+ llm: "LLM / AI Services (highest variable cost)",
793
+ usage: "Usage-Based Services",
794
+ infra: "Infrastructure & Compute",
795
+ flat: "Flat-Rate / Free Tier Services"
796
+ };
797
+ var API_KEY_HINTS = {
798
+ anthropic: "Admin key: console.anthropic.com -> Settings -> Admin API Keys",
799
+ openai: "Org key: platform.openai.com -> Settings -> API Keys",
800
+ vercel: "Token: vercel.com/account/tokens",
801
+ supabase: "Service role key: supabase.com/dashboard -> Settings -> API",
802
+ stripe: "Secret key: dashboard.stripe.com -> Developers -> API Keys",
803
+ scrapfly: "API key: scrapfly.io/dashboard"
796
804
  };
797
805
  function classifyRisk(service) {
798
806
  if (service.billingModel === "token_usage") return "llm";
@@ -829,6 +837,13 @@ async function autoDetectScrapflyPlan(apiKey) {
829
837
  }
830
838
  return null;
831
839
  }
840
+ function findEnvKey(service) {
841
+ for (const pattern of service.envPatterns) {
842
+ const val = process.env[pattern];
843
+ if (val && val.length > 0) return val;
844
+ }
845
+ return void 0;
846
+ }
832
847
  async function runInteractiveInit(detected) {
833
848
  const rl = readline.createInterface({
834
849
  input: process.stdin,
@@ -838,14 +853,16 @@ async function runInteractiveInit(detected) {
838
853
  const groups = groupByRisk(detected);
839
854
  const globalConfig = readGlobalConfig();
840
855
  console.log(
841
- "\n\u{1F4CB} Let's configure each detected service. Services are grouped by cost risk.\n"
856
+ `
857
+ Found ${detected.length} paid service${detected.length !== 1 ? "s" : ""}. Let's configure each one.
858
+ `
842
859
  );
843
860
  for (const category of RISK_ORDER) {
844
861
  const group = groups.get(category);
845
862
  if (group.length === 0) continue;
846
863
  console.log(`
847
- ${RISK_LABELS[category]}`);
848
- console.log("\u2500".repeat(50));
864
+ ${RISK_LABELS[category]}`);
865
+ console.log(" " + "-".repeat(48));
849
866
  for (const det of group) {
850
867
  const service = det.service;
851
868
  const plans = service.plans;
@@ -857,23 +874,24 @@ ${RISK_LABELS[category]}`);
857
874
  serviceId: service.id,
858
875
  detectedVia: det.sources,
859
876
  hasApiKey: false,
860
- firstDetected: (/* @__PURE__ */ new Date()).toISOString()
877
+ firstDetected: (/* @__PURE__ */ new Date()).toISOString(),
878
+ budget: 0
861
879
  };
862
- console.log(" \u2192 Auto-configured (no plan tiers available)");
880
+ console.log(" -> Configured (no plan tiers in registry, budget: $0)");
863
881
  continue;
864
882
  }
865
883
  const defaultIndex = plans.findIndex((p) => p.default);
866
884
  console.log("");
867
885
  for (let i = 0; i < plans.length; i++) {
868
886
  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";
887
+ const marker = i === defaultIndex ? " *" : "";
888
+ const costStr = plan.type === "exclude" ? "" : plan.monthlyBase !== void 0 ? ` - $${plan.monthlyBase}/mo` : " - variable";
871
889
  console.log(` ${i + 1}) ${plan.name}${costStr}${marker}`);
872
890
  }
873
891
  const defaultChoice = defaultIndex >= 0 ? String(defaultIndex + 1) : "1";
874
892
  const answer = await ask(
875
893
  rl,
876
- ` Choose [${defaultChoice}]: `
894
+ ` Which plan? [${defaultChoice}]: `
877
895
  );
878
896
  const choiceIndex = (answer === "" ? parseInt(defaultChoice) : parseInt(answer)) - 1;
879
897
  const chosen = plans[choiceIndex] ?? plans[defaultIndex >= 0 ? defaultIndex : 0];
@@ -886,10 +904,10 @@ ${RISK_LABELS[category]}`);
886
904
  excluded: true,
887
905
  planName: chosen.name
888
906
  };
889
- console.log(` \u2192 ${service.name}: excluded from tracking`);
907
+ console.log(` -> ${service.name}: excluded`);
890
908
  continue;
891
909
  }
892
- const tracked = {
910
+ const tracked2 = {
893
911
  serviceId: service.id,
894
912
  detectedVia: det.sources,
895
913
  hasApiKey: false,
@@ -897,67 +915,86 @@ ${RISK_LABELS[category]}`);
897
915
  planName: chosen.name
898
916
  };
899
917
  if (chosen.type === "flat" && chosen.monthlyBase !== void 0) {
900
- tracked.planCost = chosen.monthlyBase;
901
- if (chosen.monthlyBase > 0) {
902
- tracked.budget = chosen.monthlyBase;
903
- }
918
+ tracked2.planCost = chosen.monthlyBase;
904
919
  }
905
- if (service.apiTier === "live" || chosen.requiresKey) {
920
+ if (service.apiTier === "live") {
906
921
  const existingKey = globalConfig.services[service.id]?.apiKey;
922
+ const envKey = findEnvKey(service);
907
923
  if (existingKey) {
908
- console.log(` \u{1F510} Using existing API key from global config`);
909
- tracked.hasApiKey = true;
910
- if (service.autoDetectPlan && service.id === "scrapfly") {
911
- console.log(" \u{1F50D} Auto-detecting plan from API...");
912
- const planName = await autoDetectScrapflyPlan(existingKey);
913
- if (planName) {
914
- console.log(` \u2192 Detected plan: ${planName}`);
915
- tracked.planName = planName;
916
- }
924
+ console.log(` API key: found in global config`);
925
+ tracked2.hasApiKey = true;
926
+ } else if (envKey) {
927
+ console.log(` API key: found in environment (${service.envPatterns[0]})`);
928
+ tracked2.hasApiKey = true;
929
+ if (!globalConfig.services[service.id]) {
930
+ globalConfig.services[service.id] = {};
917
931
  }
918
- } else if (chosen.requiresKey) {
932
+ globalConfig.services[service.id].apiKey = envKey;
933
+ } else {
934
+ const hint = API_KEY_HINTS[service.id];
935
+ if (hint) console.log(` ${hint}`);
919
936
  const keyAnswer = await ask(
920
937
  rl,
921
- ` Enter API key (or press Enter to skip): `
938
+ ` API key for real-time tracking (Enter to skip): `
922
939
  );
923
940
  if (keyAnswer) {
924
- tracked.hasApiKey = true;
941
+ tracked2.hasApiKey = true;
925
942
  if (!globalConfig.services[service.id]) {
926
943
  globalConfig.services[service.id] = {};
927
944
  }
928
945
  globalConfig.services[service.id].apiKey = keyAnswer;
929
- if (service.autoDetectPlan && service.id === "scrapfly") {
930
- console.log(" \u{1F50D} Auto-detecting plan from API...");
931
- const planName = await autoDetectScrapflyPlan(keyAnswer);
932
- if (planName) {
933
- console.log(` \u2192 Detected plan: ${planName}`);
934
- tracked.planName = planName;
935
- }
946
+ }
947
+ }
948
+ if (service.autoDetectPlan && service.id === "scrapfly" && tracked2.hasApiKey) {
949
+ const key = globalConfig.services[service.id]?.apiKey;
950
+ if (key) {
951
+ console.log(" Detecting plan from API...");
952
+ const planName = await autoDetectScrapflyPlan(key);
953
+ if (planName) {
954
+ console.log(` -> Detected plan: ${planName}`);
955
+ tracked2.planName = planName;
936
956
  }
937
957
  }
938
958
  }
939
959
  }
940
- if (tracked.budget === void 0 || tracked.budget === 0) {
941
- const suggestion = chosen.monthlyBase && chosen.monthlyBase > 0 ? ` [${chosen.monthlyBase}]` : "";
960
+ const planCost = chosen.monthlyBase ?? 0;
961
+ if (chosen.type === "usage" && planCost === 0) {
942
962
  const budgetAnswer = await ask(
943
963
  rl,
944
- ` Monthly budget in USD${suggestion} (or press Enter to skip): $`
964
+ ` Monthly budget: $`
965
+ );
966
+ const parsed = parseFloat(budgetAnswer);
967
+ tracked2.budget = !isNaN(parsed) ? parsed : 0;
968
+ } else {
969
+ const budgetAnswer = await ask(
970
+ rl,
971
+ ` Monthly budget [$${planCost}]: $`
945
972
  );
946
973
  if (budgetAnswer) {
947
- const budget = parseFloat(budgetAnswer);
948
- if (!isNaN(budget)) {
949
- tracked.budget = budget;
950
- }
974
+ const parsed = parseFloat(budgetAnswer);
975
+ tracked2.budget = !isNaN(parsed) ? parsed : planCost;
976
+ } else {
977
+ tracked2.budget = planCost;
951
978
  }
952
979
  }
953
- services[service.id] = tracked;
954
- const tierLabel = tracked.hasApiKey ? "\u2705 LIVE" : tracked.planCost !== void 0 ? "\u{1F7E1} CALC" : "\u{1F534} BLIND";
955
- const budgetStr = tracked.budget !== void 0 ? ` | Budget: $${tracked.budget}/mo` : "";
980
+ services[service.id] = tracked2;
981
+ const tierLabel = tracked2.hasApiKey ? "LIVE" : tracked2.planCost !== void 0 ? "CALC" : "BLIND";
956
982
  console.log(
957
- ` \u2192 ${service.name}: ${chosen.name} (${tierLabel}${budgetStr})`
983
+ ` -> ${service.name}: ${tracked2.planName} | ${tierLabel} | $${tracked2.budget}/mo`
958
984
  );
959
985
  }
960
986
  }
987
+ const tracked = Object.values(services).filter((s) => !s.excluded);
988
+ const excluded = Object.values(services).filter((s) => s.excluded);
989
+ const liveCount = tracked.filter((s) => s.hasApiKey).length;
990
+ const totalBudget = tracked.reduce((sum, s) => sum + (s.budget ?? 0), 0);
991
+ console.log("\n " + "=".repeat(48));
992
+ console.log(` ${tracked.length} services configured`);
993
+ if (liveCount > 0) console.log(` ${liveCount} with real-time billing (LIVE)`);
994
+ if (tracked.length - liveCount > 0) console.log(` ${tracked.length - liveCount} estimated/calculated`);
995
+ if (excluded.length > 0) console.log(` ${excluded.length} excluded`);
996
+ console.log(` Total monthly budget: $${totalBudget}`);
997
+ console.log(" " + "=".repeat(48));
961
998
  writeGlobalConfig(globalConfig);
962
999
  rl.close();
963
1000
  return { services };
@@ -1029,29 +1066,71 @@ async function cmdInit() {
1029
1066
  const result = await runInteractiveInit(detected);
1030
1067
  config.services = result.services;
1031
1068
  } else {
1069
+ const globalConfig = readGlobalConfig();
1032
1070
  for (const det of detected) {
1033
- if (!config.services[det.service.id]) {
1034
- const tracked2 = {
1035
- serviceId: det.service.id,
1036
- detectedVia: det.sources,
1037
- hasApiKey: false,
1038
- firstDetected: (/* @__PURE__ */ new Date()).toISOString()
1039
- };
1040
- config.services[det.service.id] = tracked2;
1071
+ const existing = config.services[det.service.id];
1072
+ const service = det.service;
1073
+ const plans = service.plans ?? [];
1074
+ const defaultPlan = plans.find((p) => p.default) ?? plans[0];
1075
+ if (existing?.budget !== void 0 && existing.budget > 0) continue;
1076
+ const tracked = {
1077
+ serviceId: service.id,
1078
+ detectedVia: existing?.detectedVia ?? det.sources,
1079
+ hasApiKey: existing?.hasApiKey ?? false,
1080
+ firstDetected: existing?.firstDetected ?? (/* @__PURE__ */ new Date()).toISOString(),
1081
+ budget: 0
1082
+ // Always set — $0 is intentional, not missing
1083
+ };
1084
+ if (defaultPlan && defaultPlan.type !== "exclude") {
1085
+ tracked.planName = defaultPlan.name;
1086
+ if (defaultPlan.type === "flat" && defaultPlan.monthlyBase !== void 0) {
1087
+ tracked.planCost = defaultPlan.monthlyBase;
1088
+ tracked.budget = defaultPlan.monthlyBase;
1089
+ }
1090
+ }
1091
+ const existingKey = globalConfig.services[service.id]?.apiKey;
1092
+ if (existingKey) {
1093
+ tracked.hasApiKey = true;
1094
+ } else {
1095
+ for (const pattern of service.envPatterns) {
1096
+ if (process.env[pattern]) {
1097
+ tracked.hasApiKey = true;
1098
+ if (!globalConfig.services[service.id]) {
1099
+ globalConfig.services[service.id] = {};
1100
+ }
1101
+ globalConfig.services[service.id].apiKey = process.env[pattern];
1102
+ break;
1103
+ }
1104
+ }
1041
1105
  }
1106
+ config.services[service.id] = tracked;
1042
1107
  }
1108
+ writeGlobalConfig(globalConfig);
1043
1109
  if (detected.length === 0) {
1044
1110
  console.log(" No paid services detected yet.");
1045
1111
  console.log(" Services will be detected as they enter your project.\n");
1046
1112
  } else {
1047
1113
  console.log(` Found ${detected.length} paid service${detected.length > 1 ? "s" : ""}:
1048
1114
  `);
1115
+ const needBudget = [];
1049
1116
  for (const det of detected) {
1050
- 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";
1051
- console.log(` \u2022 ${det.service.name} (${tierBadge})`);
1052
- console.log(` Detected via: ${det.details.join(", ")}`);
1117
+ const svc = config.services[det.service.id];
1118
+ const tierBadge = det.service.apiTier === "live" ? svc?.hasApiKey ? "LIVE" : "BLIND" : det.service.apiTier === "calc" ? "CALC" : det.service.apiTier === "est" ? "EST" : "BLIND";
1119
+ const planStr = svc?.planName ? ` (${svc.planName})` : "";
1120
+ const budgetStr = svc?.budget !== void 0 ? ` $${svc.budget}/mo` : "";
1121
+ console.log(` ${det.service.name}${planStr} [${tierBadge}]${budgetStr}`);
1122
+ if (svc?.budget === 0 && det.service.apiTier !== "calc") {
1123
+ needBudget.push(det.service.id);
1124
+ }
1053
1125
  }
1054
1126
  console.log("");
1127
+ if (needBudget.length > 0) {
1128
+ console.log(` ${needBudget.length} service${needBudget.length > 1 ? "s" : ""} need budgets. Set them with:`);
1129
+ for (const id of needBudget) {
1130
+ console.log(` burnwatch add ${id} --budget <AMOUNT>`);
1131
+ }
1132
+ console.log("");
1133
+ }
1055
1134
  }
1056
1135
  }
1057
1136
  writeProjectConfig(config, projectRoot);
@@ -1069,27 +1148,11 @@ async function cmdInit() {
1069
1148
  );
1070
1149
  console.log("\n\u{1F517} Registering Claude Code hooks...\n");
1071
1150
  registerHooks(projectRoot);
1072
- const excluded = Object.values(config.services).filter((s) => s.excluded);
1073
- const tracked = Object.values(config.services).filter((s) => !s.excluded);
1074
- console.log("\u2705 burnwatch initialized!\n");
1075
- if (tracked.length > 0) {
1076
- console.log(` Tracking ${tracked.length} service${tracked.length > 1 ? "s" : ""}`);
1077
- for (const svc of tracked) {
1078
- const planStr = svc.planName ? ` (${svc.planName})` : "";
1079
- const budgetStr = svc.budget !== void 0 ? ` \u2014 $${svc.budget}/mo budget` : "";
1080
- console.log(` \u2022 ${svc.serviceId}${planStr}${budgetStr}`);
1081
- }
1082
- }
1083
- if (excluded.length > 0) {
1084
- console.log(`
1085
- Excluded ${excluded.length} service${excluded.length > 1 ? "s" : ""}:`);
1086
- for (const svc of excluded) {
1087
- console.log(` \u2022 ${svc.serviceId}`);
1088
- }
1089
- }
1090
- console.log("\nNext steps:");
1091
- console.log(" burnwatch status \u2014 Check your spend");
1092
- console.log(" burnwatch add <svc> \u2014 Configure additional services\n");
1151
+ console.log("\nburnwatch initialized.\n");
1152
+ console.log("Next steps:");
1153
+ console.log(" burnwatch status Show current spend");
1154
+ console.log(" burnwatch add <svc> Update a service's budget or API key");
1155
+ console.log(" burnwatch init Re-run this setup anytime\n");
1093
1156
  }
1094
1157
  async function cmdAdd() {
1095
1158
  const projectRoot = process.cwd();