burnwatch 0.2.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 +24 -0
- package/dist/cli.js +376 -134
- package/dist/cli.js.map +1 -1
- package/dist/cost-impact.d.ts +23 -0
- package/dist/cost-impact.js +281 -0
- package/dist/cost-impact.js.map +1 -0
- package/dist/detector-C4LnLT-O.d.ts +28 -0
- package/dist/hooks/on-file-change.js +324 -6
- package/dist/hooks/on-file-change.js.map +1 -1
- package/dist/hooks/on-prompt.js +2 -1
- package/dist/hooks/on-prompt.js.map +1 -1
- package/dist/hooks/on-session-start.js +114 -27
- package/dist/hooks/on-session-start.js.map +1 -1
- package/dist/hooks/on-stop.js +47 -3
- package/dist/hooks/on-stop.js.map +1 -1
- package/dist/index.d.ts +5 -159
- package/dist/index.js +352 -27
- package/dist/index.js.map +1 -1
- package/dist/interactive-init.d.ts +20 -0
- package/dist/interactive-init.js +239 -0
- package/dist/interactive-init.js.map +1 -0
- package/dist/mcp-server.js +106 -27
- package/dist/mcp-server.js.map +1 -1
- package/dist/types-fDMu4rOd.d.ts +178 -0
- package/package.json +1 -1
- package/registry.json +89 -1
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
|
@@ -122,7 +122,7 @@ function getAllServices(projectRoot) {
|
|
|
122
122
|
function detectServices(projectRoot) {
|
|
123
123
|
const registry = loadRegistry(projectRoot);
|
|
124
124
|
const results = /* @__PURE__ */ new Map();
|
|
125
|
-
const pkgDeps =
|
|
125
|
+
const pkgDeps = scanAllPackageJsons(projectRoot);
|
|
126
126
|
for (const [serviceId, service] of registry) {
|
|
127
127
|
const matchedPkgs = service.packageNames.filter(
|
|
128
128
|
(pkg) => pkgDeps.has(pkg)
|
|
@@ -134,7 +134,7 @@ function detectServices(projectRoot) {
|
|
|
134
134
|
);
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
-
const envVars =
|
|
137
|
+
const envVars = collectEnvVars(projectRoot);
|
|
138
138
|
for (const [serviceId, service] of registry) {
|
|
139
139
|
const matchedEnvs = service.envPatterns.filter(
|
|
140
140
|
(pattern) => envVars.has(pattern)
|
|
@@ -172,40 +172,118 @@ function getOrCreate(map, serviceId, service) {
|
|
|
172
172
|
}
|
|
173
173
|
return result;
|
|
174
174
|
}
|
|
175
|
-
function
|
|
175
|
+
function scanAllPackageJsons(projectRoot) {
|
|
176
176
|
const deps = /* @__PURE__ */ new Set();
|
|
177
|
-
const
|
|
177
|
+
const pkgFiles = findFiles(projectRoot, "package.json", 4);
|
|
178
|
+
for (const pkgPath of pkgFiles) {
|
|
179
|
+
try {
|
|
180
|
+
const raw = fs3.readFileSync(pkgPath, "utf-8");
|
|
181
|
+
const pkg = JSON.parse(raw);
|
|
182
|
+
for (const name of Object.keys(pkg.dependencies ?? {})) deps.add(name);
|
|
183
|
+
for (const name of Object.keys(pkg.devDependencies ?? {})) deps.add(name);
|
|
184
|
+
} catch {
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return deps;
|
|
188
|
+
}
|
|
189
|
+
function collectEnvVars(projectRoot) {
|
|
190
|
+
const envVars = new Set(Object.keys(process.env));
|
|
191
|
+
const envFiles = findEnvFiles(projectRoot, 3);
|
|
192
|
+
for (const envFile of envFiles) {
|
|
193
|
+
try {
|
|
194
|
+
const content = fs3.readFileSync(envFile, "utf-8");
|
|
195
|
+
const keys = content.split("\n").filter((line) => line.includes("=") && !line.startsWith("#")).map((line) => line.split("=")[0].trim()).filter(Boolean);
|
|
196
|
+
for (const key of keys) {
|
|
197
|
+
envVars.add(key);
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return envVars;
|
|
203
|
+
}
|
|
204
|
+
function findEnvFiles(dir, maxDepth) {
|
|
205
|
+
const results = [];
|
|
206
|
+
if (maxDepth <= 0) return results;
|
|
178
207
|
try {
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
208
|
+
const entries = fs3.readdirSync(dir, { withFileTypes: true });
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue;
|
|
211
|
+
const fullPath = path3.join(dir, entry.name);
|
|
212
|
+
if (entry.isDirectory()) {
|
|
213
|
+
results.push(...findEnvFiles(fullPath, maxDepth - 1));
|
|
214
|
+
} else if (entry.name.startsWith(".env")) {
|
|
215
|
+
results.push(fullPath);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
183
218
|
} catch {
|
|
184
219
|
}
|
|
185
|
-
return
|
|
220
|
+
return results;
|
|
221
|
+
}
|
|
222
|
+
function findFiles(dir, fileName, maxDepth) {
|
|
223
|
+
const results = [];
|
|
224
|
+
if (maxDepth <= 0) return results;
|
|
225
|
+
try {
|
|
226
|
+
const entries = fs3.readdirSync(dir, { withFileTypes: true });
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue;
|
|
229
|
+
const fullPath = path3.join(dir, entry.name);
|
|
230
|
+
if (entry.isDirectory()) {
|
|
231
|
+
results.push(...findFiles(fullPath, fileName, maxDepth - 1));
|
|
232
|
+
} else if (entry.name === fileName) {
|
|
233
|
+
results.push(fullPath);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
}
|
|
238
|
+
return results;
|
|
186
239
|
}
|
|
187
240
|
function scanImports(projectRoot) {
|
|
188
241
|
const imports = /* @__PURE__ */ new Set();
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
242
|
+
const codeDirs = ["src", "app", "lib", "pages", "components", "utils", "services", "hooks"];
|
|
243
|
+
const dirsToScan = [];
|
|
244
|
+
for (const dir of codeDirs) {
|
|
245
|
+
const fullPath = path3.join(projectRoot, dir);
|
|
246
|
+
if (fs3.existsSync(fullPath)) {
|
|
247
|
+
dirsToScan.push(fullPath);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const entries = fs3.readdirSync(projectRoot, { withFileTypes: true });
|
|
252
|
+
for (const entry of entries) {
|
|
253
|
+
if (!entry.isDirectory()) continue;
|
|
254
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist" || entry.name.startsWith(".")) continue;
|
|
255
|
+
const subPkgPath = path3.join(projectRoot, entry.name, "package.json");
|
|
256
|
+
if (fs3.existsSync(subPkgPath)) {
|
|
257
|
+
for (const dir of codeDirs) {
|
|
258
|
+
const fullPath = path3.join(projectRoot, entry.name, dir);
|
|
259
|
+
if (fs3.existsSync(fullPath)) {
|
|
260
|
+
dirsToScan.push(fullPath);
|
|
205
261
|
}
|
|
206
262
|
}
|
|
207
263
|
}
|
|
208
|
-
}
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
}
|
|
267
|
+
for (const dir of dirsToScan) {
|
|
268
|
+
const files = walkDir(dir, /\.(ts|tsx|js|jsx|mjs|cjs)$/);
|
|
269
|
+
for (const file of files) {
|
|
270
|
+
try {
|
|
271
|
+
const content = fs3.readFileSync(file, "utf-8");
|
|
272
|
+
const importRegex = /(?:from\s+["']|require\s*\(\s*["'])([^./][^"']*?)(?:["'])/g;
|
|
273
|
+
let match;
|
|
274
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
275
|
+
const pkg = match[1];
|
|
276
|
+
if (pkg) {
|
|
277
|
+
const parts = pkg.split("/");
|
|
278
|
+
if (parts[0]?.startsWith("@") && parts.length >= 2) {
|
|
279
|
+
imports.add(`${parts[0]}/${parts[1]}`);
|
|
280
|
+
} else if (parts[0]) {
|
|
281
|
+
imports.add(parts[0]);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
}
|
|
209
287
|
}
|
|
210
288
|
}
|
|
211
289
|
return imports;
|
|
@@ -501,7 +579,8 @@ var CONFIDENCE_BADGES = {
|
|
|
501
579
|
live: "\u2705 LIVE",
|
|
502
580
|
calc: "\u{1F7E1} CALC",
|
|
503
581
|
est: "\u{1F7E0} EST",
|
|
504
|
-
blind: "\u{1F534} BLIND"
|
|
582
|
+
blind: "\u{1F534} BLIND",
|
|
583
|
+
excluded: "\u2B1A SKIP"
|
|
505
584
|
};
|
|
506
585
|
|
|
507
586
|
// src/core/brief.ts
|
|
@@ -666,6 +745,14 @@ function writeLedger(brief, projectRoot) {
|
|
|
666
745
|
`| ${svc.serviceId} | ${spendStr} | ${badge} | ${budgetStr} | ${svc.statusLabel} |`
|
|
667
746
|
);
|
|
668
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
|
+
}
|
|
669
756
|
lines.push("");
|
|
670
757
|
const totalStr = brief.totalIsEstimate ? `~$${brief.totalSpend.toFixed(2)}` : `$${brief.totalSpend.toFixed(2)}`;
|
|
671
758
|
const marginStr = brief.estimateMargin > 0 ? ` (\xB1$${brief.estimateMargin.toFixed(0)} estimated margin)` : "";
|
|
@@ -698,12 +785,189 @@ function saveSnapshot(brief, projectRoot) {
|
|
|
698
785
|
);
|
|
699
786
|
}
|
|
700
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
|
+
|
|
701
963
|
// src/cli.ts
|
|
702
964
|
var args = process.argv.slice(2);
|
|
703
965
|
var command = args[0];
|
|
966
|
+
var flags = new Set(args.slice(1));
|
|
704
967
|
async function main() {
|
|
705
968
|
switch (command) {
|
|
706
969
|
case "init":
|
|
970
|
+
case "setup":
|
|
707
971
|
await cmdInit();
|
|
708
972
|
break;
|
|
709
973
|
case "add":
|
|
@@ -718,9 +982,6 @@ async function main() {
|
|
|
718
982
|
case "reconcile":
|
|
719
983
|
await cmdReconcile();
|
|
720
984
|
break;
|
|
721
|
-
case "setup":
|
|
722
|
-
await cmdSetup();
|
|
723
|
-
break;
|
|
724
985
|
case "help":
|
|
725
986
|
case "--help":
|
|
726
987
|
case "-h":
|
|
@@ -742,6 +1003,7 @@ async function main() {
|
|
|
742
1003
|
}
|
|
743
1004
|
async function cmdInit() {
|
|
744
1005
|
const projectRoot = process.cwd();
|
|
1006
|
+
const nonInteractive = flags.has("--non-interactive") || flags.has("--ni");
|
|
745
1007
|
if (isInitialized(projectRoot)) {
|
|
746
1008
|
console.log("\u2705 burnwatch is already initialized in this project.");
|
|
747
1009
|
console.log(` Config: ${projectConfigDir(projectRoot)}/config.json`);
|
|
@@ -763,14 +1025,32 @@ async function cmdInit() {
|
|
|
763
1025
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
764
1026
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
765
1027
|
};
|
|
766
|
-
|
|
767
|
-
const
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
+
}
|
|
774
1054
|
}
|
|
775
1055
|
writeProjectConfig(config, projectRoot);
|
|
776
1056
|
const gitignorePath = path5.join(projectConfigDir(projectRoot), ".gitignore");
|
|
@@ -785,29 +1065,29 @@ async function cmdInit() {
|
|
|
785
1065
|
].join("\n"),
|
|
786
1066
|
"utf-8"
|
|
787
1067
|
);
|
|
788
|
-
|
|
789
|
-
console.log(" No paid services detected yet.");
|
|
790
|
-
console.log(" Services will be detected as they enter your project.\n");
|
|
791
|
-
} else {
|
|
792
|
-
console.log(` Found ${detected.length} paid service${detected.length > 1 ? "s" : ""}:
|
|
793
|
-
`);
|
|
794
|
-
for (const det of detected) {
|
|
795
|
-
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";
|
|
796
|
-
console.log(` \u2022 ${det.service.name} (${tierBadge})`);
|
|
797
|
-
console.log(` Detected via: ${det.details.join(", ")}`);
|
|
798
|
-
}
|
|
799
|
-
console.log("");
|
|
800
|
-
}
|
|
801
|
-
console.log("\u{1F517} Registering Claude Code hooks...\n");
|
|
1068
|
+
console.log("\n\u{1F517} Registering Claude Code hooks...\n");
|
|
802
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);
|
|
803
1072
|
console.log("\u2705 burnwatch initialized!\n");
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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");
|
|
811
1091
|
}
|
|
812
1092
|
async function cmdAdd() {
|
|
813
1093
|
const projectRoot = process.cwd();
|
|
@@ -920,69 +1200,6 @@ async function cmdStatus() {
|
|
|
920
1200
|
console.log("");
|
|
921
1201
|
}
|
|
922
1202
|
}
|
|
923
|
-
async function cmdSetup() {
|
|
924
|
-
const projectRoot = process.cwd();
|
|
925
|
-
if (!isInitialized(projectRoot)) {
|
|
926
|
-
await cmdInit();
|
|
927
|
-
}
|
|
928
|
-
const config = readProjectConfig(projectRoot);
|
|
929
|
-
const detected = Object.values(config.services);
|
|
930
|
-
if (detected.length === 0) {
|
|
931
|
-
console.log("No paid services detected. You're all set!");
|
|
932
|
-
return;
|
|
933
|
-
}
|
|
934
|
-
console.log("\u{1F4CB} Auto-configuring detected services...\n");
|
|
935
|
-
const globalConfig = readGlobalConfig();
|
|
936
|
-
const liveServices = [];
|
|
937
|
-
const calcServices = [];
|
|
938
|
-
const estServices = [];
|
|
939
|
-
const blindServices = [];
|
|
940
|
-
for (const tracked of detected) {
|
|
941
|
-
const definition = getService(tracked.serviceId, projectRoot);
|
|
942
|
-
if (!definition) continue;
|
|
943
|
-
const hasKey = !!globalConfig.services[tracked.serviceId]?.apiKey;
|
|
944
|
-
if (hasKey && definition.apiTier === "live") {
|
|
945
|
-
tracked.hasApiKey = true;
|
|
946
|
-
liveServices.push(`${definition.name}`);
|
|
947
|
-
} else if (definition.apiTier === "calc") {
|
|
948
|
-
calcServices.push(`${definition.name}`);
|
|
949
|
-
} else if (definition.apiTier === "est") {
|
|
950
|
-
estServices.push(`${definition.name}`);
|
|
951
|
-
} else {
|
|
952
|
-
blindServices.push(`${definition.name}`);
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
writeProjectConfig(config, projectRoot);
|
|
956
|
-
if (liveServices.length > 0) {
|
|
957
|
-
console.log(` \u2705 LIVE (real billing data): ${liveServices.join(", ")}`);
|
|
958
|
-
}
|
|
959
|
-
if (calcServices.length > 0) {
|
|
960
|
-
console.log(` \u{1F7E1} CALC (flat-rate tracking): ${calcServices.join(", ")}`);
|
|
961
|
-
}
|
|
962
|
-
if (estServices.length > 0) {
|
|
963
|
-
console.log(` \u{1F7E0} EST (estimated from usage): ${estServices.join(", ")}`);
|
|
964
|
-
}
|
|
965
|
-
if (blindServices.length > 0) {
|
|
966
|
-
console.log(` \u{1F534} BLIND (detected, need API key): ${blindServices.join(", ")}`);
|
|
967
|
-
}
|
|
968
|
-
console.log("");
|
|
969
|
-
if (blindServices.length > 0) {
|
|
970
|
-
console.log("To upgrade BLIND services to LIVE, add API keys:");
|
|
971
|
-
for (const tracked of detected) {
|
|
972
|
-
const definition = getService(tracked.serviceId, projectRoot);
|
|
973
|
-
if (definition?.apiTier === "live" && !tracked.hasApiKey) {
|
|
974
|
-
const envHint = definition.envPatterns[0] ?? "YOUR_KEY";
|
|
975
|
-
console.log(` burnwatch add ${tracked.serviceId} --key $${envHint} --budget <N>`);
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
console.log("");
|
|
979
|
-
}
|
|
980
|
-
console.log("To set budgets for any service:");
|
|
981
|
-
console.log(" burnwatch add <service> --budget <monthly_amount>");
|
|
982
|
-
console.log("");
|
|
983
|
-
console.log("Or use /setup-burnwatch in Claude Code for guided setup with budget suggestions.\n");
|
|
984
|
-
await cmdStatus();
|
|
985
|
-
}
|
|
986
1203
|
function cmdServices() {
|
|
987
1204
|
const services = getAllServices();
|
|
988
1205
|
console.log(`
|
|
@@ -1032,7 +1249,8 @@ function cmdHelp() {
|
|
|
1032
1249
|
burnwatch \u2014 Passive cost memory for vibe coding
|
|
1033
1250
|
|
|
1034
1251
|
Usage:
|
|
1035
|
-
burnwatch init
|
|
1252
|
+
burnwatch init Interactive setup \u2014 pick plans per service
|
|
1253
|
+
burnwatch init --non-interactive Auto-detect services, no prompts
|
|
1036
1254
|
burnwatch setup Init + auto-configure all detected services
|
|
1037
1255
|
burnwatch add <service> [options] Register a service for tracking
|
|
1038
1256
|
burnwatch status Show current spend brief
|
|
@@ -1047,6 +1265,7 @@ Options for 'add':
|
|
|
1047
1265
|
|
|
1048
1266
|
Examples:
|
|
1049
1267
|
burnwatch init
|
|
1268
|
+
burnwatch init --non-interactive
|
|
1050
1269
|
burnwatch add anthropic --key sk-ant-admin-xxx --budget 100
|
|
1051
1270
|
burnwatch add scrapfly --key scp-xxx --budget 50
|
|
1052
1271
|
burnwatch add posthog --plan-cost 0 --budget 0
|
|
@@ -1066,24 +1285,47 @@ function cmdVersion() {
|
|
|
1066
1285
|
}
|
|
1067
1286
|
}
|
|
1068
1287
|
function registerHooks(projectRoot) {
|
|
1288
|
+
const sourceHooksDir = path5.resolve(
|
|
1289
|
+
path5.dirname(new URL(import.meta.url).pathname),
|
|
1290
|
+
"hooks"
|
|
1291
|
+
);
|
|
1292
|
+
const localHooksDir = path5.join(projectRoot, ".burnwatch", "hooks");
|
|
1293
|
+
fs5.mkdirSync(localHooksDir, { recursive: true });
|
|
1294
|
+
const hookFiles = [
|
|
1295
|
+
"on-session-start.js",
|
|
1296
|
+
"on-prompt.js",
|
|
1297
|
+
"on-file-change.js",
|
|
1298
|
+
"on-stop.js"
|
|
1299
|
+
];
|
|
1300
|
+
for (const file of hookFiles) {
|
|
1301
|
+
const src = path5.join(sourceHooksDir, file);
|
|
1302
|
+
const dest = path5.join(localHooksDir, file);
|
|
1303
|
+
try {
|
|
1304
|
+
fs5.copyFileSync(src, dest);
|
|
1305
|
+
const mapSrc = src + ".map";
|
|
1306
|
+
if (fs5.existsSync(mapSrc)) {
|
|
1307
|
+
fs5.copyFileSync(mapSrc, dest + ".map");
|
|
1308
|
+
}
|
|
1309
|
+
} catch (err) {
|
|
1310
|
+
console.error(` Warning: Could not copy hook ${file}: ${err instanceof Error ? err.message : err}`);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
console.log(` Hook scripts copied to ${localHooksDir}`);
|
|
1069
1314
|
const claudeDir = path5.join(projectRoot, ".claude");
|
|
1070
1315
|
const settingsPath = path5.join(claudeDir, "settings.json");
|
|
1071
1316
|
fs5.mkdirSync(claudeDir, { recursive: true });
|
|
1072
1317
|
let settings = {};
|
|
1073
1318
|
try {
|
|
1074
|
-
|
|
1319
|
+
const existing = fs5.readFileSync(settingsPath, "utf-8");
|
|
1320
|
+
settings = JSON.parse(existing);
|
|
1321
|
+
console.log(` Merging into existing ${settingsPath}`);
|
|
1075
1322
|
} catch {
|
|
1076
1323
|
}
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
const
|
|
1082
|
-
const hooksDir = path5.resolve(
|
|
1083
|
-
path5.dirname(new URL(import.meta.url).pathname),
|
|
1084
|
-
"hooks"
|
|
1085
|
-
);
|
|
1086
|
-
const hooks = settings["hooks"] ?? {};
|
|
1324
|
+
if (!settings["hooks"] || typeof settings["hooks"] !== "object") {
|
|
1325
|
+
settings["hooks"] = {};
|
|
1326
|
+
}
|
|
1327
|
+
const hooks = settings["hooks"];
|
|
1328
|
+
const hooksDir = localHooksDir;
|
|
1087
1329
|
if (!hooks["SessionStart"]) hooks["SessionStart"] = [];
|
|
1088
1330
|
addHookIfMissing(hooks["SessionStart"], "SessionStart", {
|
|
1089
1331
|
matcher: "startup|resume",
|