RISHET MEHRA · DTU '27

01 / codex-spend · live on npm

See what Codex actually burned.

A local CLI that parses Codex sessions on disk and shows token burn by model and by file. 1,474 npm downloads, no telemetry, no auth.

What it does

codex-spend answers a question I kept asking after long agent sessions: where did all those tokens go. It is a small CLI you run with npx, it reads the Codex session files already sitting on your disk, and it prints a tidy table of token burn grouped by model and by file. No login, no key, no network.

The tool lives on npm with 1,474 downloads in the last 12 months. Strangers keep installing it, which is the nicest signal a CLI can get. The constraint that shaped everything was simple: do not add auth. If the data is already on disk, parse it there.

  • Reads JSONL session logs from the Codex cache directory
  • Sums usage by model so you see whether gpt-4o or gpt-4o-mini did the burning
  • Rolls up by file so the priciest file floats to the top
  • Supports last 7 days and last 30 days with a single flag
  • Runs via npx, zero config, zero telemetry

Limitation: it is only as good as the log format. If Codex changes how it writes sessions, the parser needs an update.

How it works

The core loop is boring in the best way. Walk the sessions directory, read each JSONL line, pull out the model name and usage fields, and accumulate. No API call. The speed comes from staying local and keeping dependencies at zero.

src/parse.js · walk and sumNode · no deps
// find every Codex session on disk, no network
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'

export async function collectSessions(root) {
  const files = await readdir(root)
  const sessions = []
  for (const f of files.filter(f => f.endsWith('.jsonl'))) {
    const raw = await readFile(path.join(root, f), 'utf8')
    for (const line of raw.split('\n')) {
      if (!line.trim()) continue
      sessions.push(JSON.parse(line))
    }
  }
  return sessions
}
src/summarize.js · group by model and file
// tokens[model] and tokensByFile[file] in one pass
export function summarize(sessions) {
  const byModel = {}, byFile = {}
  for (const s of sessions) {
    const m = s.model ?? 'unknown'
    const n = s.usage?.total ?? 0
    byModel[m] = (byModel[m] ?? 0) + n
    if (s.file) byFile[s.file] = (byFile[s.file] ?? 0) + n
  }
  return { byModel, byFile, total: Object.values(byModel).reduce((a,b)=>a+b,0) }
}
src/cli.js · flags for time windows
// npx codex-spend --last 7d  or  --last 30d
import { program } from 'commander'

program.option('--last <window>', '7d or 30d', '7d')
  .action(async (opts) => {
    const cutoff = opts.last === '30d' ? daysAgo(30) : daysAgo(7)
    const rows = await loadAndFilter(cutoff)
    printTable(summarize(rows))
  })

Why it stayed local

An auth screen would have killed the flow. The moment you add a key, the tool needs docs, error states, and support. Keeping it local means the install is one line and the data never leaves the machine. That is the whole pitch.

Back to work → GitHub profile →