when-am-i / commit
chore: add `npm run usage` to print the commit usage trailer
package.json | 1 + scripts/usage.mts | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/package.json b/package.json index 65e611b..bb75cf1 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "screenshot:calendar": "tsx scripts/screenshot-calendar.mts", "screenshot:calendars": "tsx scripts/screenshot-calendars.mts", "gen:badges": "tsx scripts/gen-badges.mts", + "usage": "tsx scripts/usage.mts", "gen:favicon": "sips -z 64 64 calendar-source.png --out src/assets/favicon.png", "copy:leaflet-icons": "mkdir -p public && cp node_modules/leaflet/dist/images/marker-icon.png public/ && cp node_modules/leaflet/dist/images/marker-icon-2x.png public/ && cp node_modules/leaflet/dist/images/marker-shadow.png public/", "postinstall": "npm run copy:leaflet-icons" diff --git a/scripts/usage.mts b/scripts/usage.mts new file mode 100644 index 0000000..0d610ac --- /dev/null +++ b/scripts/usage.mts @@ -0,0 +1,82 @@ +// Print the session usage trailer for commit messages. +// +// npm run usage +// +// Emits the two-line trailer this project appends to commit messages — a session +// token tally (summed from the Claude Code transcript JSONL for this repo) and +// rtk's lifetime savings — already formatted in human units (K/M/G). Copy-paste +// the output straight into the message shown for commit approval. + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; + +/** 26594 -> "26.6K", 5_110_043 -> "5.1M". */ +function human(n: number): string { + if (n >= 1e9) return `${(n / 1e9).toFixed(1)}G`; + if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`; + if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`; + return String(n); +} + +// The transcript dir mirrors the cwd with every non-alphanumeric char as '-'. +function transcriptDir(): string { + const slug = process.cwd().replace(/[^a-zA-Z0-9]/g, '-'); + return path.join(os.homedir(), '.claude', 'projects', slug); +} + +function latestTranscript(dir: string): string | null { + let files: string[]; + try { + files = readdirSync(dir).filter((f) => f.endsWith('.jsonl')); + } catch { + return null; + } + if (files.length === 0) return null; + return files + .map((f) => path.join(dir, f)) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0]; +} + +function sessionTally(file: string): { in: number; out: number; cached: number } { + const totals = { in: 0, out: 0, cached: 0 }; + for (const line of readFileSync(file, 'utf-8').split('\n')) { + if (!line.trim()) continue; + try { + const u = JSON.parse(line)?.message?.usage; + if (!u) continue; + totals.in += u.input_tokens ?? 0; + totals.out += u.output_tokens ?? 0; + totals.cached += u.cache_read_input_tokens ?? 0; + } catch { + // Skip any non-JSON / partial line. + } + } + return totals; +} + +// Parse `rtk gain` for "Total commands:" and "Tokens saved: 81.1K (90.9%)". +function rtkLine(): string { + try { + const out = execSync('rtk gain', { encoding: 'utf-8' }); + const cmds = /Total commands:\s*([\d,]+)/.exec(out)?.[1]?.replace(/,/g, '') ?? '?'; + const saved = /Tokens saved:\s*([\d.]+[KMG]?)\s*\(([\d.]+)%\)/.exec(out); + const amount = saved?.[1] ?? '?'; + const pct = saved?.[2] ?? '?'; + return `rtk: ${amount} saved, ${pct}% over ${cmds} cmds (global/lifetime)`; + } catch { + return 'rtk: (rtk gain unavailable)'; + } +} + +const dir = transcriptDir(); +const file = latestTranscript(dir); +if (!file) { + console.error(`No transcript JSONL found in ${dir}`); + process.exit(1); +} + +const t = sessionTally(file); +console.log(`Usage: ${human(t.in)} in / ${human(t.out)} out / ${human(t.cached)} cached (session tally)`); +console.log(rtkLine());