burnwatch 0.4.3 → 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,20 @@ 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.3] - 2026-03-24
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.
9
17
 
10
18
  ### Fixed
11
19
 
12
- - **Non-interactive init auto-applies default plans**: `burnwatch init` (non-interactive or re-run) now picks each service's default plan from the registry. Flat-rate services with a cost (e.g., Scrapfly Scale at $80/mo) get budget auto-set to plan cost. Services with existing API keys in global config are auto-detected as LIVE.
13
- - **Init output shows tier and budget per service**: Non-interactive init now prints each service with its confidence tier, plan name, and budget (if set), plus a clear list of which services still need budgets with the exact `burnwatch add` commands.
14
- - **Untracked message is actionable**: Changed circular "run burnwatch status" message to "run burnwatch init to configure".
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".
15
22
 
16
23
  ## [0.4.2] - 2026-03-24
17
24
 
@@ -59,6 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
59
66
  - Snapshot system for delta computation across sessions
60
67
  - Claude Code skills: `/spend` (on-demand brief), `/setup-burnwatch` (guided onboarding)
61
68
 
69
+ [0.5.0]: https://github.com/RaleighSF/burnwatch/compare/v0.4.3...v0.5.0
62
70
  [0.4.3]: https://github.com/RaleighSF/burnwatch/compare/v0.4.2...v0.4.3
63
71
  [0.4.2]: https://github.com/RaleighSF/burnwatch/compare/v0.4.0...v0.4.2
64
72
  [0.4.0]: https://github.com/RaleighSF/burnwatch/compare/v0.1.0...v0.4.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) {
962
+ const budgetAnswer = await ask(
963
+ rl,
964
+ ` Monthly budget: $`
965
+ );
966
+ const parsed = parseFloat(budgetAnswer);
967
+ tracked2.budget = !isNaN(parsed) ? parsed : 0;
968
+ } else {
942
969
  const budgetAnswer = await ask(
943
970
  rl,
944
- ` Monthly budget in USD${suggestion} (or press Enter to skip): $`
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 };
@@ -1036,27 +1073,39 @@ async function cmdInit() {
1036
1073
  const plans = service.plans ?? [];
1037
1074
  const defaultPlan = plans.find((p) => p.default) ?? plans[0];
1038
1075
  if (existing?.budget !== void 0 && existing.budget > 0) continue;
1039
- const tracked2 = {
1076
+ const tracked = {
1040
1077
  serviceId: service.id,
1041
1078
  detectedVia: existing?.detectedVia ?? det.sources,
1042
1079
  hasApiKey: existing?.hasApiKey ?? false,
1043
- firstDetected: existing?.firstDetected ?? (/* @__PURE__ */ new Date()).toISOString()
1080
+ firstDetected: existing?.firstDetected ?? (/* @__PURE__ */ new Date()).toISOString(),
1081
+ budget: 0
1082
+ // Always set — $0 is intentional, not missing
1044
1083
  };
1045
1084
  if (defaultPlan && defaultPlan.type !== "exclude") {
1046
- tracked2.planName = defaultPlan.name;
1085
+ tracked.planName = defaultPlan.name;
1047
1086
  if (defaultPlan.type === "flat" && defaultPlan.monthlyBase !== void 0) {
1048
- tracked2.planCost = defaultPlan.monthlyBase;
1049
- if (defaultPlan.monthlyBase > 0) {
1050
- tracked2.budget = defaultPlan.monthlyBase;
1051
- }
1087
+ tracked.planCost = defaultPlan.monthlyBase;
1088
+ tracked.budget = defaultPlan.monthlyBase;
1052
1089
  }
1053
1090
  }
1054
1091
  const existingKey = globalConfig.services[service.id]?.apiKey;
1055
1092
  if (existingKey) {
1056
- tracked2.hasApiKey = true;
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
+ }
1057
1105
  }
1058
- config.services[service.id] = tracked2;
1106
+ config.services[service.id] = tracked;
1059
1107
  }
1108
+ writeGlobalConfig(globalConfig);
1060
1109
  if (detected.length === 0) {
1061
1110
  console.log(" No paid services detected yet.");
1062
1111
  console.log(" Services will be detected as they enter your project.\n");
@@ -1066,11 +1115,11 @@ async function cmdInit() {
1066
1115
  const needBudget = [];
1067
1116
  for (const det of detected) {
1068
1117
  const svc = config.services[det.service.id];
1069
- const tierBadge = det.service.apiTier === "live" ? svc?.hasApiKey ? "\u2705 LIVE" : "\u{1F534} BLIND" : det.service.apiTier === "calc" ? "\u{1F7E1} CALC" : det.service.apiTier === "est" ? "\u{1F7E0} EST" : "\u{1F534} BLIND";
1118
+ const tierBadge = det.service.apiTier === "live" ? svc?.hasApiKey ? "LIVE" : "BLIND" : det.service.apiTier === "calc" ? "CALC" : det.service.apiTier === "est" ? "EST" : "BLIND";
1070
1119
  const planStr = svc?.planName ? ` (${svc.planName})` : "";
1071
- const budgetStr = svc?.budget ? ` - $${svc.budget}/mo budget` : "";
1072
- console.log(` \u2022 ${det.service.name}${planStr} ${tierBadge}${budgetStr}`);
1073
- if (!svc?.budget) {
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") {
1074
1123
  needBudget.push(det.service.id);
1075
1124
  }
1076
1125
  }
@@ -1099,27 +1148,11 @@ async function cmdInit() {
1099
1148
  );
1100
1149
  console.log("\n\u{1F517} Registering Claude Code hooks...\n");
1101
1150
  registerHooks(projectRoot);
1102
- const excluded = Object.values(config.services).filter((s) => s.excluded);
1103
- const tracked = Object.values(config.services).filter((s) => !s.excluded);
1104
- console.log("\u2705 burnwatch initialized!\n");
1105
- if (tracked.length > 0) {
1106
- console.log(` Tracking ${tracked.length} service${tracked.length > 1 ? "s" : ""}`);
1107
- for (const svc of tracked) {
1108
- const planStr = svc.planName ? ` (${svc.planName})` : "";
1109
- const budgetStr = svc.budget !== void 0 ? ` \u2014 $${svc.budget}/mo budget` : "";
1110
- console.log(` \u2022 ${svc.serviceId}${planStr}${budgetStr}`);
1111
- }
1112
- }
1113
- if (excluded.length > 0) {
1114
- console.log(`
1115
- Excluded ${excluded.length} service${excluded.length > 1 ? "s" : ""}:`);
1116
- for (const svc of excluded) {
1117
- console.log(` \u2022 ${svc.serviceId}`);
1118
- }
1119
- }
1120
- console.log("\nNext steps:");
1121
- console.log(" burnwatch status \u2014 Check your spend");
1122
- 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");
1123
1156
  }
1124
1157
  async function cmdAdd() {
1125
1158
  const projectRoot = process.cwd();