cchubber 0.5.8 → 0.6.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.
@@ -0,0 +1,149 @@
1
+ // Words and numbers shared by every --vs surface (terminal, page, card, post), so they cannot drift apart.
2
+ // Everything here is computed from the reprice object; nothing is a hardcoded figure.
3
+
4
+ export const FINE_PRINT = "Same tokens on each model's list price, cache included. Another model would use a different number of tokens, so this compares prices, not outcomes.";
5
+
6
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
7
+
8
+ export const usd0 = (n) => '$' + Math.round(n).toLocaleString('en-US');
9
+ export const int = (n) => Math.round(n).toLocaleString('en-US');
10
+
11
+ /** '2026-09-30' -> '30 Sep 2026' */
12
+ export function dayLabel(iso) {
13
+ const d = new Date(iso + 'T00:00:00Z');
14
+ return isNaN(d) ? String(iso) : `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
15
+ }
16
+
17
+ /** epoch ms -> '30 Sep 2026, 22:14' in the user's own time zone */
18
+ export function localStamp(ms) {
19
+ const d = new Date(ms);
20
+ const hh = String(d.getHours()).padStart(2, '0');
21
+ const mm = String(d.getMinutes()).padStart(2, '0');
22
+ return `${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}, ${hh}:${mm}`;
23
+ }
24
+
25
+ export function period(rp) {
26
+ const dates = rp.dailyCum?.dates || [];
27
+ return dates.length ? { first: dates[0], last: dates[dates.length - 1] } : null;
28
+ }
29
+
30
+ export function periodLabel(rp) {
31
+ const p = period(rp);
32
+ if (!p) return '';
33
+ return p.first === p.last ? dayLabel(p.first) : `${dayLabel(p.first)} to ${dayLabel(p.last)}`;
34
+ }
35
+
36
+ export function totalTokens(rp) {
37
+ const t = rp.tokens;
38
+ return t.input + t.output + t.cacheRead + t.cacheWrite;
39
+ }
40
+
41
+ export function tokensLabel(n) {
42
+ return n >= 1e9 ? `${(n / 1e9).toFixed(1)} billion` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} million` : int(n);
43
+ }
44
+
45
+ /** What a model's row is sorted and quoted by: its price today (an intro price stays the price until it ends). */
46
+ export const low = (rp) => rp.models[rp.models.length - 1];
47
+ export const high = (rp) => rp.models[0];
48
+
49
+ export function readLine(rp) {
50
+ return `For every token Claude wrote, it read ${int(rp.readPerWrite)}.`;
51
+ }
52
+
53
+ export function cacheShareRange(rp) {
54
+ const pcts = rp.models.map(m => Math.round(m.cacheShare * 100));
55
+ return { lo: Math.min(...pcts), hi: Math.max(...pcts) };
56
+ }
57
+
58
+ export function cacheLine(rp) {
59
+ const { lo, hi } = cacheShareRange(rp);
60
+ return lo === hi ? `Cache reads and writes are ${lo}% of every bill above.` : `Cache reads and writes are ${lo}% to ${hi}% of every bill above.`;
61
+ }
62
+
63
+ // The plan cost is an estimate, never a reading of the user's invoices: the plan's US list price times the months billed.
64
+ export const PAID_FINE_PRINT = "The plan cost is an estimate: the plan's US list price (or the price you gave) before tax, times the months billed. Tax, currency, upgrades and billing dates change what you really paid. Type what you actually paid to replace it.";
65
+
66
+ // Templates the page script fills when the user types what they actually paid. {amt} and {x} are swapped for figures.
67
+ export const PAID_ACTUAL_TEXT = 'You paid {amt}.';
68
+ export const PAID_ACTUAL_POST = 'I paid {amt}.';
69
+ export const PAID_ACTUAL_CAPTION = 'what I paid';
70
+
71
+ /** { paid, estimate: true, text, post, caption } or null when no plan was detected. `months` comes from monthsBilled. */
72
+ export function paidFacts(plan, months) {
73
+ if (!plan) return null;
74
+ const paid = plan.monthlyUSD * months;
75
+ const span = `${months} month${months === 1 ? '' : 's'}`;
76
+ const isList = plan.key !== 'custom';
77
+ const detail = isList ? `${plan.name} at the US list price of ${usd0(plan.monthlyUSD)} a month, ${span}` : `${span} at ${usd0(plan.monthlyUSD)} a month, the price you gave`;
78
+ return {
79
+ paid,
80
+ estimate: true,
81
+ text: `Estimated plan cost: about ${usd0(paid)} (${detail}).`,
82
+ post: isList ? `Plan cost, estimated at US list price: ${usd0(paid)}.` : `Plan cost, estimated: ${usd0(paid)}.`,
83
+ caption: isList ? 'estimated plan cost at US list price' : 'estimated plan cost',
84
+ };
85
+ }
86
+
87
+ // 15 for 14.6, 3.5 for 3.46: one decimal only while it still says something.
88
+ export const timesLabel = (r) => (r >= 10 ? String(Math.round(r)) : r.toFixed(1).replace(/\.0$/, ''));
89
+
90
+ /** What you ran (Claude list prices) against the paid figure. `template` holds {x} for the page script. Null without both numbers. */
91
+ export function multipleFacts(ran, paid, estimate, x = null) {
92
+ if (!(ran > 0) || !(paid > 0)) return null;
93
+ const n = x ?? timesLabel(ran / paid);
94
+ const text = estimate
95
+ ? `What you ran, at Claude list prices, is about ${n} times that estimate.`
96
+ : `What you ran, at Claude list prices, is ${n} times what you paid.`;
97
+ return { times: ran / paid, text };
98
+ }
99
+
100
+ export function overrideRows(rp) {
101
+ return rp.models.filter(m => m.source === 'override');
102
+ }
103
+
104
+ /** 'Prices: LiteLLM, fetched 30 Sep 2026, 22:14; Gemini 4 Argon from blog.google, 30 Sep 2026.' */
105
+ export function pricesLine(rp) {
106
+ const head = rp.offline ? `offline, prices as of ${dayLabel(rp.snapshotDate || '2026-09-30')}` : `LiteLLM, fetched ${localStamp(rp.fetchedAt || Date.now())}`;
107
+ const extra = overrideRows(rp).map(m => `${m.label} from ${m.sourceName || m.sourceUrl}, ${dayLabel(m.checked)}`);
108
+ return `Prices: ${[head, ...extra].join('; ')}.`;
109
+ }
110
+
111
+ /** Text for the row of a model that has a price after an introductory period. */
112
+ export function introNote(m) {
113
+ return m.after ? `intro price, ${usd0(m.after.total)} after` : '';
114
+ }
115
+
116
+ /** Where a provider lists no cache price and the input price was used instead, as plain sentences. */
117
+ export function filledNotes(rp) {
118
+ const name = { cacheRead: 'cache read', cacheWrite: 'cache write' };
119
+ const groups = {};
120
+ for (const m of rp.models) for (const f of m.filled || []) (groups[f] = groups[f] || []).push(m.label);
121
+ return Object.entries(groups).map(([f, labels]) => `No separate ${name[f]} price listed for ${labels.join(', ')}, so the input price is used.`);
122
+ }
123
+
124
+ export const SHARE_CMD = 'npx cchubber';
125
+
126
+ /** Greedy fit: add optional lines while the whole post (plus the command) stays inside 280 characters. The page script repeats this. */
127
+ export function assemblePost(head, optional) {
128
+ let body = head;
129
+ for (const line of optional.filter(Boolean)) {
130
+ const next = `${body} ${line}`;
131
+ if ((next + '\n\n' + SHARE_CMD).length <= 280) body = next;
132
+ }
133
+ if ((body + '\n\n' + SHARE_CMD).length > 280) body = head;
134
+ return `${body}\n\n${SHARE_CMD}`;
135
+ }
136
+
137
+ export function postHead(rp) {
138
+ const lo = low(rp), hi = high(rp);
139
+ return `My Claude Code tokens, repriced on ${rp.models.length} frontier models: ${usd0(lo.total)} (${lo.label}) to ${usd0(hi.total)} (${hi.label}).`;
140
+ }
141
+
142
+ export function driftPost(driftPct) {
143
+ return driftPct != null ? `${driftPct}% of my last week went to what I said I'd do.` : null;
144
+ }
145
+
146
+ /** The text for Copy text and Post on X: one text, kept inside 280 characters by adding lines only while they fit. */
147
+ export function postText(rp, { plan = null, months = 1, driftPct = null } = {}) {
148
+ return assemblePost(postHead(rp), [readLine(rp), paidFacts(plan, months)?.post, driftPost(driftPct)]);
149
+ }
package/src/telemetry.js CHANGED
@@ -19,10 +19,22 @@ function execSync(cmd, opts = {}) {
19
19
 
20
20
  const TELEMETRY_URL = process.env.CC_HUBBER_TELEMETRY_URL || 'https://cchubber-telemetry.asmirkhan087.workers.dev/collect';
21
21
 
22
- export function shouldSendTelemetry(flags) {
22
+ // The opt-outs only. The 24-hour throttle below limits the automatic stats send, not whether the user agreed to telemetry.
23
+ export function telemetryEnabled(flags = {}) {
23
24
  if (flags.noTelemetry) return false;
24
25
  if (process.env.CC_HUBBER_TELEMETRY === '0') return false;
25
26
  if (process.env.DO_NOT_TRACK === '1') return false;
27
+ return true;
28
+ }
29
+
30
+ /** What the report needs to send its three button counts: { on: false }, or the address, the anonymous id and the version. */
31
+ export function beaconConfig(flags = {}) {
32
+ if (!telemetryEnabled(flags)) return { on: false };
33
+ return { on: true, url: TELEMETRY_URL, uid: getOrCreateUID(), v: PKG_VERSION };
34
+ }
35
+
36
+ export function shouldSendTelemetry(flags) {
37
+ if (!telemetryEnabled(flags)) return false;
26
38
 
27
39
  // Throttle: once per 24 hours per machine
28
40
  const stampFile = join(homedir(), '.cchubber-last-telemetry');