clanker-stats 0.1.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.
Files changed (2) hide show
  1. package/cli.js +346 -0
  2. package/package.json +17 -0
package/cli.js ADDED
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile, writeFile } from "node:fs/promises"
4
+ import { homedir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { execFileSync } from "node:child_process"
7
+ import fg from "fast-glob"
8
+ import { Resvg } from "@resvg/resvg-js"
9
+
10
+ const home = homedir()
11
+ const toDate = (ms) => new Date(ms).toISOString().slice(0, 10)
12
+ const isoToDate = (iso) => iso.slice(0, 10)
13
+
14
+ function add(map, date, n) {
15
+ map.set(date, (map.get(date) || 0) + n)
16
+ }
17
+
18
+ async function collectClaude() {
19
+ const counts = new Map()
20
+ const dir = join(home, ".claude", "projects")
21
+ for (const path of await fg("*/*.jsonl", { cwd: dir })) {
22
+ if (path.includes("/subagents/")) continue
23
+ const text = await readFile(join(dir, path), "utf-8")
24
+ for (const line of text.split("\n")) {
25
+ if (!line.includes('"assistant"') || !line.includes('"usage"')) continue
26
+ try {
27
+ const obj = JSON.parse(line)
28
+ if (obj.type !== "assistant" || obj.isSidechain) continue
29
+ const u = obj.message?.usage
30
+ if (!u || !obj.timestamp) continue
31
+ if (obj.message.model === "<synthetic>") continue
32
+ const tokens = (u.input_tokens || 0) + (u.output_tokens || 0) +
33
+ (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0)
34
+ if (tokens > 0) add(counts, isoToDate(obj.timestamp), tokens)
35
+ } catch {}
36
+ }
37
+ }
38
+ return counts
39
+ }
40
+
41
+ async function collectCodex() {
42
+ const counts = new Map()
43
+ const dir = join(home, ".codex", "sessions")
44
+ for (const path of await fg("*/*/*/*rollout-*.jsonl", { cwd: dir })) {
45
+ const text = await readFile(join(dir, path), "utf-8")
46
+ let prevCumulative = -1
47
+ for (const line of text.split("\n")) {
48
+ if (!line.includes('"token_count"')) continue
49
+ try {
50
+ const obj = JSON.parse(line)
51
+ if (obj.payload?.type !== "token_count") continue
52
+ const cumulative = obj.payload.info?.total_token_usage?.total_tokens ?? -1
53
+ if (cumulative === prevCumulative) continue
54
+ prevCumulative = cumulative
55
+ const tokens = obj.payload.info?.last_token_usage?.total_tokens
56
+ if (tokens > 0 && obj.timestamp) add(counts, isoToDate(obj.timestamp), tokens)
57
+ } catch {}
58
+ }
59
+ }
60
+ return counts
61
+ }
62
+
63
+ async function collectOpenCode() {
64
+ const counts = new Map()
65
+ const dir = join(home, ".local", "share", "opencode", "storage", "message")
66
+ for (const path of await fg("ses_*/msg_*.json", { cwd: dir })) {
67
+ try {
68
+ const obj = JSON.parse(await readFile(join(dir, path), "utf-8"))
69
+ const t = obj.tokens
70
+ if (!t) continue
71
+ const tokens = (t.input || 0) + (t.output || 0) + (t.reasoning || 0) +
72
+ (t.cache?.read || 0) + (t.cache?.write || 0)
73
+ if (tokens > 0 && obj.time?.created) add(counts, toDate(obj.time.created), tokens)
74
+ } catch {}
75
+ }
76
+ return counts
77
+ }
78
+
79
+ async function collectGemini() {
80
+ const counts = new Map()
81
+ const dir = join(home, ".gemini", "tmp")
82
+ for (const path of await fg("*/chats/session-*.json", { cwd: dir })) {
83
+ try {
84
+ const obj = JSON.parse(await readFile(join(dir, path), "utf-8"))
85
+ if (!obj.messages) continue
86
+ for (const msg of obj.messages) {
87
+ if (msg.tokens?.total && msg.timestamp) add(counts, isoToDate(msg.timestamp), msg.tokens.total)
88
+ }
89
+ } catch {}
90
+ }
91
+ return counts
92
+ }
93
+
94
+ async function collectAmp() {
95
+ const counts = new Map()
96
+ const dir = join(home, ".local", "share", "amp", "threads")
97
+ for (const path of await fg("T-*.json", { cwd: dir })) {
98
+ try {
99
+ const obj = JSON.parse(await readFile(join(dir, path), "utf-8"))
100
+ if (!obj.messages) continue
101
+ const date = obj.created ? toDate(obj.created) : null
102
+ if (!date) continue
103
+ for (const msg of obj.messages) {
104
+ const u = msg.usage
105
+ if (!u) continue
106
+ const tokens = (u.totalInputTokens || 0) + (u.outputTokens || 0)
107
+ if (tokens > 0) add(counts, date, tokens)
108
+ }
109
+ } catch {}
110
+ }
111
+ return counts
112
+ }
113
+
114
+ async function collectPi() {
115
+ const counts = new Map()
116
+ const dir = join(home, ".pi", "agent", "sessions")
117
+ for (const path of await fg("*/*.jsonl", { cwd: dir })) {
118
+ const text = await readFile(join(dir, path), "utf-8")
119
+ for (const line of text.split("\n")) {
120
+ if (!line.includes('"usage"')) continue
121
+ try {
122
+ const obj = JSON.parse(line)
123
+ if (obj.type !== "message") continue
124
+ const tokens = obj.message?.usage?.totalTokens
125
+ if (tokens > 0 && obj.timestamp) add(counts, isoToDate(obj.timestamp), tokens)
126
+ } catch {}
127
+ }
128
+ }
129
+ return counts
130
+ }
131
+
132
+ const tools = [
133
+ { name: "Claude Code", collect: collectClaude, color: "#f97316" },
134
+ { name: "Codex", collect: collectCodex, color: "#22c55e" },
135
+ { name: "OpenCode", collect: collectOpenCode, color: "#3b82f6" },
136
+ { name: "Gemini CLI", collect: collectGemini, color: "#eab308" },
137
+ { name: "Amp", collect: collectAmp, color: "#a855f7" },
138
+ { name: "Pi", collect: collectPi, color: "#ec4899" },
139
+ ]
140
+
141
+ function catmullRomPath(points, tension = 0.3, yFloor) {
142
+ if (points.length < 2) return ""
143
+ const clampY = (y) => yFloor !== undefined ? Math.min(y, yFloor) : y
144
+ let d = `M${points[0].x},${points[0].y}`
145
+ for (let i = 0; i < points.length - 1; i++) {
146
+ const p0 = points[Math.max(0, i - 1)]
147
+ const p1 = points[i]
148
+ const p2 = points[i + 1]
149
+ const p3 = points[Math.min(points.length - 1, i + 2)]
150
+ const cp1y = clampY(p1.y + (p2.y - p0.y) * tension / 3)
151
+ const cp2y = clampY(p2.y - (p3.y - p1.y) * tension / 3)
152
+ d += ` C${p1.x + (p2.x - p0.x) * tension / 3},${cp1y} ${p2.x - (p3.x - p1.x) * tension / 3},${cp2y} ${p2.x},${p2.y}`
153
+ }
154
+ return d
155
+ }
156
+
157
+ function formatTotal(n) {
158
+ if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1).replace(/\.0$/, "") + "B"
159
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"
160
+ if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "k"
161
+ return String(n)
162
+ }
163
+
164
+ function renderChart(allDays, results, total) {
165
+ const W = 1500, H = 560
166
+ const pad = { top: 100, right: 50, bottom: 60, left: 50 }
167
+ const chartW = W - pad.left - pad.right
168
+ const chartH = H - pad.top - pad.bottom
169
+ const font = `-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`
170
+
171
+ const numWeeks = Math.ceil(allDays.length / 7)
172
+ const toolWeekly = results.map(r => {
173
+ const weeks = []
174
+ for (let i = 0; i < allDays.length; i += 7) {
175
+ let sum = 0
176
+ for (let j = i; j < Math.min(i + 7, allDays.length); j++) sum += r.counts.get(allDays[j]) || 0
177
+ weeks.push(sum)
178
+ }
179
+ return weeks
180
+ })
181
+
182
+ const stacked = []
183
+ for (let t = 0; t < results.length; t++) {
184
+ stacked.push(Array.from({ length: numWeeks }, (_, w) => {
185
+ let sum = 0
186
+ for (let ti = 0; ti <= t; ti++) sum += toolWeekly[ti][w]
187
+ return sum
188
+ }))
189
+ }
190
+
191
+ const maxY = (Math.max(...stacked[stacked.length - 1]) || 1) * 1.08
192
+
193
+ const toX = (w) => pad.left + (w / (numWeeks - 1)) * chartW
194
+ const toY = (val) => pad.top + chartH - (val / maxY) * chartH
195
+ const baseline = pad.top + chartH
196
+
197
+ let areas = ""
198
+ for (let t = results.length - 1; t >= 0; t--) {
199
+ const topPoints = Array.from({ length: numWeeks }, (_, w) => ({ x: toX(w), y: toY(stacked[t][w]) }))
200
+ const botPoints = t === 0
201
+ ? [{ x: toX(0), y: baseline }, { x: toX(numWeeks - 1), y: baseline }]
202
+ : Array.from({ length: numWeeks }, (_, w) => ({ x: toX(w), y: toY(stacked[t - 1][w]) }))
203
+
204
+ const topPath = catmullRomPath(topPoints, 0.3, baseline)
205
+ const botReversed = [...botPoints].reverse()
206
+ const botPath = t === 0
207
+ ? `L${toX(numWeeks - 1)},${baseline} L${toX(0)},${baseline}`
208
+ : catmullRomPath(botReversed, 0.3, baseline).replace("M", "L")
209
+
210
+ areas += `<path d="${topPath} ${botPath} Z" fill="${results[t].color}" opacity="0.55" clip-path="url(#chart-clip)"/>\n`
211
+ }
212
+
213
+ const totalPoints = Array.from({ length: numWeeks }, (_, w) => ({ x: toX(w), y: toY(stacked[stacked.length - 1][w]) }))
214
+ const totalPath = catmullRomPath(totalPoints, 0.3, baseline)
215
+
216
+ const gridCount = 4
217
+ let gridLines = ""
218
+ for (let i = 1; i <= gridCount; i++) {
219
+ const val = (maxY / gridCount) * i
220
+ const y = pad.top + chartH - (i / gridCount) * chartH
221
+ gridLines += `<line x1="${pad.left}" y1="${y}" x2="${pad.left + chartW}" y2="${y}" stroke="#21262d" stroke-width="1"/>\n`
222
+ gridLines += `<text x="${pad.left + chartW + 8}" y="${y + 4}" fill="#3b434b" font-family="${font}" font-size="11">${formatTotal(val)}</text>\n`
223
+ }
224
+
225
+ const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
226
+ let labels = ""
227
+ const seenLabels = new Set()
228
+ const startDate = new Date(allDays[0] + "T00:00:00")
229
+ const endDate = new Date(allDays[allDays.length - 1] + "T00:00:00")
230
+ let cursor = new Date(startDate.getFullYear(), startDate.getMonth(), 1)
231
+ while (cursor <= endDate) {
232
+ const iso = cursor.toISOString().slice(0, 10)
233
+ const dayIndex = allDays.indexOf(iso)
234
+ const effectiveIndex = dayIndex >= 0 ? dayIndex : allDays.findIndex(d => d >= iso)
235
+ if (effectiveIndex >= 0) {
236
+ const x = pad.left + (effectiveIndex / (allDays.length - 1)) * chartW
237
+ const label = `${monthNames[cursor.getMonth()]} '${String(cursor.getFullYear()).slice(2)}`
238
+ if (!seenLabels.has(label) && x >= pad.left + 20 && x <= pad.left + chartW - 20) {
239
+ seenLabels.add(label)
240
+ labels += `<text x="${x}" y="${H - 18}" text-anchor="middle" fill="#484f58" font-family="${font}" font-size="13">${label}</text>\n`
241
+ }
242
+ }
243
+ cursor.setMonth(cursor.getMonth() + 1)
244
+ }
245
+
246
+ const visible = results.filter(r => [...r.counts.values()].reduce((a, b) => a + b, 0) > 0)
247
+ const legendItemW = 130
248
+ const legendStartX = W - pad.right - visible.length * legendItemW
249
+ let legend = ""
250
+ for (let i = 0; i < visible.length; i++) {
251
+ const x = legendStartX + i * legendItemW
252
+ const y = 38
253
+ const count = [...visible[i].counts.values()].reduce((a, b) => a + b, 0)
254
+ legend += `<rect x="${x}" y="${y - 8}" width="10" height="10" rx="2" fill="${visible[i].color}" opacity="0.8"/>`
255
+ legend += `<text x="${x + 16}" y="${y + 1}" fill="#8b949e" font-family="${font}" font-size="14">${visible[i].name}</text>`
256
+ legend += `<text x="${x + 16}" y="${y + 16}" fill="#8b949e" font-family="${font}" font-size="13">${formatTotal(count)}</text>\n`
257
+ }
258
+
259
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
260
+ <defs>
261
+ <clipPath id="chart-clip">
262
+ <rect x="${pad.left}" y="${pad.top - 4}" width="${chartW}" height="${chartH + 8}"/>
263
+ </clipPath>
264
+ </defs>
265
+ <rect width="${W}" height="${H}" rx="16" fill="#0d1117" stroke="#30363d" stroke-width="1"/>
266
+ <text x="${pad.left + 8}" y="${pad.top - 40}" fill="#f0f6fc" font-family="${font}" font-size="42" font-weight="800">${formatTotal(total)}</text>
267
+ <text x="${pad.left + 8}" y="${pad.top - 16}" fill="#484f58" font-family="${font}" font-size="14" letter-spacing="1.5" font-weight="600">TOKENS</text>
268
+ <rect x="${(pad.left + 200 + legendStartX) / 2 - 125}" y="30" width="250" height="30" rx="6" fill="#161b22"/>
269
+ <text x="${(pad.left + 200 + legendStartX) / 2}" y="50" text-anchor="middle" fill="#8b949e" font-family="'SF Mono', 'Fira Code', monospace" font-size="15">npx clanker-stats --share</text>
270
+ ${legend}
271
+ ${gridLines}
272
+ ${areas}
273
+ <path d="${totalPath}" fill="none" stroke="#e6edf3" stroke-width="1.5" stroke-opacity="0.4" stroke-linejoin="round" stroke-linecap="round" clip-path="url(#chart-clip)"/>
274
+ ${labels}
275
+ </svg>`
276
+ }
277
+
278
+ function openPath(target) {
279
+ try {
280
+ if (process.platform === "darwin") execFileSync("open", [target])
281
+ else if (process.platform === "win32") execFileSync("cmd", ["/c", "start", "", target])
282
+ else execFileSync("xdg-open", [target])
283
+ } catch {}
284
+ }
285
+
286
+ async function main() {
287
+ const share = process.argv.includes("--share")
288
+ const results = []
289
+
290
+ for (const tool of tools) {
291
+ try {
292
+ const counts = await tool.collect()
293
+ results.push({ name: tool.name, color: tool.color, counts })
294
+ const t = [...counts.values()].reduce((a, b) => a + b, 0)
295
+ console.log(`${tool.name}: ${formatTotal(t)} tokens`)
296
+ } catch (e) {
297
+ console.warn(`${tool.name}: skipped (${e?.message || e})`)
298
+ results.push({ name: tool.name, color: tool.color, counts: new Map() })
299
+ }
300
+ }
301
+
302
+ const allDates = new Set()
303
+ for (const r of results) for (const d of r.counts.keys()) allDates.add(d)
304
+ const dates = [...allDates].sort()
305
+
306
+ if (dates.length === 0) {
307
+ console.error("No data found.")
308
+ process.exit(1)
309
+ }
310
+
311
+ const start = new Date(dates[0])
312
+ const end = new Date(dates[dates.length - 1])
313
+ const allDays = []
314
+ for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
315
+ allDays.push(d.toISOString().slice(0, 10))
316
+ }
317
+
318
+ const total = results.reduce((sum, r) => sum + [...r.counts.values()].reduce((a, b) => a + b, 0), 0)
319
+ console.log(`\n${allDays.length} days, ${formatTotal(total)} total tokens`)
320
+
321
+ const svg = renderChart(allDays, results, total)
322
+ const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1500 } })
323
+ const png = resvg.render().asPng()
324
+
325
+ const outPath = join(process.cwd(), "chart.png")
326
+ await writeFile(outPath, png)
327
+ console.log(`Wrote ${outPath}`)
328
+
329
+ if (share) {
330
+ if (process.platform === "darwin") {
331
+ const escaped = outPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
332
+ execFileSync("osascript", ["-e", `set the clipboard to (read (POSIX file "${escaped}") as \u00ABclass PNGf\u00BB)`])
333
+ console.log("Image copied to clipboard")
334
+ } else {
335
+ console.log("Copy the image manually: " + outPath)
336
+ }
337
+ const visible = results.filter(r => [...r.counts.values()].reduce((a, b) => a + b, 0) > 0)
338
+ const text = `${formatTotal(total)} tokens across ${visible.length} AI coding tools\n\nnpx clanker-stats`
339
+ openPath(`https://x.com/intent/post?text=${encodeURIComponent(text)}`)
340
+ console.log("Paste the image from your clipboard into the post")
341
+ } else {
342
+ openPath(outPath)
343
+ }
344
+ }
345
+
346
+ main()
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "clanker-stats",
3
+ "version": "0.1.0",
4
+ "description": "See how many tokens you've burned across AI coding tools",
5
+ "type": "module",
6
+ "bin": {
7
+ "clanker-stats": "cli.js"
8
+ },
9
+ "files": [
10
+ "cli.js"
11
+ ],
12
+ "dependencies": {
13
+ "@resvg/resvg-js": "^2",
14
+ "fast-glob": "^3"
15
+ },
16
+ "license": "MIT"
17
+ }