burnwatch 0.5.2 → 0.7.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,6 +5,48 @@ 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.7.0] - 2026-03-25
9
+
10
+ ### Added
11
+
12
+ - **Service probing system**: New extensible `probes.ts` module that auto-detects plan tiers, usage, and billing data from service APIs. Adding a new service probe is a single function — the system supports N services. Probes for 9 of 14 services:
13
+ - **Scrapfly**: Plan name + credit usage (high confidence — skips plan selection)
14
+ - **Vercel**: Plan tier from team/user API (high confidence)
15
+ - **Supabase**: Plan tier from Management API (high confidence, requires PAT)
16
+ - **Anthropic**: Current month USD spend from Admin API cost report (medium — shows spend, still asks plan)
17
+ - **OpenAI**: Token usage from Admin API (medium)
18
+ - **Stripe**: Balance and processing volume (medium)
19
+ - **Browserbase**: Session count and browser hours (medium)
20
+ - **Upstash**: Database discovery (low — validates key)
21
+ - **PostHog**: Organization discovery (low — validates key)
22
+ - **Tiered discovery in interview**: Init now follows API → key → ask → hedge:
23
+ 1. Find API key (env vars, global config)
24
+ 2. If key found + probe exists → hit the API, show what we found
25
+ 3. High confidence → "Detected: Pro ($100/mo, 1M credits). Correct? [Y/n]"
26
+ 4. Medium confidence → show usage data, then ask plan
27
+ 5. No key → show plan list, ask for key after
28
+ 6. Budget always set
29
+ - **Auto-configure probing**: Non-TTY mode (Claude Code) also probes APIs when keys are available, auto-matching detected plans instead of always using defaults.
30
+
31
+ ### Changed
32
+
33
+ - **Interview no longer requires `autoDetectPlan` in registry**: Any service with a probe in `PROBES` map is automatically probed when a key is available. Adding a service-specific probe is the only requirement.
34
+ - **Key detection runs before plan selection for all services**: Previously only LIVE-tier services checked for keys. Now any service with a probe gets key detection first.
35
+
36
+ ## [0.6.0] - 2026-03-24
37
+
38
+ ### Added
39
+
40
+ - **Allowance tracking for credit-pool services**: Services like Scrapfly that sell a fixed credit pool (e.g., Pro $100/mo = 1M credits) now track unit consumption against the plan allowance, not just dollar spend. The brief shows `↳ 850K/1M credits (85%) ⚠️` alongside the dollar line. This is the distinction between "budget" (what you pay) and "spend metric" (what you consume).
41
+ - **Allowance data in spend snapshots**: `SpendSnapshot.allowance` provides `used`, `included`, `unitName`, and `percent` for credit-pool services with LIVE connectors.
42
+ - **PlanTier `includedUnits` and `unitName`**: Registry plans can now declare how many units are included (e.g., `"includedUnits": 1000000, "unitName": "credits"`). Init automatically sets `TrackedService.allowance` from plan selection.
43
+
44
+ ### Changed
45
+
46
+ - **Scrapfly registry updated to real pricing**: Discovery $30/200K, Pro $100/1M (default), Startup $250/2.5M, Enterprise $500/5.5M. Previously had incorrect plan names and prices.
47
+ - **Scrapfly connector returns unit data**: `BillingResult` now includes `unitsUsed`, `unitsTotal`, and `unitName` so the brief can show credit consumption alongside dollar spend.
48
+ - **Allowance-aware status labels**: Credit-pool services show "750K credits left" or "⚠️ 125% of 1M credits used" instead of generic budget percentages.
49
+
8
50
  ## [0.5.2] - 2026-03-24
9
51
 
10
52
  ### Changed
@@ -79,6 +121,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
79
121
  - Snapshot system for delta computation across sessions
80
122
  - Claude Code skills: `/spend` (on-demand brief), `/setup-burnwatch` (guided onboarding)
81
123
 
124
+ [0.7.0]: https://github.com/RaleighSF/burnwatch/compare/v0.6.0...v0.7.0
125
+ [0.6.0]: https://github.com/RaleighSF/burnwatch/compare/v0.5.2...v0.6.0
82
126
  [0.5.2]: https://github.com/RaleighSF/burnwatch/compare/v0.5.1...v0.5.2
83
127
  [0.5.1]: https://github.com/RaleighSF/burnwatch/compare/v0.5.0...v0.5.1
84
128
  [0.5.0]: https://github.com/RaleighSF/burnwatch/compare/v0.4.3...v0.5.0
package/dist/cli.js CHANGED
@@ -497,6 +497,9 @@ var scrapflyConnector = {
497
497
  spend,
498
498
  isEstimate: false,
499
499
  tier: "live",
500
+ unitsUsed: creditsUsed,
501
+ unitsTotal: creditsTotal,
502
+ unitName: "credits",
500
503
  raw: {
501
504
  credits_used: creditsUsed,
502
505
  credits_total: creditsTotal,
@@ -606,6 +609,15 @@ function formatBrief(brief) {
606
609
  const budgetStr = svc.budget ? `$${svc.budget}` : "\u2014";
607
610
  const leftStr = formatLeft(svc);
608
611
  lines.push(formatRow(svc.serviceId, spendStr, badge, budgetStr, leftStr, width));
612
+ if (svc.allowance) {
613
+ const usedStr = formatCompact(svc.allowance.used);
614
+ const totalStr2 = formatCompact(svc.allowance.included);
615
+ const pctStr = svc.allowance.percent.toFixed(0);
616
+ const warn = svc.allowance.percent >= 75 ? " \u26A0\uFE0F" : "";
617
+ lines.push(
618
+ `\u2551 \u21B3 ${usedStr}/${totalStr2} ${svc.allowance.unitName} (${pctStr}%)${warn}`.padEnd(width + 1) + "\u2551"
619
+ );
620
+ }
609
621
  }
610
622
  lines.push(`\u2560${hrDouble}\u2563`);
611
623
  const totalStr = brief.totalIsEstimate ? `~$${brief.totalSpend.toFixed(2)}` : `$${brief.totalSpend.toFixed(2)}`;
@@ -690,7 +702,7 @@ function formatLeft(snap) {
690
702
  }
691
703
  return "\u2014";
692
704
  }
693
- function buildSnapshot(serviceId, tier, spend, budget) {
705
+ function buildSnapshot(serviceId, tier, spend, budget, allowanceData) {
694
706
  const isEstimate = tier === "est" || tier === "calc";
695
707
  const budgetPercent = budget ? spend / budget * 100 : void 0;
696
708
  let status = "unknown";
@@ -707,7 +719,21 @@ function buildSnapshot(serviceId, tier, spend, budget) {
707
719
  statusLabel = `${(100 - budgetPercent).toFixed(0)}% \u2014 healthy`;
708
720
  }
709
721
  }
710
- if (tier === "calc" && budget) {
722
+ let allowance;
723
+ if (allowanceData && allowanceData.included > 0) {
724
+ const percent = allowanceData.used / allowanceData.included * 100;
725
+ allowance = { ...allowanceData, percent };
726
+ if (percent > 100) {
727
+ status = "over";
728
+ statusLabel = `\u26A0\uFE0F ${percent.toFixed(0)}% of ${formatCompact(allowanceData.included)} ${allowanceData.unitName} used`;
729
+ } else if (percent >= 75) {
730
+ status = "caution";
731
+ statusLabel = `${formatCompact(allowanceData.included - allowanceData.used)} ${allowanceData.unitName} left \u2014 caution`;
732
+ } else {
733
+ status = "healthy";
734
+ statusLabel = `${formatCompact(allowanceData.included - allowanceData.used)} ${allowanceData.unitName} left`;
735
+ }
736
+ } else if (tier === "calc" && budget) {
711
737
  statusLabel = `flat \u2014 on plan`;
712
738
  status = "healthy";
713
739
  }
@@ -720,9 +746,15 @@ function buildSnapshot(serviceId, tier, spend, budget) {
720
746
  budgetPercent,
721
747
  status,
722
748
  statusLabel,
723
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
749
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
750
+ allowance
724
751
  };
725
752
  }
753
+ function formatCompact(n) {
754
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1)}M`;
755
+ if (n >= 1e3) return `${(n / 1e3).toFixed(n % 1e3 === 0 ? 0 : 1)}K`;
756
+ return String(n);
757
+ }
726
758
 
727
759
  // src/core/ledger.ts
728
760
  import * as fs4 from "fs";
@@ -787,6 +819,240 @@ function saveSnapshot(brief, projectRoot) {
787
819
 
788
820
  // src/interactive-init.ts
789
821
  import * as readline from "readline";
822
+
823
+ // src/probes.ts
824
+ function matchPlanByPrefix(detected, plans) {
825
+ const lower = detected.toLowerCase();
826
+ return plans.find((p) => {
827
+ if (p.type === "exclude") return false;
828
+ const firstWord = p.name.split(/[\s(]/)[0].toLowerCase();
829
+ return lower.includes(firstWord) || firstWord.includes(lower);
830
+ });
831
+ }
832
+ var probeScrapfly = async (apiKey, plans) => {
833
+ const result = await fetchJson(`https://api.scrapfly.io/account?key=${apiKey}`);
834
+ if (!result.ok || !result.data) return null;
835
+ const planName = result.data.subscription?.plan?.name;
836
+ let unitsUsed = 0;
837
+ let unitsTotal = 0;
838
+ if (result.data.subscription?.usage?.scrape) {
839
+ unitsUsed = result.data.subscription.usage.scrape.used ?? 0;
840
+ unitsTotal = result.data.subscription.usage.scrape.allowed ?? 0;
841
+ } else if (result.data.account) {
842
+ unitsUsed = result.data.account.credits_used ?? 0;
843
+ unitsTotal = result.data.account.credits_total ?? 0;
844
+ }
845
+ const matched = planName ? matchPlanByPrefix(planName, plans) : void 0;
846
+ return {
847
+ planName: planName ?? void 0,
848
+ matchedPlan: matched,
849
+ usage: {
850
+ unitsUsed,
851
+ unitsTotal,
852
+ unitName: "credits"
853
+ },
854
+ summary: matched ? `${matched.name} \u2014 ${formatK(unitsUsed)}/${formatK(unitsTotal)} credits used` : `${formatK(unitsUsed)}/${formatK(unitsTotal)} credits used`,
855
+ confidence: matched ? "high" : "medium"
856
+ };
857
+ };
858
+ var probeAnthropic = async (apiKey, _plans) => {
859
+ const now = /* @__PURE__ */ new Date();
860
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
861
+ const params = new URLSearchParams({
862
+ start_date: startOfMonth.toISOString().split("T")[0],
863
+ end_date: now.toISOString().split("T")[0]
864
+ });
865
+ const result = await fetchJson(`https://api.anthropic.com/v1/organizations/cost_report?${params}`, {
866
+ headers: {
867
+ "x-api-key": apiKey,
868
+ "anthropic-version": "2023-06-01"
869
+ }
870
+ });
871
+ if (!result.ok || !result.data?.data) return null;
872
+ let totalCents = 0;
873
+ for (const entry of result.data.data) {
874
+ totalCents += parseFloat(entry.amount ?? "0");
875
+ }
876
+ const spend = totalCents / 100;
877
+ return {
878
+ usage: { spend, currency: "USD" },
879
+ summary: `$${spend.toFixed(2)} spent this billing period`,
880
+ confidence: "medium"
881
+ };
882
+ };
883
+ var probeOpenAI = async (apiKey, _plans) => {
884
+ const now = /* @__PURE__ */ new Date();
885
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
886
+ const params = new URLSearchParams({
887
+ start_time: String(Math.floor(startOfMonth.getTime() / 1e3)),
888
+ end_time: String(Math.floor(now.getTime() / 1e3))
889
+ });
890
+ const result = await fetchJson(`https://api.openai.com/v1/organization/usage/completions?${params}`, {
891
+ headers: { Authorization: `Bearer ${apiKey}` }
892
+ });
893
+ if (!result.ok || !result.data?.data) return null;
894
+ let totalTokens = 0;
895
+ for (const bucket of result.data.data) {
896
+ for (const r of bucket.results ?? []) {
897
+ totalTokens += r.amount?.value ?? 0;
898
+ }
899
+ }
900
+ return {
901
+ usage: { unitsUsed: totalTokens, unitName: "tokens" },
902
+ summary: `${formatK(totalTokens)} tokens used this period`,
903
+ confidence: "medium"
904
+ };
905
+ };
906
+ var probeVercel = async (apiKey, plans) => {
907
+ const teamsResult = await fetchJson("https://api.vercel.com/v2/teams", {
908
+ headers: { Authorization: `Bearer ${apiKey}` }
909
+ });
910
+ if (teamsResult.ok && teamsResult.data?.teams?.[0]) {
911
+ const team = teamsResult.data.teams[0];
912
+ const planName = team.billing?.plan;
913
+ if (planName) {
914
+ const matched = matchPlanByPrefix(planName, plans);
915
+ return {
916
+ planName,
917
+ matchedPlan: matched,
918
+ summary: `Team "${team.name}" on ${planName} plan`,
919
+ confidence: matched ? "high" : "medium"
920
+ };
921
+ }
922
+ }
923
+ const userResult = await fetchJson("https://api.vercel.com/v2/user", {
924
+ headers: { Authorization: `Bearer ${apiKey}` }
925
+ });
926
+ if (userResult.ok && userResult.data?.user) {
927
+ const plan = userResult.data.user.billing?.plan ?? "hobby";
928
+ const matched = matchPlanByPrefix(plan, plans);
929
+ return {
930
+ planName: plan,
931
+ matchedPlan: matched,
932
+ summary: `Personal account on ${plan} plan`,
933
+ confidence: matched ? "high" : "low"
934
+ };
935
+ }
936
+ return null;
937
+ };
938
+ var probeSupabase = async (apiKey, plans) => {
939
+ const orgsResult = await fetchJson("https://api.supabase.com/v1/organizations", {
940
+ headers: { Authorization: `Bearer ${apiKey}` }
941
+ });
942
+ if (!orgsResult.ok || !orgsResult.data || !Array.isArray(orgsResult.data)) return null;
943
+ const org = orgsResult.data[0];
944
+ if (!org) return null;
945
+ const planName = org.billing?.plan;
946
+ if (planName) {
947
+ const matched = matchPlanByPrefix(planName, plans);
948
+ return {
949
+ planName,
950
+ matchedPlan: matched,
951
+ summary: `Org "${org.name}" on ${planName} plan`,
952
+ confidence: matched ? "high" : "medium"
953
+ };
954
+ }
955
+ return {
956
+ summary: `Org "${org.name}" found (plan not detected)`,
957
+ confidence: "low"
958
+ };
959
+ };
960
+ var probeStripe = async (apiKey, _plans) => {
961
+ const result = await fetchJson("https://api.stripe.com/v1/balance", {
962
+ headers: { Authorization: `Bearer ${apiKey}` }
963
+ });
964
+ if (!result.ok || !result.data) return null;
965
+ const available = result.data.available?.[0];
966
+ const pending = result.data.pending?.[0];
967
+ const totalCents = (available?.amount ?? 0) + (pending?.amount ?? 0);
968
+ const currency = (available?.currency ?? "usd").toUpperCase();
969
+ return {
970
+ usage: { spend: totalCents / 100, currency },
971
+ summary: `Balance: ${currency} ${(totalCents / 100).toFixed(2)} (${((available?.amount ?? 0) / 100).toFixed(2)} available)`,
972
+ confidence: "medium"
973
+ };
974
+ };
975
+ var probeBrowserbase = async (apiKey, _plans) => {
976
+ const projResult = await fetchJson("https://api.browserbase.com/v1/projects", {
977
+ headers: { "X-BB-API-Key": apiKey }
978
+ });
979
+ if (!projResult.ok || !projResult.data?.[0]?.id) return null;
980
+ const projectId = projResult.data[0].id;
981
+ const usageResult = await fetchJson(`https://api.browserbase.com/v1/projects/${projectId}/usage`, {
982
+ headers: { "X-BB-API-Key": apiKey }
983
+ });
984
+ if (!usageResult.ok || !usageResult.data) {
985
+ return {
986
+ summary: `Project "${projResult.data[0].name}" found`,
987
+ confidence: "low"
988
+ };
989
+ }
990
+ const sessions = usageResult.data.sessions_count ?? 0;
991
+ const hours = usageResult.data.browser_hours ?? 0;
992
+ return {
993
+ usage: { unitsUsed: sessions, unitName: "sessions" },
994
+ summary: `${sessions} sessions, ${hours.toFixed(1)} browser hours this period`,
995
+ confidence: "medium"
996
+ };
997
+ };
998
+ var probeUpstash = async (apiKey, _plans) => {
999
+ const result = await fetchJson("https://api.upstash.com/v2/redis/databases", {
1000
+ headers: {
1001
+ Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`
1002
+ }
1003
+ });
1004
+ if (!result.ok) return null;
1005
+ const dbCount = Array.isArray(result.data) ? result.data.length : 0;
1006
+ return {
1007
+ summary: `${dbCount} Redis database${dbCount !== 1 ? "s" : ""} found`,
1008
+ confidence: "low"
1009
+ };
1010
+ };
1011
+ var probePostHog = async (apiKey, _plans) => {
1012
+ const result = await fetchJson("https://us.posthog.com/api/organizations/@current", {
1013
+ headers: { Authorization: `Bearer ${apiKey}` }
1014
+ });
1015
+ if (!result.ok || !result.data) return null;
1016
+ return {
1017
+ summary: "Organization found",
1018
+ confidence: "low"
1019
+ };
1020
+ };
1021
+ var PROBES = /* @__PURE__ */ new Map([
1022
+ ["scrapfly", probeScrapfly],
1023
+ ["anthropic", probeAnthropic],
1024
+ ["openai", probeOpenAI],
1025
+ ["vercel", probeVercel],
1026
+ ["supabase", probeSupabase],
1027
+ ["stripe", probeStripe],
1028
+ ["browserbase", probeBrowserbase],
1029
+ ["upstash", probeUpstash],
1030
+ ["posthog", probePostHog]
1031
+ ]);
1032
+ async function probeService(serviceId, apiKey, plans) {
1033
+ const probe = PROBES.get(serviceId);
1034
+ if (!probe) return null;
1035
+ try {
1036
+ return await probe(apiKey, plans);
1037
+ } catch {
1038
+ return null;
1039
+ }
1040
+ }
1041
+ function hasProbe(serviceId) {
1042
+ return PROBES.has(serviceId);
1043
+ }
1044
+ function formatK(n) {
1045
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1)}M`;
1046
+ if (n >= 1e3) return `${(n / 1e3).toFixed(n % 1e3 === 0 ? 0 : 1)}K`;
1047
+ return String(n);
1048
+ }
1049
+
1050
+ // src/interactive-init.ts
1051
+ function formatUnits(n) {
1052
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1)}M`;
1053
+ if (n >= 1e3) return `${(n / 1e3).toFixed(n % 1e3 === 0 ? 0 : 1)}K`;
1054
+ return String(n);
1055
+ }
790
1056
  var RISK_ORDER = ["llm", "usage", "infra", "flat"];
791
1057
  var RISK_LABELS = {
792
1058
  llm: "LLM / AI Services (highest variable cost)",
@@ -827,16 +1093,6 @@ function ask(rl, question) {
827
1093
  });
828
1094
  });
829
1095
  }
830
- async function autoDetectScrapflyPlan(apiKey) {
831
- try {
832
- const result = await fetchJson(`https://api.scrapfly.io/account?key=${apiKey}`);
833
- if (result.ok && result.data?.subscription?.plan?.name) {
834
- return result.data.subscription.plan.name;
835
- }
836
- } catch {
837
- }
838
- return null;
839
- }
840
1096
  function findEnvKey(service) {
841
1097
  for (const pattern of service.envPatterns) {
842
1098
  const val = process.env[pattern];
@@ -844,7 +1100,7 @@ function findEnvKey(service) {
844
1100
  }
845
1101
  return void 0;
846
1102
  }
847
- function autoConfigureServices(detected) {
1103
+ async function autoConfigureServices(detected) {
848
1104
  const services = {};
849
1105
  const groups = groupByRisk(detected);
850
1106
  const globalConfig = readGlobalConfig();
@@ -877,25 +1133,54 @@ function autoConfigureServices(detected) {
877
1133
  } else if (defaultPlan.suggestedBudget !== void 0) {
878
1134
  tracked.budget = defaultPlan.suggestedBudget;
879
1135
  }
1136
+ if (defaultPlan.includedUnits !== void 0 && defaultPlan.unitName) {
1137
+ tracked.allowance = {
1138
+ included: defaultPlan.includedUnits,
1139
+ unitName: defaultPlan.unitName
1140
+ };
1141
+ }
880
1142
  }
881
1143
  const existingKey = globalConfig.services[service.id]?.apiKey;
882
1144
  const envKey = findEnvKey(service);
883
1145
  let keySource = "";
1146
+ let apiKey;
884
1147
  if (existingKey) {
885
1148
  tracked.hasApiKey = true;
1149
+ apiKey = existingKey;
886
1150
  keySource = " (key: global config)";
887
1151
  } else if (envKey) {
888
1152
  tracked.hasApiKey = true;
1153
+ apiKey = envKey;
889
1154
  if (!globalConfig.services[service.id]) {
890
1155
  globalConfig.services[service.id] = {};
891
1156
  }
892
1157
  globalConfig.services[service.id].apiKey = envKey;
893
1158
  keySource = ` (key: ${service.envPatterns[0]})`;
894
1159
  }
1160
+ if (apiKey && hasProbe(service.id)) {
1161
+ try {
1162
+ const probe = await probeService(service.id, apiKey, plans);
1163
+ if (probe?.matchedPlan && probe.confidence === "high") {
1164
+ const mp = probe.matchedPlan;
1165
+ tracked.planName = mp.name;
1166
+ if (mp.type === "flat" && mp.monthlyBase !== void 0) {
1167
+ tracked.planCost = mp.monthlyBase;
1168
+ tracked.budget = mp.monthlyBase;
1169
+ } else if (mp.suggestedBudget !== void 0) {
1170
+ tracked.budget = mp.suggestedBudget;
1171
+ }
1172
+ if (mp.includedUnits !== void 0 && mp.unitName) {
1173
+ tracked.allowance = { included: mp.includedUnits, unitName: mp.unitName };
1174
+ }
1175
+ }
1176
+ } catch {
1177
+ }
1178
+ }
895
1179
  const tierLabel = tracked.hasApiKey ? "LIVE" : tracked.planCost !== void 0 ? "CALC" : "BLIND";
896
1180
  const planStr = tracked.planName ? ` ${tracked.planName}` : "";
1181
+ const trackingStr = tracked.allowance ? `$${tracked.budget}/mo | ${formatUnits(tracked.allowance.included)} ${tracked.allowance.unitName}` : `$${tracked.budget}/mo`;
897
1182
  console.log(
898
- ` ${service.name}:${planStr} | ${tierLabel} | $${tracked.budget}/mo${keySource}`
1183
+ ` ${service.name}:${planStr} | ${tierLabel} | ${trackingStr}${keySource}`
899
1184
  );
900
1185
  services[service.id] = tracked;
901
1186
  }
@@ -947,21 +1232,61 @@ async function runInteractiveInit(detected) {
947
1232
  console.log(" -> Configured (no plan tiers in registry, budget: $0)");
948
1233
  continue;
949
1234
  }
950
- const defaultIndex = plans.findIndex((p) => p.default);
951
- console.log("");
952
- for (let i = 0; i < plans.length; i++) {
953
- const plan = plans[i];
954
- const marker = i === defaultIndex ? " *" : "";
955
- const costStr = plan.type === "exclude" ? "" : plan.monthlyBase !== void 0 ? ` - $${plan.monthlyBase}/mo` : " - variable";
956
- console.log(` ${i + 1}) ${plan.name}${costStr}${marker}`);
1235
+ let apiKey;
1236
+ const existingKey = globalConfig.services[service.id]?.apiKey;
1237
+ const envKey = findEnvKey(service);
1238
+ if (existingKey) {
1239
+ apiKey = existingKey;
1240
+ console.log(` API key: found in global config`);
1241
+ } else if (envKey) {
1242
+ apiKey = envKey;
1243
+ console.log(` API key: found in environment (${service.envPatterns[0]})`);
1244
+ if (!globalConfig.services[service.id]) {
1245
+ globalConfig.services[service.id] = {};
1246
+ }
1247
+ globalConfig.services[service.id].apiKey = envKey;
1248
+ }
1249
+ let chosen;
1250
+ if (apiKey && hasProbe(service.id)) {
1251
+ console.log(" Probing API...");
1252
+ const probe = await probeService(service.id, apiKey, plans);
1253
+ if (probe) {
1254
+ console.log(` ${probe.summary}`);
1255
+ if (probe.confidence === "high" && probe.matchedPlan) {
1256
+ const plan = probe.matchedPlan;
1257
+ const costStr = plan.monthlyBase !== void 0 ? `$${plan.monthlyBase}/mo` : "variable";
1258
+ const unitsStr = plan.includedUnits && plan.unitName ? `, ${formatUnits(plan.includedUnits)} ${plan.unitName}` : "";
1259
+ const confirm = await ask(
1260
+ rl,
1261
+ ` Detected: ${plan.name} (${costStr}${unitsStr}). Correct? [Y/n]: `
1262
+ );
1263
+ if (confirm === "" || confirm.toLowerCase().startsWith("y")) {
1264
+ chosen = plan;
1265
+ }
1266
+ } else if (probe.confidence === "medium") {
1267
+ if (probe.usage?.spend !== void 0) {
1268
+ console.log(` Current spend: $${probe.usage.spend.toFixed(2)}`);
1269
+ }
1270
+ }
1271
+ }
1272
+ }
1273
+ if (!chosen) {
1274
+ const defaultIndex = plans.findIndex((p) => p.default);
1275
+ console.log("");
1276
+ for (let i = 0; i < plans.length; i++) {
1277
+ const plan = plans[i];
1278
+ const marker = i === defaultIndex ? " *" : "";
1279
+ const costStr = plan.type === "exclude" ? "" : plan.monthlyBase !== void 0 ? ` - $${plan.monthlyBase}/mo` : " - variable";
1280
+ console.log(` ${i + 1}) ${plan.name}${costStr}${marker}`);
1281
+ }
1282
+ const defaultChoice = defaultIndex >= 0 ? String(defaultIndex + 1) : "1";
1283
+ const answer = await ask(
1284
+ rl,
1285
+ ` Which plan? [${defaultChoice}]: `
1286
+ );
1287
+ const choiceIndex = (answer === "" ? parseInt(defaultChoice) : parseInt(answer)) - 1;
1288
+ chosen = plans[choiceIndex] ?? plans[defaultIndex >= 0 ? defaultIndex : 0];
957
1289
  }
958
- const defaultChoice = defaultIndex >= 0 ? String(defaultIndex + 1) : "1";
959
- const answer = await ask(
960
- rl,
961
- ` Which plan? [${defaultChoice}]: `
962
- );
963
- const choiceIndex = (answer === "" ? parseInt(defaultChoice) : parseInt(answer)) - 1;
964
- const chosen = plans[choiceIndex] ?? plans[defaultIndex >= 0 ? defaultIndex : 0];
965
1290
  if (chosen.type === "exclude") {
966
1291
  services[service.id] = {
967
1292
  serviceId: service.id,
@@ -977,49 +1302,37 @@ async function runInteractiveInit(detected) {
977
1302
  const tracked2 = {
978
1303
  serviceId: service.id,
979
1304
  detectedVia: det.sources,
980
- hasApiKey: false,
1305
+ hasApiKey: !!apiKey,
981
1306
  firstDetected: (/* @__PURE__ */ new Date()).toISOString(),
982
1307
  planName: chosen.name
983
1308
  };
984
1309
  if (chosen.type === "flat" && chosen.monthlyBase !== void 0) {
985
1310
  tracked2.planCost = chosen.monthlyBase;
986
1311
  }
987
- if (service.apiTier === "live") {
988
- const existingKey = globalConfig.services[service.id]?.apiKey;
989
- const envKey = findEnvKey(service);
990
- if (existingKey) {
991
- console.log(` API key: found in global config`);
992
- tracked2.hasApiKey = true;
993
- } else if (envKey) {
994
- console.log(` API key: found in environment (${service.envPatterns[0]})`);
1312
+ if (chosen.includedUnits !== void 0 && chosen.unitName) {
1313
+ tracked2.allowance = {
1314
+ included: chosen.includedUnits,
1315
+ unitName: chosen.unitName
1316
+ };
1317
+ }
1318
+ if (!apiKey && hasProbe(service.id)) {
1319
+ const hint = API_KEY_HINTS[service.id];
1320
+ if (hint) console.log(` ${hint}`);
1321
+ const keyAnswer = await ask(
1322
+ rl,
1323
+ ` API key for real-time tracking (Enter to skip): `
1324
+ );
1325
+ if (keyAnswer) {
995
1326
  tracked2.hasApiKey = true;
1327
+ apiKey = keyAnswer;
996
1328
  if (!globalConfig.services[service.id]) {
997
1329
  globalConfig.services[service.id] = {};
998
1330
  }
999
- globalConfig.services[service.id].apiKey = envKey;
1000
- } else {
1001
- const hint = API_KEY_HINTS[service.id];
1002
- if (hint) console.log(` ${hint}`);
1003
- const keyAnswer = await ask(
1004
- rl,
1005
- ` API key for real-time tracking (Enter to skip): `
1006
- );
1007
- if (keyAnswer) {
1008
- tracked2.hasApiKey = true;
1009
- if (!globalConfig.services[service.id]) {
1010
- globalConfig.services[service.id] = {};
1011
- }
1012
- globalConfig.services[service.id].apiKey = keyAnswer;
1013
- }
1014
- }
1015
- if (service.autoDetectPlan && service.id === "scrapfly" && tracked2.hasApiKey) {
1016
- const key = globalConfig.services[service.id]?.apiKey;
1017
- if (key) {
1018
- console.log(" Detecting plan from API...");
1019
- const planName = await autoDetectScrapflyPlan(key);
1020
- if (planName) {
1021
- console.log(` -> Detected plan: ${planName}`);
1022
- tracked2.planName = planName;
1331
+ globalConfig.services[service.id].apiKey = keyAnswer;
1332
+ if (hasProbe(service.id)) {
1333
+ const probe = await probeService(service.id, keyAnswer, plans);
1334
+ if (probe?.usage) {
1335
+ console.log(` ${probe.summary}`);
1023
1336
  }
1024
1337
  }
1025
1338
  }
@@ -1037,8 +1350,9 @@ async function runInteractiveInit(detected) {
1037
1350
  }
1038
1351
  services[service.id] = tracked2;
1039
1352
  const tierLabel = tracked2.hasApiKey ? "LIVE" : tracked2.planCost !== void 0 ? "CALC" : "BLIND";
1353
+ const allowanceStr = tracked2.allowance ? ` | ${formatUnits(tracked2.allowance.included)} ${tracked2.allowance.unitName}` : "";
1040
1354
  console.log(
1041
- ` -> ${service.name}: ${tracked2.planName} | ${tierLabel} | $${tracked2.budget}/mo`
1355
+ ` -> ${service.name}: ${tracked2.planName} | ${tierLabel} | $${tracked2.budget}/mo${allowanceStr}`
1042
1356
  );
1043
1357
  }
1044
1358
  }
@@ -1141,7 +1455,7 @@ async function cmdInit() {
1141
1455
  const result = await runInteractiveInit(detected);
1142
1456
  config.services = result.services;
1143
1457
  } else {
1144
- const result = autoConfigureServices(detected);
1458
+ const result = await autoConfigureServices(detected);
1145
1459
  config.services = result.services;
1146
1460
  }
1147
1461
  writeProjectConfig(config, projectRoot);
@@ -1252,14 +1566,11 @@ async function cmdStatus() {
1252
1566
  }
1253
1567
  console.log("\u{1F4CA} Polling services...\n");
1254
1568
  const results = await pollAllServices(trackedServices);
1255
- const snapshots = results.map(
1256
- (r) => buildSnapshot(
1257
- r.serviceId,
1258
- r.tier,
1259
- r.spend,
1260
- config.services[r.serviceId]?.budget
1261
- )
1262
- );
1569
+ const snapshots = results.map((r) => {
1570
+ const tracked = config.services[r.serviceId];
1571
+ const allowanceData = r.unitsUsed !== void 0 && r.unitsTotal !== void 0 && r.unitName ? { used: r.unitsUsed, included: r.unitsTotal, unitName: r.unitName } : tracked?.allowance ? { used: 0, included: tracked.allowance.included, unitName: tracked.allowance.unitName } : void 0;
1572
+ return buildSnapshot(r.serviceId, r.tier, r.spend, tracked?.budget, allowanceData);
1573
+ });
1263
1574
  const blindCount = snapshots.filter((s) => s.tier === "blind").length;
1264
1575
  const brief = buildBrief(config.projectName, snapshots, blindCount);
1265
1576
  saveSnapshot(brief, projectRoot);