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.
- package/cli.js +346 -0
- 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
|
+
}
|