SLOPSHOPPER

session-receipt

Prints a shareable receipt of your Claude Code session: tokens, cost, tools, files, and a few silly stats.

newrowsguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-receipt
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /receipt ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ * SESSION RECEIPT * │ │ CLAUDE CODE │ │ RECEIPT #0001 │ │ THU OCT 9 2025 1:53 AM │ │ app │ │ SERVED BY OPUS 5.5 │ │ ---------------------------------- │ │ Bash .......................... x4 │ │ Write ......................... x2 │ │ Read .......................... x1 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Command output
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ * SESSION RECEIPT * │ │ CLAUDE CODE │ │ RECEIPT #0001 │ │ THU OCT 9 2025 1:53 AM │ │ app │ │ SERVED BY OPUS 5.5 │ │ ---------------------------------- │ │ Bash .......................... x4 │ │ Write ......................... x2 │ │ Read .......................... x1 │ │ Grep .......................... x1 │ │ Edit .......................... x1 │ │ ---------------------------------- │ │ TIME ...................... 30m 0s │ │ TURNS .......................... 1 │ │ TOOL CALLS ..................... 9 │ │ FILES CHANGED .................. 3 │ │ MOST EDITED ........... auth.ts x1 │ │ LONGEST WAIT ........... Read 0.0s │ │ FAILED CALLS ................... 1 │ │ TOKENS IN .................... 97k │ │ TOKENS OUT .................. 1.5k │ │ CACHE HIT .................... 93% │ │ ---------------------------------- │ │ "ABSOLUTELY RIGHT" ............ x0 │ │ APOLOGIES ..................... x0 │ │ INTERRUPTIONS ................. x0 │ │ ================================== │ │ TOTAL ...................... $0.42 │ │ ================================== │ │ THANK YOU, COME AGAIN │ │ | |||||| ||||||||| ||||| ||| │ │ sreshtalluri/claude-mods │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ [ Copy text ] [ Copy image ]
README

claude-mods

Claude Code mods by @sreshtalluri.

session-receipt: a receipt for every Claude Code session

Install

/plugin marketplace add sreshtalluri/claude-mods
/plugin install session-receipt@claude-mods
/plugin install daily-grind@claude-mods
/plugin install next-asks@claude-mods

Mods use Claude Code's function-hooks plugin API, which is early access, so you need a current Claude Code.

Mods

ModWhat it does
session-receipt/receipt prints a shareable receipt of your session. /receipt week totals your week. Copy image gives you a square PNG to post.
daily-grind/grind opens a hub of daily puzzles to play while Claude works. Developer: Git Golf (reshape a commit graph under par), Heisenbug (find the buggy line), Install Order (dependency logic). Everyone: Ladder, Pixel Logic, Codebreaker. A Classics tab holds Lexer, Buckets and Daemons. Streaks, and /grind share for a shareable result.
next-asksAfter each turn, a band above the prompt suggests up to three things worth asking next that you might not think of: a missed edge case, an unrun check, a related file. Press its number to put it in the prompt. One small Haiku call per turn.

session-receipt

session-receipt demo

Command
/receiptThis session: model, time, turns, tools, files, tokens, cache hit, cost, and how many times Claude said you were absolutely right.
/receipt weekThe last seven days: sessions, busiest day, top project, usual model, total.
/receipt lastThe previous session's receipt.
  • Copy image puts a 1200×1200 PNG on your clipboard on macOS, Windows and Linux. If nothing can draw it, the image is saved and its path is shown.
  • Every session's receipt is saved to ~/.claude/receipts/ when it ends. Nothing leaves your machine.
  • In the terminal it's a text slip; in the desktop app it's drawn as paper.

Adding a mod

  1. Put it in plugins/<name>/ (with .claude-plugin/plugin.json).
  2. Add an entry to .claude-plugin/marketplace.json.
  3. claude plugin validate plugins/<name>, claude plugin test plugins/<name> and claude plugin validate .

The demo clip is a HyperFrames composition in media/receipt-demo: bun build.ts && npm run render.

License

MIT

Source 4 files
hooks/register.tsx 260 lines
1import { atom, read, update } from 'claude-code'
2import type { ButtonProps, EngineInterface, Register } from 'claude-code'
3
4import type { ReceiptLifetime, ReceiptStats } from '../types'
5import {
6  countAbsolutelyRight,
7  countApologies,
8  emptyStats,
9  DAY,
10  formatReceipt,
11  formatWeek,
12  isCredit,
13  isRule,
14  isShipCommand,
15  isTotal,
16  receiptFileName,
17  receiptSvg,
18  slipLines,
19  summarize,
20} from './format'
21import { clipboardCommand, drawCommands } from './image'
22import type { Platform } from './image'
23import type { ReceiptInput, SessionSummary } from './format'
24
25const stats = atom({ plugin: 'session-receipt', key: 'stats' } as const, emptyStats())
26const LIFETIME = 'lifetime'
27/** The last saved receipt: `{ lines, path, seen }`, for `/receipt last` and the next start's toast. */
28const LAST = 'last'
29type LastReceipt = { lines: string[]; path: string; seen: boolean }
30/** Finished sessions' summaries, a month deep, for `/receipt week`. */
31const HISTORY = 'history'
32const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
33
34/** Never let bookkeeping break the session: a failed write just loses a stat. */
35const tally = async ($: EngineInterface, fn: (s: ReceiptStats) => ReceiptStats) => {
36  try {
37    await update($, stats, fn)
38  } catch {
39    // stats are best effort
40  }
41}
42
43const readLifetime = async ($: EngineInterface): Promise<ReceiptLifetime> => {
44  const raw = (await $.store.get(LIFETIME)) as Partial<ReceiptLifetime> | undefined
45  return { sessions: Number(raw?.sessions ?? 0), usd: Number(raw?.usd ?? 0) }
46}
47
48const snapshot = async ($: EngineInterface): Promise<ReceiptInput> => {
49  const usage = await $.session.usage()
50  const lifetime = await readLifetime($)
51  return {
52    stats: await read($, stats),
53    usd: usage.cost?.usd ?? null,
54    startedAt: usage.startedAt,
55    now: await $.clock.now(),
56    cwd: await $.session.cwd(),
57    sessionId: await $.session.id(),
58    number: lifetime.sessions + 1,
59    model: await $.session.model().catch(() => null),
60  }
61}
62
63const isEmpty = (s: ReceiptStats) => s.turns === 0 && s.toolCalls === 0
64
65const readHistory = async ($: EngineInterface) =>
66  ((await $.store.get(HISTORY)) as SessionSummary[] | undefined) ?? []
67
68const receiptsDir = async ($: EngineInterface) =>
69  `${(await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '~'}/.claude/receipts`
70
71/** The slip as a square PNG on the clipboard; where nothing can draw it, the SVG's path. */
72const copyImage = async ($: EngineInterface, lines: string[]) => {
73  try {
74    const dir = `${await receiptsDir($)}/images`
75    const svg = `${dir}/receipt.svg`
76    const png = `${svg}.png`
77    await $.fs.write(svg, receiptSvg(lines, { backdrop: true }))
78
79    const os: Platform =
80      (await $.env.get('OS')) === 'Windows_NT' ? 'windows'
81      : (await $.process.run(['uname']).then(r => r.stdout.trim(), () => '')) === 'Darwin' ? 'mac'
82      : 'linux'
83    const ok = (argv: string[]) => $.process.run(argv, { timeoutMs: 20_000 }).then(r => r.exitCode === 0, () => false)
84
85    let drawn = false
86    const win = {
87      programFiles: (await $.env.get('ProgramFiles')) ?? 'C:\\Program Files',
88      programFilesX86: (await $.env.get('ProgramFiles(x86)')) ?? 'C:\\Program Files (x86)',
89      localAppData: (await $.env.get('LOCALAPPDATA')) ?? '',
90    }
91    for (const argv of drawCommands(os, svg, png, dir, win)) if ((drawn = await ok(argv))) break
92
93    if (drawn && (await ok(clipboardCommand(os, png)))) $.ui.toast('🧾 Receipt image copied')
94    else $.ui.toast(`Receipt image saved: ${drawn ? png : svg}`, { timeoutMs: 8000 })
95  } catch {
96    $.ui.toast('Could not make the receipt image')
97  }
98}
99
100export const register: Register = on => {
101  // ponytail: module-level, so a reload can toast once more; fine for a nudge.
102  let shipToasted = false
103
104  on('session.start', async ($, e, next) => {
105    await $.command.register({
106      name: 'receipt',
107      description: 'Print a receipt for this session (or: last, week)',
108      argumentHint: '[last|week]',
109    })
110    const last = (await $.store.get(LAST)) as LastReceipt | undefined
111    if (last && !last.seen) {
112      const total = last.lines.find(isTotal)?.replace(/^TOTAL[ .]*/, '').trim()
113      $.ui.toast(`🧾 Last session's receipt${total ? ` (${total})` : ''}: /receipt last`, { timeoutMs: 8000 })
114      await $.store.set(LAST, { ...last, seen: true })
115    }
116    return next(e)
117  })
118
119  on('tool.call', async ($, e, next) => {
120    const startedAt = await $.clock.now()
121    const ran = await next(e)
122    const ms = (await $.clock.now()) - startedAt
123    const tool = String(e.tool)
124    const path = (e as { file_path?: unknown }).file_path
125
126    await tally($, s => ({
127      ...s,
128      toolCalls: s.toolCalls + 1,
129      toolCounts: { ...s.toolCounts, [tool]: (s.toolCounts[tool] ?? 0) + 1 },
130      failedCalls: s.failedCalls + (ran.isError === true ? 1 : 0),
131      deniedCalls: s.deniedCalls + (ran.deny !== undefined ? 1 : 0),
132      editCounts:
133        EDIT_TOOLS.has(tool) && typeof path === 'string' && ran.deny === undefined && ran.isError !== true
134          ? { ...s.editCounts, [path]: (s.editCounts[path] ?? 0) + 1 }
135          : s.editCounts,
136      longest: !s.longest || ms > s.longest.ms ? { tool, ms } : s.longest,
137    }))
138
139    if (!shipToasted && tool === 'Bash' && !ran.isError && ran.deny === undefined) {
140      const command = (e as { command?: unknown }).command
141      if (typeof command === 'string' && isShipCommand(command)) {
142        shipToasted = true
143        $.ui.toast('🧾 Shipped! /receipt for the tab')
144      }
145    }
146
147    return ran
148  })
149
150  on('turn.complete', async ($, e, next) => {
151    const isMain = e.agentId === undefined
152    const u = e.usage
153
154    await tally($, s => ({
155      ...s,
156      turns: s.turns + (isMain ? 1 : 0),
157      interrupts: s.interrupts + (isMain && e.isAborted ? 1 : 0),
158      absolutelyRight: s.absolutelyRight + (isMain ? countAbsolutelyRight(e.answer) : 0),
159      apologies: s.apologies + (isMain ? countApologies(e.answer) : 0),
160      tokensIn: s.tokensIn + (u?.input_tokens ?? 0),
161      tokensOut: s.tokensOut + (u?.output_tokens ?? 0),
162      cacheRead: s.cacheRead + (u?.cache_read_input_tokens ?? 0),
163      cacheWrite: s.cacheWrite + (u?.cache_creation_input_tokens ?? 0),
164    }))
165
166    return next(e)
167  })
168
169  on('command.run', { command: 'receipt' }, async ($, e) => {
170    if (e.args.trim() === 'last') {
171      const last = (await $.store.get(LAST)) as LastReceipt | undefined
172      if (!last) return { text: 'No saved receipt yet: one is saved when a session ends.' }
173      return { text: '```\n' + last.lines.join('\n') + '\n```\n' + last.path }
174    }
175    const now = await $.clock.now()
176    if (e.args.trim() === 'week') {
177      const current = await snapshot($)
178      const sessions = await readHistory($)
179      const lines = formatWeek(isEmpty(current.stats) ? sessions : [...sessions, summarize(current.stats, current)], now)
180      return { text: '```\n' + lines.join('\n') + '\n```' }
181    }
182    const lines = formatReceipt(await snapshot($))
183    return { text: '```\n' + lines.join('\n') + '\n```' }
184  })
185
186  // Draw the receipt as a bordered slip instead of a plain code block.
187  on('ui.render', { component: 'CommandOutput', props: { command: 'receipt' } }, async ($, e, next) => {
188    if (e.props.isErrored) return next(e)
189    const lines = slipLines(e.props.text)
190    if (lines.length < 5) return next(e)
191    const plain = lines.join('\n')
192
193    const copy: ButtonProps['onPress'] = press => {
194      void $.ui.copy({ text: plain, surface: press.surface })
195      $.ui.toast('Receipt copied')
196    }
197
198    const image: ButtonProps['onPress'] = () => void copyImage($, lines)
199
200    if (e.surface !== 'terminal') {
201      // Proportional-font surfaces: draw a paper slip instead of padded text.
202      const { Box, Svg, Button } = $.ui.resolve(e)
203      return (
204        <Box flexDirection="column" alignItems="flex-start" gap={1}>
205          <Svg key="slip" source={receiptSvg(lines)} alt={plain} />
206          <Box key="actions" flexDirection="row" gap={1}>
207            <Button key="copy" label="Copy text" onPress={copy} />
208            <Button key="image" label="Copy image" onPress={image} />
209          </Box>
210        </Box>
211      )
212    }
213
214    const { Box, Text, Button } = $.ui.resolve(e)
215    return (
216      <Box flexDirection="column" alignItems="flex-start">
217        <Box key="slip" flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
218          {lines.map((line, i) =>
219            i === 0 ? (
220              <Text bold>{line}</Text>
221            ) : isTotal(line) ? (
222              <Text bold color="green">
223                {line}
224              </Text>
225            ) : isRule(line) || isCredit(line) ? (
226              <Text dimColor>{line}</Text>
227            ) : (
228              <Text>{line}</Text>
229            ),
230          )}
231        </Box>
232        <Box key="actions" flexDirection="row" gap={1}>
233          <Button key="copy" label="Copy text" onPress={copy} />
234          <Button key="image" label="Copy image" onPress={image} />
235        </Box>
236      </Box>
237    )
238  })
239
240  on('session.end', async ($, e, next) => {
241    try {
242      const r = await snapshot($)
243      // An opened-and-closed session gets no receipt and no number.
244      if (!isEmpty(r.stats)) {
245        const lines = formatReceipt(r)
246        const path = `${await receiptsDir($)}/${receiptFileName(r.now, r.cwd, r.number)}`
247        const history = (await readHistory($)).filter(x => r.now - x.at < 35 * DAY)
248        await $.store.set(LIFETIME, { sessions: r.number, usd: (await readLifetime($)).usd + (r.usd ?? 0) })
249        await $.store.set(HISTORY, [...history, summarize(r.stats, r)])
250        await $.store.set(LAST, { lines, path, seen: false } satisfies LastReceipt)
251        await $.fs.write(path, lines.join('\n') + '\n')
252      }
253      if (e.reason === 'clear') await update($, stats, () => emptyStats())
254    } catch {
255      // best effort inside session.end's short budget
256    }
257    return next(e)
258  })
259}
260
hooks/format.ts 368 lines
1import type { ReceiptStats } from '../types'
2
3export const WIDTH = 34
4export const RULE = '-'.repeat(WIDTH)
5export const DOUBLE_RULE = '='.repeat(WIDTH)
6
7export const emptyStats = (): ReceiptStats => ({
8  turns: 0,
9  interrupts: 0,
10  toolCalls: 0,
11  failedCalls: 0,
12  deniedCalls: 0,
13  toolCounts: {},
14  editCounts: {},
15  longest: null,
16  tokensIn: 0,
17  tokensOut: 0,
18  cacheRead: 0,
19  cacheWrite: 0,
20  absolutelyRight: 0,
21  apologies: 0,
22})
23
24export type ReceiptInput = {
25  stats: ReceiptStats
26  usd: number | null
27  startedAt: number
28  now: number
29  cwd: string
30  sessionId: string
31  number: number
32  model: string | null
33}
34
35/** Where to get one: the receipt's small print, so a shared screenshot says it. */
36export const CREDIT = 'sreshtalluri/claude-mods'
37
38/** `claude-opus-5-5[1m]` reads as `OPUS 5.5` on a receipt; anything else, as given. */
39export const prettyModel = (model: string): string => {
40  const m = /(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?!\d)/i.exec(model)
41  return (m ? `${m[1]} ${m[2]}${m[3] ? '.' + m[3] : ''}` : model.replace(/\[.*\]$/, '')).toUpperCase()
42}
43
44const ABSOLUTELY_RIGHT = /you'?re (absolutely|completely|totally) right/gi
45const APOLOGY = /\b(i apologi[sz]e|my apologies|sorry about that|i'm sorry)\b/gi
46
47export const countMatches = (text: string, re: RegExp): number =>
48  (text.match(re) ?? []).length
49
50export const countAbsolutelyRight = (text: string) => countMatches(text, ABSOLUTELY_RIGHT)
51export const countApologies = (text: string) => countMatches(text, APOLOGY)
52
53/** Left label, right value, dots in between, exactly WIDTH wide. */
54export const row = (label: string, rawValue: string): string => {
55  const max = WIDTH - 10
56  const value = rawValue.length > max ? '~' + rawValue.slice(rawValue.length - max + 1) : rawValue
57  const room = WIDTH - value.length - 1
58  const left = label.length > room - 1 ? label.slice(0, room - 2) + '~' : label
59  const dots = Math.max(1, WIDTH - left.length - value.length)
60  return left + (dots > 2 ? ' ' + '.'.repeat(dots - 2) + ' ' : ' '.repeat(dots)) + value
61}
62
63export const center = (text: string): string => {
64  const t = text.length > WIDTH ? text.slice(0, WIDTH) : text
65  const pad = Math.floor((WIDTH - t.length) / 2)
66  return ' '.repeat(pad) + t + ' '.repeat(WIDTH - t.length - pad)
67}
68
69export const compact = (n: number): string => {
70  if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + 'M'
71  if (n >= 1_000) return (n / 1_000).toFixed(n >= 10_000 ? 0 : 1) + 'k'
72  return String(n)
73}
74
75export const duration = (ms: number): string => {
76  const s = Math.max(0, Math.round(ms / 1000))
77  const h = Math.floor(s / 3600)
78  const m = Math.floor((s % 3600) / 60)
79  const sec = s % 60
80  if (h > 0) return `${h}h ${m}m`
81  if (m > 0) return `${m}m ${sec}s`
82  return `${sec}s`
83}
84
85const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']
86const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
87
88export const stamp = (ms: number): string => {
89  const d = new Date(ms)
90  const h = d.getHours()
91  const hh = String(h % 12 === 0 ? 12 : h % 12)
92  const mm = String(d.getMinutes()).padStart(2, '0')
93  return `${DAYS[d.getDay()]} ${MONTHS[d.getMonth()]} ${d.getDate()} ${d.getFullYear()}  ${hh}:${mm} ${h < 12 ? 'AM' : 'PM'}`
94}
95
96/** mcp__github__create_issue reads as github.create_issue on a receipt. */
97export const shortTool = (tool: string): string => {
98  const parts = tool.split('__')
99  return parts[0] === 'mcp' && parts.length >= 3 ? `${parts[1]}.${parts.slice(2).join('__')}` : tool
100}
101
102const basename = (path: string): string => path.split(/[\\/]/).filter(Boolean).pop() ?? path
103
104/** A fake barcode that is stable per session id, for the screenshot. */
105export const barcode = (seed: string): string => {
106  let hash = 2166136261
107  for (let i = 0; i < seed.length; i++) {
108    hash ^= seed.charCodeAt(i)
109    hash = Math.imul(hash, 16777619) >>> 0
110  }
111  const glyphs = ['|', '|', '||', ' ', '|', '| ', '|||', ' ']
112  let out = ''
113  while (out.length < WIDTH - 4) {
114    hash = Math.imul(hash ^ (hash >>> 13), 1274126177) >>> 0
115    out += glyphs[hash % glyphs.length]
116  }
117  return center(out.slice(0, WIDTH - 4))
118}
119
120/** Footer, barcode and small print: how every slip ends. */
121const close = (push: (line: string) => void, thanks: string, seed: string) => {
122  push(center(thanks))
123  push(barcode(seed))
124  push(center(CREDIT))
125}
126
127const footer = (s: ReceiptStats): string => {
128  if (s.absolutelyRight >= 3) return 'YOU WERE ABSOLUTELY RIGHT'
129  if (s.failedCalls >= 10) return 'IT WORKED ON MY MACHINE'
130  if (s.toolCalls === 0) return 'JUST HERE TO TALK'
131  if (s.interrupts >= 3) return 'PATIENCE IS A VIRTUE'
132  return 'THANK YOU, COME AGAIN'
133}
134
135/** The receipt as plain lines, every one exactly WIDTH wide. */
136export const formatReceipt = (r: ReceiptInput): string[] => {
137  const s = r.stats
138  const lines: string[] = []
139  const push = (line: string) => lines.push(line.padEnd(WIDTH))
140
141  push(center('*  SESSION RECEIPT  *'))
142  push(center('CLAUDE CODE'))
143  push(center(`RECEIPT #${String(r.number).padStart(4, '0')}`))
144  push(center(stamp(r.now)))
145  push(center(basename(r.cwd) || '~'))
146  if (r.model) push(center(`SERVED BY ${prettyModel(r.model)}`))
147  push(RULE)
148
149  const tools = Object.entries(s.toolCounts).sort((a, b) => b[1] - a[1])
150  if (tools.length === 0) {
151    push(center('(no tools were harmed)'))
152  } else {
153    for (const [tool, count] of tools.slice(0, 6)) push(row(shortTool(tool), `x${count}`))
154    if (tools.length > 6) {
155      const rest = tools.slice(6).reduce((n, [, c]) => n + c, 0)
156      push(row(`+${tools.length - 6} MORE`, `x${rest}`))
157    }
158  }
159  push(RULE)
160
161  const files = Object.entries(s.editCounts).sort((a, b) => b[1] - a[1])
162  const totalIn = s.tokensIn + s.cacheRead + s.cacheWrite
163  const cacheHit = totalIn > 0 ? Math.round((s.cacheRead / totalIn) * 100) : 0
164
165  push(row('TIME', duration(r.now - r.startedAt)))
166  push(row('TURNS', String(s.turns)))
167  push(row('TOOL CALLS', String(s.toolCalls)))
168  push(row('FILES CHANGED', String(files.length)))
169  if (files[0]) push(row('MOST EDITED', `${basename(files[0][0])} x${files[0][1]}`))
170  if (s.longest) {
171    const wait = s.longest.ms < 10_000 ? `${(s.longest.ms / 1000).toFixed(1)}s` : duration(s.longest.ms)
172    push(row('LONGEST WAIT', `${shortTool(s.longest.tool)} ${wait}`))
173  }
174  push(row('FAILED CALLS', String(s.failedCalls)))
175  if (s.deniedCalls > 0) push(row('DENIED', String(s.deniedCalls)))
176  push(row('TOKENS IN', compact(totalIn)))
177  push(row('TOKENS OUT', compact(s.tokensOut)))
178  push(row('CACHE HIT', `${cacheHit}%`))
179  push(RULE)
180  push(row('"ABSOLUTELY RIGHT"', `x${s.absolutelyRight}`))
181  push(row('APOLOGIES', `x${s.apologies}`))
182  push(row('INTERRUPTIONS', `x${s.interrupts}`))
183  push(DOUBLE_RULE)
184  push(row('TOTAL', r.usd === null ? 'n/a' : `$${r.usd.toFixed(2)}`))
185  push(DOUBLE_RULE)
186  close(push, footer(s), `${r.sessionId}:${r.startedAt}:${r.number}`)
187
188  return lines
189}
190
191/** Lines the drawing styles differently, by exact content. */
192export const isRule = (line: string) => line === RULE || line === DOUBLE_RULE
193export const isTotal = (line: string) => line.startsWith('TOTAL')
194export const isCredit = (line: string) => line.trim() === CREDIT
195const isBarcode = (line: string) => line.includes('|') && /^[| ]+$/.test(line)
196
197/** The receipt's lines out of a CommandOutput row: what sits between the ``` fences. */
198export const slipLines = (text: string): string[] => {
199  const lines = text.split('\n')
200  const open = lines.findIndex(l => l.includes('```'))
201  const close = lines.findLastIndex(l => l.includes('```'))
202  return close > open && open >= 0 ? lines.slice(open + 1, close) : lines
203}
204
205const xml = (s: string) =>
206  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
207
208/**
209 * The receipt as a paper slip for surfaces that draw SVG (desktop, mobile).
210 * Rules and the barcode are drawn as shapes, so only the text depends on the
211 * font being monospace.
212 */
213export const receiptSvg = (lines: string[], { backdrop = false } = {}): string => {
214  const FS = 13
215  const CW = FS * 0.6 // monospace advance
216  const LH = 19
217  const PAD = 18
218  const TOOTH = 7
219  const w = Math.round(PAD * 2 + WIDTH * CW)
220  const bodyH = PAD * 2 + lines.length * LH
221  const h = bodyH + TOOTH * 2
222
223  // Torn edges: a zigzag along the top and bottom.
224  const teeth = Math.round(w / (TOOTH * 2))
225  const step = w / teeth
226  const f = (n: number) => +n.toFixed(1)
227  let edge = `M0 ${TOOTH}`
228  for (let i = 0; i < teeth; i++) edge += ` L${f((i + 0.5) * step)} 0 L${f((i + 1) * step)} ${TOOTH}`
229  edge += ` L${w} ${h - TOOTH}`
230  for (let i = teeth; i > 0; i--) edge += ` L${f((i - 0.5) * step)} ${h} L${f((i - 1) * step)} ${h - TOOTH}`
231  edge += ' Z'
232
233  const body = lines.map((line, i) => {
234    const y = TOOTH + PAD + i * LH
235    const mid = y + LH / 2
236    if (line === RULE) return `<line x1="${PAD}" x2="${w - PAD}" y1="${mid}" y2="${mid}" stroke="#9a968c" stroke-dasharray="4 3"/>`
237    if (line === DOUBLE_RULE)
238      return `<line x1="${PAD}" x2="${w - PAD}" y1="${mid - 2}" y2="${mid - 2}" stroke="#2b2a27"/><line x1="${PAD}" x2="${w - PAD}" y1="${mid + 2}" y2="${mid + 2}" stroke="#2b2a27"/>`
239    if (isBarcode(line))
240      return [...line]
241        .map((c, j) => (c === '|' ? `<rect x="${(PAD + j * CW).toFixed(1)}" y="${y - 2}" width="${(CW * 0.55).toFixed(1)}" height="${LH + 1}" fill="#2b2a27"/>` : ''))
242        .join('')
243    const style =
244      i === 0 ? ' font-weight="700"'
245      : isTotal(line) ? ' font-weight="700" fill="#1f7a3a"'
246      : isCredit(line) ? ` fill="#6b675e" font-size="${FS - 2}"`
247      : ''
248    // Centered lines anchor on the middle; rows are stretched to the slip's
249    // width, so the dot leaders line up whatever monospace the surface has.
250    const text = line.trimEnd()
251    return line.startsWith(' ')
252      ? `<text x="${w / 2}" y="${y + FS}" text-anchor="middle"${style}>${xml(text.trim())}</text>`
253      : `<text x="${PAD}" y="${y + FS}" textLength="${(text.length * CW).toFixed(1)}" lengthAdjust="spacingAndGlyphs"${style}>${xml(text)}</text>`
254  })
255
256  const slip =
257    `<path d="${edge}" fill="#000" opacity="0.18" transform="translate(0 3)"/>` +
258    `<path d="${edge}" fill="#fbf9f3"/>` +
259    `<g font-family="ui-monospace, 'SF Mono', Menlo, Consolas, 'Courier New', monospace" font-size="${FS}" fill="#2b2a27" xml:space="preserve" style="white-space:pre">` +
260    body.join('') +
261    '</g>'
262  // The shareable image: the slip centred on a dark square, the shape feeds crop to.
263  const side = Math.max(w, h) + 80
264  return backdrop
265    ? `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" viewBox="0 0 ${side} ${side}">` +
266        `<rect width="${side}" height="${side}" fill="#1f1e1c"/>` +
267        `<g transform="translate(${Math.round((side - w) / 2)} ${Math.round((side - h) / 2)})">${slip}</g></svg>`
268    : `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h + 12}" viewBox="0 0 ${w} ${h + 12}">${slip}</svg>`
269}
270
271/** `2026-10-03-claude-mods-0042.txt`: sorts by date, says where and which. */
272export const receiptFileName = (now: number, cwd: string, number: number): string => {
273  const d = new Date(now)
274  const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
275  const project = (basename(cwd) || 'home').replace(/[^\w.-]+/g, '_')
276  return `${date}-${project}-${String(number).padStart(4, '0')}.txt`
277}
278
279/** A Bash command that ships something: a commit or a new PR. */
280export const isShipCommand = (command: string) => /\bgit\s+commit\b|\bgh\s+pr\s+create\b/.test(command)
281
282/** One finished session, kept for `/receipt week`. */
283export type SessionSummary = {
284  at: number
285  ms: number
286  usd: number
287  turns: number
288  toolCalls: number
289  files: number
290  tokensIn: number
291  tokensOut: number
292  absolutelyRight: number
293  apologies: number
294  project: string
295  model: string | null
296  toolCounts: Record<string, number>
297}
298
299export const DAY = 86_400_000
300
301export const summarize = (s: ReceiptStats, r: Omit<ReceiptInput, 'stats' | 'sessionId' | 'number'>): SessionSummary => ({
302  at: r.now,
303  ms: r.now - r.startedAt,
304  usd: r.usd ?? 0,
305  turns: s.turns,
306  toolCalls: s.toolCalls,
307  files: Object.keys(s.editCounts).length,
308  tokensIn: s.tokensIn + s.cacheRead + s.cacheWrite,
309  tokensOut: s.tokensOut,
310  absolutelyRight: s.absolutelyRight,
311  apologies: s.apologies,
312  project: basename(r.cwd) || '~',
313  model: r.model,
314  toolCounts: s.toolCounts,
315})
316
317const top = (counts: Record<string, number>) => Object.entries(counts).sort((a, b) => b[1] - a[1])
318
319/** The last seven days as one slip, every line exactly WIDTH wide. */
320export const formatWeek = (sessions: SessionSummary[], now: number): string[] => {
321  const week = sessions.filter(x => now - x.at < 7 * DAY)
322  const lines: string[] = []
323  const push = (line: string) => lines.push(line.padEnd(WIDTH))
324  const sum = (f: (x: SessionSummary) => number) => week.reduce((n, x) => n + f(x), 0)
325  const tally = (key: (x: SessionSummary) => string | null, weight: (x: SessionSummary) => number = () => 1) => {
326    const out: Record<string, number> = {}
327    for (const x of week) {
328      const k = key(x)
329      if (k) out[k] = (out[k] ?? 0) + weight(x)
330    }
331    return out
332  }
333  const tools: Record<string, number> = {}
334  for (const x of week) for (const [t, c] of Object.entries(x.toolCounts)) tools[t] = (tools[t] ?? 0) + c
335
336  push(center('*  WEEKLY RECEIPT  *'))
337  push(center('CLAUDE CODE'))
338  push(center(`${stamp(now - 6 * DAY).slice(4, 10).trim()} - ${stamp(now).slice(4, 10).trim()}`))
339  push(RULE)
340
341  const ranked = top(tools)
342  if (ranked.length === 0) push(center('(no tools were harmed)'))
343  for (const [tool, count] of ranked.slice(0, 5)) push(row(shortTool(tool), `x${count}`))
344  push(RULE)
345
346  const busiest = top(tally(x => DAYS[new Date(x.at).getDay()] ?? null, x => x.ms))[0]
347  const project = top(tally(x => x.project, x => x.ms))[0]
348  const model = top(tally(x => (x.model ? prettyModel(x.model) : null), x => x.ms))[0]
349  push(row('SESSIONS', String(week.length)))
350  push(row('TIME', duration(sum(x => x.ms))))
351  push(row('TURNS', String(sum(x => x.turns))))
352  push(row('TOOL CALLS', String(sum(x => x.toolCalls))))
353  push(row('FILES CHANGED', String(sum(x => x.files))))
354  if (busiest) push(row('BUSIEST DAY', busiest[0]))
355  if (project) push(row('TOP PROJECT', project[0]))
356  if (model) push(row('USUAL ORDER', model[0]))
357  push(row('TOKENS IN', compact(sum(x => x.tokensIn))))
358  push(row('TOKENS OUT', compact(sum(x => x.tokensOut))))
359  push(RULE)
360  push(row('"ABSOLUTELY RIGHT"', `x${sum(x => x.absolutelyRight)}`))
361  push(row('APOLOGIES', `x${sum(x => x.apologies)}`))
362  push(DOUBLE_RULE)
363  push(row('TOTAL', `$${sum(x => x.usd).toFixed(2)}`))
364  push(DOUBLE_RULE)
365  close(push, week.length >= 20 ? 'SEE YOU TOMORROW' : 'THANK YOU, COME AGAIN', `week:${week.map(x => x.at).join(',')}`)
366  return lines
367}
368
hooks/image.ts 50 lines
1/**
2 * The commands that turn a receipt SVG into a PNG and put it on the clipboard,
3 * per OS, using what the machine already has: Quick Look, a Chromium browser
4 * (Edge ships with Windows) or rsvg-convert to draw it, the OS's own clipboard
5 * command to copy it. Pure: register.tsx runs them, first success wins.
6 */
7export type Platform = 'mac' | 'windows' | 'linux'
8
9/** Windows' folders, from its environment; ignored elsewhere. */
10export type WindowsDirs = { programFiles: string; programFilesX86: string; localAppData: string }
11
12export const SIZE = 1200
13
14export const drawCommands = (os: Platform, svg: string, png: string, dir: string, win?: WindowsDirs): string[][] => {
15  const url = 'file:///' + svg.replace(/\\/g, '/').replace(/^\/+/, '')
16  const shot = (browser: string) => [browser, '--headless=new', '--disable-gpu', '--hide-scrollbars',
17    `--window-size=${SIZE},${SIZE}`, '--force-device-scale-factor=1', `--screenshot=${png}`, url]
18  if (os === 'windows') {
19    const w = win ?? { programFiles: 'C:\\Program Files', programFilesX86: 'C:\\Program Files (x86)', localAppData: '' }
20    return [
21      `${w.programFilesX86}\\Microsoft\\Edge\\Application\\msedge.exe`,
22      `${w.programFiles}\\Microsoft\\Edge\\Application\\msedge.exe`,
23      `${w.programFiles}\\Google\\Chrome\\Application\\chrome.exe`,
24      `${w.localAppData}\\Google\\Chrome\\Application\\chrome.exe`,
25    ].map(shot)
26  }
27  if (os === 'mac') {
28    return [
29      // Quick Look names its output <svg>.png, which is `png`.
30      ['qlmanage', '-t', '-s', String(SIZE), '-o', dir, svg],
31      shot('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
32      shot('/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'),
33    ]
34  }
35  return [
36    ...['google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge'].map(shot),
37    ['rsvg-convert', '-w', String(SIZE), '-h', String(SIZE), '-o', png, svg],
38  ]
39}
40
41export const clipboardCommand = (os: Platform, png: string): string[] =>
42  os === 'windows'
43    ? ['powershell', '-NoProfile', '-STA', '-Command',
44        'Add-Type -AssemblyName System.Windows.Forms,System.Drawing; ' +
45        `[System.Windows.Forms.Clipboard]::SetImage([System.Drawing.Image]::FromFile('${png.replace(/'/g, "''")}'))`]
46    : os === 'mac'
47      ? ['osascript', '-e', `set the clipboard to (read (POSIX file "${png.replace(/["\\]/g, '\\$&')}") as «class PNGf»)`]
48      : // Both fork a server for the clipboard: send its output away so the call returns.
49        ['sh', '-c', 'wl-copy --type image/png < "$1" >/dev/null 2>&1 || xclip -selection clipboard -t image/png -i "$1" >/dev/null 2>&1', 'sh', png]
50
types/index.d.ts 25 lines
1export type ReceiptStats = {
2  turns: number
3  interrupts: number
4  toolCalls: number
5  failedCalls: number
6  deniedCalls: number
7  toolCounts: Record<string, number>
8  editCounts: Record<string, number>
9  longest: { tool: string; ms: number } | null
10  tokensIn: number
11  tokensOut: number
12  cacheRead: number
13  cacheWrite: number
14  absolutelyRight: number
15  apologies: number
16}
17
18export type ReceiptLifetime = { sessions: number; usd: number }
19
20declare module 'claude-code' {
21  interface PluginState {
22    'session-receipt': { stats: ReceiptStats }
23  }
24}
25