burnwatch 0.9.0 → 0.10.1
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/dist/cli.js +240 -19
- package/dist/cli.js.map +1 -1
- package/dist/hooks/on-prompt.js.map +1 -1
- package/dist/hooks/on-session-start.js +141 -5
- package/dist/hooks/on-session-start.js.map +1 -1
- package/dist/hooks/on-stop.js +139 -3
- package/dist/hooks/on-stop.js.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.js +141 -5
- package/dist/mcp-server.js.map +1 -1
- package/package.json +1 -1
- package/skills/burnwatch-interview/SKILL.md +76 -51
package/dist/cli.js
CHANGED
|
@@ -767,13 +767,151 @@ var scrapflyConnector = {
|
|
|
767
767
|
}
|
|
768
768
|
};
|
|
769
769
|
|
|
770
|
+
// src/services/supabase.ts
|
|
771
|
+
init_base();
|
|
772
|
+
var supabaseConnector = {
|
|
773
|
+
serviceId: "supabase",
|
|
774
|
+
async fetchSpend(token) {
|
|
775
|
+
const orgsResult = await fetchJson("https://api.supabase.com/v1/organizations", {
|
|
776
|
+
headers: {
|
|
777
|
+
Authorization: `Bearer ${token}`
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
if (!orgsResult.ok || !orgsResult.data) {
|
|
781
|
+
return {
|
|
782
|
+
serviceId: "supabase",
|
|
783
|
+
spend: 0,
|
|
784
|
+
isEstimate: true,
|
|
785
|
+
tier: "est",
|
|
786
|
+
error: orgsResult.error ?? "Failed to fetch Supabase orgs \u2014 is this a PAT (not service_role key)?"
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
const org = orgsResult.data[0];
|
|
790
|
+
if (!org?.id) {
|
|
791
|
+
return {
|
|
792
|
+
serviceId: "supabase",
|
|
793
|
+
spend: 0,
|
|
794
|
+
isEstimate: true,
|
|
795
|
+
tier: "est",
|
|
796
|
+
error: "No Supabase organization found"
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
const planName = org.billing?.plan ?? "unknown";
|
|
800
|
+
const planCosts = {
|
|
801
|
+
free: 0,
|
|
802
|
+
pro: 25,
|
|
803
|
+
team: 599,
|
|
804
|
+
enterprise: 0
|
|
805
|
+
// custom pricing
|
|
806
|
+
};
|
|
807
|
+
const baseCost = planCosts[planName.toLowerCase()] ?? 0;
|
|
808
|
+
let totalSpend = baseCost;
|
|
809
|
+
const usageResult = await fetchJson(`https://api.supabase.com/v1/organizations/${org.id}/usage`, {
|
|
810
|
+
headers: {
|
|
811
|
+
Authorization: `Bearer ${token}`
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
if (usageResult.ok && usageResult.data) {
|
|
815
|
+
if (usageResult.data.usage) {
|
|
816
|
+
const overageCost = usageResult.data.usage.reduce(
|
|
817
|
+
(sum, item) => sum + (item.cost ?? 0),
|
|
818
|
+
0
|
|
819
|
+
);
|
|
820
|
+
if (overageCost > 0) totalSpend = baseCost + overageCost;
|
|
821
|
+
} else if (usageResult.data.total_usage !== void 0) {
|
|
822
|
+
totalSpend = usageResult.data.total_usage;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
serviceId: "supabase",
|
|
827
|
+
spend: totalSpend,
|
|
828
|
+
isEstimate: false,
|
|
829
|
+
tier: "live",
|
|
830
|
+
raw: {
|
|
831
|
+
plan: planName,
|
|
832
|
+
base_cost: baseCost,
|
|
833
|
+
org_id: org.id,
|
|
834
|
+
org_name: org.name,
|
|
835
|
+
...usageResult.data ?? {}
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
// src/services/browserbase.ts
|
|
842
|
+
init_base();
|
|
843
|
+
var browserbaseConnector = {
|
|
844
|
+
serviceId: "browserbase",
|
|
845
|
+
async fetchSpend(apiKey) {
|
|
846
|
+
const projectsResult = await fetchJson("https://api.browserbase.com/v1/projects", {
|
|
847
|
+
headers: {
|
|
848
|
+
"X-BB-API-Key": apiKey
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
if (!projectsResult.ok || !projectsResult.data) {
|
|
852
|
+
return {
|
|
853
|
+
serviceId: "browserbase",
|
|
854
|
+
spend: 0,
|
|
855
|
+
isEstimate: true,
|
|
856
|
+
tier: "est",
|
|
857
|
+
error: projectsResult.error ?? "Failed to fetch Browserbase projects"
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
const project = projectsResult.data[0];
|
|
861
|
+
if (!project?.id) {
|
|
862
|
+
return {
|
|
863
|
+
serviceId: "browserbase",
|
|
864
|
+
spend: 0,
|
|
865
|
+
isEstimate: true,
|
|
866
|
+
tier: "est",
|
|
867
|
+
error: "No Browserbase project found"
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
const usageResult = await fetchJson(`https://api.browserbase.com/v1/projects/${project.id}/usage`, {
|
|
871
|
+
headers: {
|
|
872
|
+
"X-BB-API-Key": apiKey
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
if (!usageResult.ok || !usageResult.data) {
|
|
876
|
+
return {
|
|
877
|
+
serviceId: "browserbase",
|
|
878
|
+
spend: 0,
|
|
879
|
+
isEstimate: true,
|
|
880
|
+
tier: "est",
|
|
881
|
+
error: "Projects found but usage endpoint failed"
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
const minutes = usageResult.data.total_minutes ?? usageResult.data.usage?.minutes ?? (usageResult.data.total_hours ?? usageResult.data.usage?.hours ?? 0) * 60;
|
|
885
|
+
const sessionCount = usageResult.data.total_sessions ?? usageResult.data.usage?.sessions ?? 0;
|
|
886
|
+
const minuteRate = 0.1;
|
|
887
|
+
const spend = minutes * minuteRate;
|
|
888
|
+
return {
|
|
889
|
+
serviceId: "browserbase",
|
|
890
|
+
spend,
|
|
891
|
+
isEstimate: true,
|
|
892
|
+
// rate may vary by plan
|
|
893
|
+
tier: "est",
|
|
894
|
+
unitsUsed: sessionCount,
|
|
895
|
+
unitName: "sessions",
|
|
896
|
+
raw: {
|
|
897
|
+
minutes,
|
|
898
|
+
sessions: sessionCount,
|
|
899
|
+
minute_rate: minuteRate,
|
|
900
|
+
...usageResult.data
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
|
|
770
906
|
// src/services/index.ts
|
|
771
907
|
init_base();
|
|
772
908
|
var connectors = /* @__PURE__ */ new Map([
|
|
773
909
|
["anthropic", anthropicConnector],
|
|
774
910
|
["openai", openaiConnector],
|
|
775
911
|
["vercel", vercelConnector],
|
|
776
|
-
["scrapfly", scrapflyConnector]
|
|
912
|
+
["scrapfly", scrapflyConnector],
|
|
913
|
+
["supabase", supabaseConnector],
|
|
914
|
+
["browserbase", browserbaseConnector]
|
|
777
915
|
]);
|
|
778
916
|
async function pollService(tracked) {
|
|
779
917
|
const globalConfig = readGlobalConfig();
|
|
@@ -862,7 +1000,7 @@ function formatBrief(brief) {
|
|
|
862
1000
|
);
|
|
863
1001
|
lines.push(`\u2551 ${hrSingle} \u2551`);
|
|
864
1002
|
for (const svc of brief.services) {
|
|
865
|
-
const spendStr = svc.isEstimate ? `~$${svc.spend.toFixed(2)}` : `$${svc.spend.toFixed(2)}`;
|
|
1003
|
+
const spendStr = svc.tier === "blind" && svc.spend === 0 ? "\u2014" : svc.isEstimate ? `~$${svc.spend.toFixed(2)}` : `$${svc.spend.toFixed(2)}`;
|
|
866
1004
|
const badge = CONFIDENCE_BADGES[svc.tier];
|
|
867
1005
|
const budgetStr = svc.budget ? `$${svc.budget}` : "\u2014";
|
|
868
1006
|
const leftStr = formatLeft(svc);
|
|
@@ -880,7 +1018,7 @@ function formatBrief(brief) {
|
|
|
880
1018
|
lines.push(`\u2560${hrDouble}\u2563`);
|
|
881
1019
|
const totalStr = brief.totalIsEstimate ? `~$${brief.totalSpend.toFixed(2)}` : `$${brief.totalSpend.toFixed(2)}`;
|
|
882
1020
|
const marginStr = brief.estimateMargin > 0 ? ` Est margin: \xB1$${brief.estimateMargin.toFixed(0)}` : "";
|
|
883
|
-
const untrackedStr = brief.untrackedCount > 0 ? `
|
|
1021
|
+
const untrackedStr = brief.untrackedCount > 0 ? `No billing data: ${brief.untrackedCount} \u26A0\uFE0F` : `All tracked \u2705`;
|
|
884
1022
|
lines.push(
|
|
885
1023
|
`\u2551 TOTAL: ${totalStr} ${untrackedStr}${marginStr}`.padEnd(
|
|
886
1024
|
width + 1
|
|
@@ -931,7 +1069,7 @@ function buildBrief(projectName, snapshots, blindCount) {
|
|
|
931
1069
|
alerts.push({
|
|
932
1070
|
serviceId: "_blind",
|
|
933
1071
|
type: "blind_service",
|
|
934
|
-
message: `${blindCount} service${blindCount > 1 ? "s" : ""}
|
|
1072
|
+
message: `${blindCount} service${blindCount > 1 ? "s" : ""} have no billing data \u2014 add API keys for live tracking`,
|
|
935
1073
|
severity: "warning"
|
|
936
1074
|
});
|
|
937
1075
|
}
|
|
@@ -964,7 +1102,7 @@ function buildSnapshot(serviceId, tier, spend, budget, allowanceData) {
|
|
|
964
1102
|
const isEstimate = tier === "est" || tier === "calc";
|
|
965
1103
|
const budgetPercent = budget ? spend / budget * 100 : void 0;
|
|
966
1104
|
let status = "unknown";
|
|
967
|
-
let statusLabel = "no budget";
|
|
1105
|
+
let statusLabel = tier === "blind" ? "needs API key" : "no budget";
|
|
968
1106
|
if (budget) {
|
|
969
1107
|
if (budgetPercent > 100) {
|
|
970
1108
|
status = "over";
|
|
@@ -1510,10 +1648,16 @@ async function cmdInit() {
|
|
|
1510
1648
|
console.log("\n\u{1F517} Registering Claude Code hooks...\n");
|
|
1511
1649
|
registerHooks(projectRoot);
|
|
1512
1650
|
console.log("\nburnwatch initialized.\n");
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1651
|
+
if (process.stdin.isTTY) {
|
|
1652
|
+
console.log("Next steps:");
|
|
1653
|
+
console.log(" burnwatch status Show current spend");
|
|
1654
|
+
console.log(" burnwatch add <svc> Update a service's budget or API key");
|
|
1655
|
+
console.log(" burnwatch init Re-run this setup anytime\n");
|
|
1656
|
+
} else {
|
|
1657
|
+
console.log("Next steps:");
|
|
1658
|
+
console.log(" Ask your agent to run /burnwatch-interview for guided setup");
|
|
1659
|
+
console.log(" Or run 'burnwatch status' to see current spend\n");
|
|
1660
|
+
}
|
|
1517
1661
|
}
|
|
1518
1662
|
async function cmdInterview() {
|
|
1519
1663
|
const projectRoot = process.cwd();
|
|
@@ -1541,24 +1685,62 @@ async function cmdInterview() {
|
|
|
1541
1685
|
const config = readProjectConfig(projectRoot);
|
|
1542
1686
|
const globalConfig = readGlobalConfig();
|
|
1543
1687
|
const allRegistryServices = getAllServices(projectRoot);
|
|
1688
|
+
const connectorServices = ["anthropic", "openai", "vercel", "scrapfly", "supabase", "browserbase"];
|
|
1544
1689
|
const serviceStates = [];
|
|
1545
1690
|
for (const [serviceId, tracked] of Object.entries(config.services)) {
|
|
1546
1691
|
const definition = allRegistryServices.find((s) => s.id === serviceId);
|
|
1547
1692
|
if (!definition) continue;
|
|
1548
1693
|
let keySource = null;
|
|
1694
|
+
const envKeysFound = [];
|
|
1549
1695
|
const globalKey = globalConfig.services[serviceId]?.apiKey;
|
|
1550
|
-
if (globalKey)
|
|
1551
|
-
|
|
1696
|
+
if (globalKey) {
|
|
1697
|
+
keySource = "global_config";
|
|
1698
|
+
} else {
|
|
1552
1699
|
for (const pattern of definition.envPatterns) {
|
|
1553
1700
|
if (process.env[pattern]) {
|
|
1554
1701
|
keySource = `env:${pattern}`;
|
|
1702
|
+
envKeysFound.push(pattern);
|
|
1555
1703
|
break;
|
|
1556
1704
|
}
|
|
1557
1705
|
}
|
|
1706
|
+
if (!keySource) {
|
|
1707
|
+
const envFiles = [".env", ".env.local", ".env.development"];
|
|
1708
|
+
for (const envFile of envFiles) {
|
|
1709
|
+
try {
|
|
1710
|
+
const envPath = path5.join(projectRoot, envFile);
|
|
1711
|
+
const envContent = fs5.readFileSync(envPath, "utf-8");
|
|
1712
|
+
for (const pattern of definition.envPatterns) {
|
|
1713
|
+
const regex = new RegExp(`^${pattern}=(.+)$`, "m");
|
|
1714
|
+
const match = envContent.match(regex);
|
|
1715
|
+
if (match?.[1]) {
|
|
1716
|
+
keySource = `file:${envFile}:${pattern}`;
|
|
1717
|
+
envKeysFound.push(`${pattern} (in ${envFile})`);
|
|
1718
|
+
break;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
if (keySource) break;
|
|
1722
|
+
} catch {
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
let apiKey = globalKey;
|
|
1728
|
+
if (!apiKey && keySource?.startsWith("env:")) {
|
|
1729
|
+
apiKey = process.env[keySource.slice(4)];
|
|
1730
|
+
}
|
|
1731
|
+
if (!apiKey && keySource?.startsWith("file:")) {
|
|
1732
|
+
const parts = keySource.split(":");
|
|
1733
|
+
const envFile = parts[1];
|
|
1734
|
+
const envVar = parts[2];
|
|
1735
|
+
try {
|
|
1736
|
+
const envContent = fs5.readFileSync(path5.join(projectRoot, envFile), "utf-8");
|
|
1737
|
+
const match = envContent.match(new RegExp(`^${envVar}=(.+)$`, "m"));
|
|
1738
|
+
if (match?.[1]) apiKey = match[1].trim().replace(/^["']|["']$/g, "");
|
|
1739
|
+
} catch {
|
|
1740
|
+
}
|
|
1558
1741
|
}
|
|
1559
1742
|
let probeResult = null;
|
|
1560
1743
|
const { probeService: probe, hasProbe: checkProbe } = await Promise.resolve().then(() => (init_probes(), probes_exports));
|
|
1561
|
-
const apiKey = globalKey ?? (keySource?.startsWith("env:") ? process.env[keySource.slice(4)] : void 0);
|
|
1562
1744
|
if (apiKey && checkProbe(serviceId)) {
|
|
1563
1745
|
probeResult = await probe(serviceId, apiKey, definition.plans ?? []);
|
|
1564
1746
|
}
|
|
@@ -1581,6 +1763,24 @@ async function cmdInterview() {
|
|
|
1581
1763
|
upstash: "email:api_key from console.upstash.com \u2192 Account \u2192 Management API",
|
|
1582
1764
|
posthog: "Personal API key from posthog.com \u2192 Settings \u2192 Personal API Keys"
|
|
1583
1765
|
};
|
|
1766
|
+
const hasConnector = connectorServices.includes(serviceId);
|
|
1767
|
+
const canGoLive = hasConnector && !tracked.hasApiKey && !apiKey;
|
|
1768
|
+
let suggestedAction;
|
|
1769
|
+
if (tracked.excluded) {
|
|
1770
|
+
suggestedAction = "excluded \u2014 skip";
|
|
1771
|
+
} else if (probeResult && probeResult.confidence === "high") {
|
|
1772
|
+
suggestedAction = `confirm \u2014 probe detected ${probeResult.summary}`;
|
|
1773
|
+
} else if (apiKey && hasConnector) {
|
|
1774
|
+
suggestedAction = "configure with found key \u2014 LIVE tracking available";
|
|
1775
|
+
} else if (apiKey && !hasConnector) {
|
|
1776
|
+
suggestedAction = "key found but no billing connector \u2014 set plan cost for CALC tracking";
|
|
1777
|
+
} else if (canGoLive) {
|
|
1778
|
+
suggestedAction = `ask for API key \u2014 LIVE tracking possible (${keyHints[serviceId] ?? "check service dashboard"})`;
|
|
1779
|
+
} else if (!hasConnector) {
|
|
1780
|
+
suggestedAction = "no billing API \u2014 ask for plan tier and set budget as alert threshold";
|
|
1781
|
+
} else {
|
|
1782
|
+
suggestedAction = "ask user for plan details";
|
|
1783
|
+
}
|
|
1584
1784
|
serviceStates.push({
|
|
1585
1785
|
serviceId,
|
|
1586
1786
|
serviceName: definition.name,
|
|
@@ -1591,6 +1791,10 @@ async function cmdInterview() {
|
|
|
1591
1791
|
tier,
|
|
1592
1792
|
excluded: tracked.excluded ?? false,
|
|
1593
1793
|
hasProbe: checkProbe(serviceId),
|
|
1794
|
+
hasConnector,
|
|
1795
|
+
canGoLive,
|
|
1796
|
+
envKeysFound,
|
|
1797
|
+
suggestedAction,
|
|
1594
1798
|
probeResult,
|
|
1595
1799
|
availablePlans: (definition.plans ?? []).map((p, i) => ({
|
|
1596
1800
|
index: i + 1,
|
|
@@ -1618,8 +1822,14 @@ async function cmdInterview() {
|
|
|
1618
1822
|
totalBudget: serviceStates.reduce((sum, s) => sum + (s.currentBudget ?? 0), 0),
|
|
1619
1823
|
liveCount: serviceStates.filter((s) => s.tier === "live").length,
|
|
1620
1824
|
blindCount: serviceStates.filter((s) => s.tier === "blind").length,
|
|
1825
|
+
canGoLiveCount: serviceStates.filter((s) => s.canGoLive).length,
|
|
1826
|
+
keysFoundInEnv: serviceStates.filter((s) => s.envKeysFound.length > 0).length,
|
|
1621
1827
|
services: serviceStates,
|
|
1622
|
-
|
|
1828
|
+
instructions: {
|
|
1829
|
+
keyStorage: "burnwatch stores API keys in ~/.config/burnwatch/ (chmod 600) \u2014 never in the project directory. Use --key flag with configure to save keys securely.",
|
|
1830
|
+
liveTracking: "Services with hasConnector=true can do LIVE billing tracking when given an API key. Services without a connector can only do CALC (budget threshold alerts).",
|
|
1831
|
+
configureCommand: "burnwatch configure --service <id> [--plan <name>] [--budget <N>] [--key <KEY>] [--exclude]"
|
|
1832
|
+
}
|
|
1623
1833
|
};
|
|
1624
1834
|
if (flags.has("--json")) {
|
|
1625
1835
|
console.log(JSON.stringify(output, null, 2));
|
|
@@ -1744,16 +1954,27 @@ async function cmdConfigure() {
|
|
|
1744
1954
|
}
|
|
1745
1955
|
config.services[serviceId] = tracked;
|
|
1746
1956
|
writeProjectConfig(config, projectRoot);
|
|
1957
|
+
const connectorServices = ["anthropic", "openai", "vercel", "scrapfly", "supabase", "browserbase"];
|
|
1958
|
+
const hasConnector = connectorServices.includes(serviceId);
|
|
1747
1959
|
let tier = "blind";
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1960
|
+
let tierNote = null;
|
|
1961
|
+
if (tracked.excluded) {
|
|
1962
|
+
tier = "excluded";
|
|
1963
|
+
} else if (tracked.hasApiKey && hasConnector) {
|
|
1964
|
+
tier = "live";
|
|
1965
|
+
} else if (tracked.hasApiKey && !hasConnector) {
|
|
1966
|
+
tier = "calc";
|
|
1967
|
+
tierNote = `Key saved but ${serviceId} has no billing connector yet \u2014 tracking as CALC. The key will be used for probing during interviews.`;
|
|
1968
|
+
} else if (tracked.planCost !== void 0) {
|
|
1969
|
+
tier = "calc";
|
|
1970
|
+
}
|
|
1751
1971
|
const result = {
|
|
1752
1972
|
success: true,
|
|
1753
1973
|
serviceId,
|
|
1754
1974
|
plan: tracked.planName ?? null,
|
|
1755
1975
|
budget: tracked.budget ?? null,
|
|
1756
1976
|
tier,
|
|
1977
|
+
tierNote,
|
|
1757
1978
|
hasApiKey: tracked.hasApiKey,
|
|
1758
1979
|
allowance: tracked.allowance ?? null
|
|
1759
1980
|
};
|
|
@@ -1858,12 +2079,12 @@ async function cmdStatus() {
|
|
|
1858
2079
|
console.log(formatBrief(brief));
|
|
1859
2080
|
console.log("");
|
|
1860
2081
|
if (blindCount > 0) {
|
|
1861
|
-
console.log(`\u26A0\uFE0F ${blindCount} service${blindCount > 1 ? "s" : ""}
|
|
2082
|
+
console.log(`\u26A0\uFE0F ${blindCount} service${blindCount > 1 ? "s" : ""} with no billing data:`);
|
|
1862
2083
|
for (const snap of snapshots.filter((s) => s.tier === "blind")) {
|
|
1863
|
-
console.log(` \u2022 ${snap.serviceId}`);
|
|
2084
|
+
console.log(` \u2022 ${snap.serviceId} \u2014 add an API key for live tracking`);
|
|
1864
2085
|
}
|
|
1865
2086
|
console.log(`
|
|
1866
|
-
Run 'burnwatch
|
|
2087
|
+
Run 'burnwatch configure --service <id> --key <KEY>' to enable live billing.
|
|
1867
2088
|
`);
|
|
1868
2089
|
}
|
|
1869
2090
|
}
|