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 +12 -4
- package/dist/cli.js +116 -83
- package/dist/cli.js.map +1 -1
- package/dist/interactive-init.d.ts +9 -3
- package/dist/interactive-init.js +86 -49
- package/dist/interactive-init.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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
|
-
- **
|
|
13
|
-
- **
|
|
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: "
|
|
793
|
-
usage: "
|
|
794
|
-
infra: "
|
|
795
|
-
flat: "
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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 ? "
|
|
870
|
-
const costStr = plan.type === "exclude" ? "" : plan.monthlyBase !== void 0 ? `
|
|
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
|
-
`
|
|
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(`
|
|
907
|
+
console.log(` -> ${service.name}: excluded`);
|
|
890
908
|
continue;
|
|
891
909
|
}
|
|
892
|
-
const
|
|
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
|
-
|
|
901
|
-
if (chosen.monthlyBase > 0) {
|
|
902
|
-
tracked.budget = chosen.monthlyBase;
|
|
903
|
-
}
|
|
918
|
+
tracked2.planCost = chosen.monthlyBase;
|
|
904
919
|
}
|
|
905
|
-
if (service.apiTier === "live"
|
|
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(`
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
938
|
+
` API key for real-time tracking (Enter to skip): `
|
|
922
939
|
);
|
|
923
940
|
if (keyAnswer) {
|
|
924
|
-
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
941
|
-
|
|
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
|
|
971
|
+
` Monthly budget [$${planCost}]: $`
|
|
945
972
|
);
|
|
946
973
|
if (budgetAnswer) {
|
|
947
|
-
const
|
|
948
|
-
|
|
949
|
-
|
|
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] =
|
|
954
|
-
const tierLabel =
|
|
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
|
-
`
|
|
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
|
|
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
|
-
|
|
1085
|
+
tracked.planName = defaultPlan.name;
|
|
1047
1086
|
if (defaultPlan.type === "flat" && defaultPlan.monthlyBase !== void 0) {
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
-
|
|
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] =
|
|
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 ? "
|
|
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 ? `
|
|
1072
|
-
console.log(`
|
|
1073
|
-
if (
|
|
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
|
-
|
|
1103
|
-
|
|
1104
|
-
console.log("
|
|
1105
|
-
|
|
1106
|
-
|
|
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();
|