cchubber 0.5.3 → 0.5.5

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/README.md CHANGED
@@ -9,9 +9,7 @@ Your Claude Code usage, diagnosed. One command.
9
9
  npx cchubber
10
10
  ```
11
11
 
12
- Reads your local data, generates an HTML report. No API keys, no accounts, runs entirely offline.
13
-
14
- Sends anonymous aggregate stats (grade, cache ratio, model split) once per day to help build community benchmarks. No tokens, no file contents, no project names. Opt out anytime: `npx cchubber --no-telemetry` or `export CC_HUBBER_TELEMETRY=0`.
12
+ Reads your local data, generates an HTML report. No API keys, no accounts. Sends anonymous stats for community benchmarks ([details](#telemetry)). Opt out: `--no-telemetry`.
15
13
 
16
14
  Built during the March 2026 cache crisis because nobody could tell if they'd been hit. Thousands of users burning through limits 10-20x faster than normal, and Anthropic's only answer was "we're investigating." We wanted receipts.
17
15
 
@@ -103,6 +101,35 @@ Everything is local. CC Hubber reads files that already exist on your machine.
103
101
 
104
102
  CC Hubber tells you why, and whether it's normal. Inflection detection, cache break estimation, model routing savings, session intelligence, trend-weighted grading. Different tools for different questions.
105
103
 
104
+ ## Telemetry
105
+
106
+ CC Hubber sends anonymous usage stats once per day to power community benchmarks. This is what makes the leaderboard rank, grade calibration, and community comparisons in your recommendations work. Without it, every user gets the same generic thresholds instead of real benchmarks.
107
+
108
+ **What's collected:**
109
+ - Cache health: grade, ratio, hit rate, break count
110
+ - Cost: bucketed range (e.g. "$500-1K"), not exact amounts
111
+ - Models: opus/sonnet/haiku split percentages
112
+ - Sessions: count, average duration, tool usage counts
113
+ - Config: CLAUDE.md token count, hook count, MCP server names, skill count
114
+ - Environment: OS, architecture, Node version, Claude Code version
115
+ - Tech stack: boolean flags (uses React, TypeScript, Tailwind, etc.)
116
+ - Anonymous machine ID (random hash, not tied to any account)
117
+
118
+ **What's NOT collected:**
119
+ - No file contents, project names, or directory paths
120
+ - No API keys, tokens, or credentials
121
+ - No conversation text or prompts
122
+ - No personal information (name, email, IP address)
123
+
124
+ **Opt out anytime:**
125
+ ```bash
126
+ npx cchubber --no-telemetry
127
+ # or permanently:
128
+ export CC_HUBBER_TELEMETRY=0
129
+ ```
130
+
131
+ Full source: [`src/telemetry.js`](src/telemetry.js)
132
+
106
133
  ## License
107
134
 
108
135
  MIT
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "cchubber",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "What you spent. Why you spent it. Is that normal. — Claude Code usage diagnosis with beautiful HTML reports.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cchubber": "./src/cli/index.js"
8
8
  },
9
9
  "scripts": {
10
- "start": "node src/cli/index.js"
10
+ "start": "node src/cli/index.js",
11
+ "test": "node test/smoke.js",
12
+ "release": "bash release.sh"
11
13
  },
12
14
  "keywords": [
13
15
  "claude",
@@ -748,7 +748,9 @@ ${cacheHealth.totalCacheBreaks > 0 ? `
748
748
 
749
749
  function chart(d){
750
750
  if(!svg)return;
751
- if(!d.length){svg.innerHTML='<text x="450" y="100" text-anchor="middle" fill="#908fa0" font-size="13" font-family="Inter,sans-serif">No data</text>';return}
751
+ if(!d.length){svg.innerHTML='<text x="450" y="100" text-anchor="middle" fill="#908fa0" font-size="13" font-family="Inter,sans-serif">No data for this period</text>';return}
752
+ var total=d.reduce(function(s,x){return s+x.cost},0);
753
+ if(total<0.01){svg.innerHTML='<text x="450" y="100" text-anchor="middle" fill="#908fa0" font-size="13" font-family="Inter,sans-serif">No cost data — costs are only calculated from token counts, not the costUSD field</text>';return}
752
754
  var mx=Math.max.apply(null,d.map(function(x){return x.cost}))*1.1;if(mx<0.01)mx=1;
753
755
  var s='';
754
756
  // grid lines
@@ -1062,6 +1064,7 @@ ${cacheHealth.totalCacheBreaks > 0 ? `
1062
1064
  // Community leaderboard — stats fetched at generation time, embedded as JSON
1063
1065
  var MY_RATIO = ${cacheHealth.efficiencyRatio || 0};
1064
1066
  var MY_GRADE = '${cacheHealth.grade?.letter || '?'}';
1067
+ var MY_COST = '${(() => { const c = totalCost; return c<10?'<10':c<50?'10-50':c<200?'50-200':c<500?'200-500':c<1000?'500-1K':c<5000?'1K-5K':'5K+'; })()}';
1065
1068
  var gradeColors = {A:'#10b981',B:'#22d3ee',C:'#f59e0b',D:'#f97316',F:'#ef4444'};
1066
1069
  var stats = ${JSON.stringify(report.communityStats || null)};
1067
1070
 
@@ -1123,20 +1126,24 @@ ${cacheHealth.totalCacheBreaks > 0 ? `
1123
1126
  });
1124
1127
 
1125
1128
  // Leaderboard table — re-graded, with user's position injected
1129
+ // Filter: minimum activity to qualify (exclude <$10 and $10-50 users — too little data for meaningful grade)
1130
+ var validCosts = {'50-200':1,'200-500':1,'500-1K':1,'1K-5K':1,'5K+':1};
1126
1131
  var tbody = document.getElementById('leaderboard-body');
1127
- var sorted = recent.filter(function(r){return r.ratio}).sort(function(a,b){return (a.ratio||9999)-(b.ratio||9999)});
1132
+ var sorted = recent.filter(function(r){return r.ratio && (validCosts[r.cost] || r.cost===MY_COST)}).sort(function(a,b){return (a.ratio||9999)-(b.ratio||9999)});
1128
1133
 
1129
- // Find where the user would rank
1134
+ // Insert user's own entry into the sorted list at the right position
1135
+ var myEntry = {ratio:MY_RATIO, grade:MY_GRADE, cost:MY_COST, opus:${report.modelRouting?.opusPct ?? 'null'}, country:'You', os:'${process.platform}', isMe:true};
1130
1136
  var myRank = sorted.findIndex(function(e){return (e.ratio||0) >= MY_RATIO});
1131
1137
  if(myRank < 0) myRank = sorted.length;
1138
+ sorted.splice(myRank, 0, myEntry);
1132
1139
 
1133
1140
  // Show top 10 + user's position (if not in top 10)
1134
1141
  var showIndices = [];
1135
1142
  for(var si=0;si<Math.min(10,sorted.length);si++) showIndices.push(si);
1136
1143
  var userInTop = myRank < 10;
1137
- if(!userInTop && myRank < sorted.length){
1144
+ if(!userInTop){
1138
1145
  showIndices.push(-1); // separator
1139
- if(myRank > 0) showIndices.push(myRank-1);
1146
+ if(myRank > 1) showIndices.push(myRank-1);
1140
1147
  showIndices.push(myRank);
1141
1148
  if(myRank+1 < sorted.length) showIndices.push(myRank+1);
1142
1149
  }
@@ -1145,28 +1152,28 @@ ${cacheHealth.totalCacheBreaks > 0 ? `
1145
1152
  for(var si2=0;si2<showIndices.length;si2++){
1146
1153
  var idx = showIndices[si2];
1147
1154
  if(idx === -1){
1148
- html += '<tr><td colspan="6" class="px-8 py-1 text-center text-[10px] text-[#908fa0]">···</td></tr>';
1155
+ html += '<tr><td colspan="6" class="px-8 py-1 text-center text-[10px] text-[#908fa0]">...</td></tr>';
1149
1156
  continue;
1150
1157
  }
1151
1158
  var entry = sorted[idx];
1152
1159
  if(!entry) continue;
1153
- var g = getGrade(entry);
1154
- var isMe = idx === myRank;
1160
+ var g = entry.isMe ? MY_GRADE : getGrade(entry);
1161
+ var isMe = entry.isMe;
1155
1162
  var rowStyle = isMe ? 'background:rgba(192,193,255,0.08);border-left:3px solid #c0c1ff;' : '';
1156
1163
  html += '<tr style="border-bottom:1px solid rgba(70,69,84,0.1);'+rowStyle+'">';
1157
1164
  html += '<td class="px-8 py-3 text-sm font-mono '+(isMe?'text-[#c0c1ff] font-bold':'text-[#908fa0]')+'">#'+(idx+1)+(isMe?' ← you':'')+'</td>';
1158
1165
  html += '<td class="px-4 py-3 text-sm font-bold text-center" style="color:'+gradeColors[g]+'">'+g+'</td>';
1159
1166
  html += '<td class="px-4 py-3 text-sm font-mono text-[#c7c4d7] text-right">'+(entry.ratio||'?')+':1</td>';
1160
1167
  html += '<td class="px-4 py-3 text-sm font-mono text-[#908fa0]">'+(entry.cost||'?')+'</td>';
1161
- html += '<td class="px-4 py-3 text-sm font-mono text-[#c7c4d7] text-right">'+(entry.opus||'?')+'%</td>';
1162
- html += '<td class="px-8 py-3 text-sm text-[#908fa0]">'+(entry.country||'?')+'</td>';
1168
+ html += '<td class="px-4 py-3 text-sm font-mono text-[#c7c4d7] text-right">'+(entry.opus!=null?entry.opus:'?')+'%</td>';
1169
+ html += '<td class="px-8 py-3 text-sm text-[#908fa0]">'+(isMe?'You':(entry.country||'?'))+'</td>';
1163
1170
  html += '</tr>';
1164
1171
  }
1165
1172
  tbody.innerHTML = html;
1166
1173
 
1167
1174
  // Update percentile text with rank
1168
1175
  document.getElementById('community-percentile').innerHTML =
1169
- 'You\'re <strong style="color:#c0c1ff">#'+(myRank+1)+' of '+sorted.length+'</strong> users by cache efficiency. Better than <strong style="color:#c0c1ff">'+pctile+'%</strong>';
1176
+ "You are <strong style=\\"color:#c0c1ff\\">#"+(myRank+1)+" of "+(sorted.length)+"</strong> users by cache efficiency. Better than <strong style=\\"color:#c0c1ff\\">"+pctile+"%</strong>";
1170
1177
  })(stats);
1171
1178
  })();
1172
1179
  </script>