SLOPSHOPPER

firecrawl-credits

Shows what each Firecrawl CLI command cost, and the running total for this session.

newbandguardcommandprocess
v0.1.0no licenseupdated 2026-10-06RichardBray/firecrawl-credits-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · firecrawl-credits
› 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 › /credits ⎿ firecrawl-credits: Firecrawl: 0 credits over 0 calls this session ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

firecrawl-credits

A Claude Code mod that shows what each Firecrawl CLI command cost, right above the prompt, while Claude is still working.

Firecrawl credits · 10 this session
░░░░░░░░░░░░░░░░░░░░░░░░  0% · 1,203 left
developer — 8 credits · /credits

It watches Claude's Bash calls. When one runs the firecrawl CLI, the mod reads the cost from the command's own JSON output (creditsUsed). If the command printed no JSON, it falls back to the drop in your account balance and marks the figure with ~. Drawing the band is local, so it uses no tokens.

Written up in Claude Mods: What They Are and How to Build One in TypeScript.

Requirements

  • Claude Code v2.1.287 or later (mods are on by default from that version)
  • The Firecrawl CLI, logged in. The mod reads your balance with firecrawl credit-usage --json, so it never needs your API key itself.

Use it

git clone https://github.com/RichardBray/firecrawl-credits-mod
claude --plugin-dir ./firecrawl-credits-mod

Run /credits any time for the session total.

Configure

CREDIT_BUDGET in hooks/register.tsx sets what the bar treats as a full tank. Leave it at 0 to use your plan's credits, or set a number to measure against your own budget.

Files

  • .claude-plugin/plugin.json: the plugin manifest
  • hooks/hooks.json: points Claude Code at the hooks module
  • hooks/register.tsx: the mod
Source 1 files
hooks/register.tsx 194 lines
1/* @jsxRuntime classic */
2/* @jsx h */
3import type { Register } from 'claude-code'
4
5// Watches Claude's Bash calls. When one runs the firecrawl CLI, it shows what that
6// command cost in a band above the prompt, with a bar for how much of the plan is gone.
7//
8// Cost comes from one of two places, in this order:
9//   1. `creditsUsed` in the command's own JSON output — exact, and free to read
10//   2. the drop in the account balance since we last looked — used when the command
11//      printed no JSON, and shown with a ~ so it reads as an approximation
12
13const ORANGE = '#fa5d19'
14const BAR_WIDTH = 24
15
16// What the bar treats as a full tank. 0 works it out instead: the plan's credits when the
17// account has no more than its plan, otherwise the balance this session opened with.
18// Set a number here to film the bar moving on an account too large to show a change.
19const CREDIT_BUDGET = 0
20
21type Balance = { remaining: number; plan: number }
22
23let spentThisSession = 0
24let callsThisSession = 0
25let balance: Balance | undefined
26let lastCall: string | undefined
27let openingBalance: number | undefined
28// How many priced firecrawl commands are running right now. A before/after pair only
29// measures one command if it was the only one in flight for the whole call.
30let inFlight = 0
31
32// `firecrawl scrape …` yes; `… | grep firecrawl` no. Close enough for a command line.
33const isFirecrawlCommand = (command: string) => /(^|[;&|]\s*)firecrawl\s+\S/.test(command)
34
35// Commands that never spend credits, so pricing them is just noise. `search-feedback`
36// actually refunds one, and the firecrawl skill sends it automatically after a search.
37const SPENDS_NOTHING =
38  /firecrawl\s+(credit-usage|search-feedback|feedback|config|view-config|login|logout|doctor|version|env|setup|make|init)\b/
39const isFreeCommand = (command: string) => SPENDS_NOTHING.test(command)
40
41const subcommandOf = (command: string) => command.match(/firecrawl\s+([a-z-]+)/)?.[1] ?? 'firecrawl'
42
43const sumOfNestedCredits = (text: string): number | undefined => {
44  const found = [...text.matchAll(/"creditsUsed"\s*:\s*(\d+)/g)].map(m => Number(m[1]))
45  return found.length > 0 ? found.reduce((a, b) => a + b, 0) : undefined
46}
47
48// A search that scraped its results reports the total at the top AND a copy on every
49// result, so adding up every `creditsUsed` bills the command about twice. The top-level
50// figure wins where there is one; the sum is for a crawl, which reports one per page
51// and no total, and for output we could not parse.
52const creditsReportedIn = (stdout: string): number | undefined => {
53  try {
54    const parsed = JSON.parse(stdout)
55    const total = Number(parsed?.creditsUsed ?? parsed?.data?.creditsUsed)
56    if (Number.isFinite(total)) return total
57  } catch {}
58  return sumOfNestedCredits(stdout)
59}
60
61// Reads the account through the CLI, which already holds the credentials.
62async function readBalance($: any): Promise<Balance | undefined> {
63  try {
64    const { stdout } = await $.process.run(['firecrawl', 'credit-usage', '--json'])
65    const data = JSON.parse(stdout)?.data
66    const remaining = Number(data?.remainingCredits)
67    const plan = Number(data?.planCredits)
68    return Number.isFinite(remaining) ? { remaining, plan } : undefined
69  } catch {
70    return undefined
71  }
72}
73
74// An account topped up beyond its plan (or an internal one) reports more credits than
75// its plan holds, so the plan is no denominator, and neither is the distance from it:
76// the bar then measures what this session spent against the budget instead.
77const tankFor = (b: Balance) => {
78  const spentSinceOpening = Math.max(0, (openingBalance ?? b.remaining) - b.remaining)
79  const budget = CREDIT_BUDGET > 0 ? CREDIT_BUDGET : b.plan > 0 ? b.plan : (openingBalance ?? b.remaining)
80  // Credits in hand are the honest reading of how much of the tank is gone, but an account
81  // holding more than the tank has none of it gone, so there the bar tracks this session.
82  return b.remaining <= budget
83    ? { budget, used: budget - b.remaining }
84    : { budget, used: spentSinceOpening }
85}
86
87const barFor = (b: Balance) => {
88  const { budget, used } = tankFor(b)
89  const usedFraction = budget > 0 ? Math.min(1, Math.max(0, used / budget)) : 0
90  const filled = Math.round(usedFraction * BAR_WIDTH)
91  return {
92    bar: '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled),
93    percent: Math.round(usedFraction * 100),
94  }
95}
96
97export const register: Register = on => {
98  on('session.start', async ($, e, next) => {
99    const result = await next(e)
100
101    // A baseline, so the very first fallback measurement has something to subtract from.
102    balance = await readBalance($)
103    openingBalance = balance?.remaining
104
105    await $.command.register({
106      name: 'credits',
107      description: 'Firecrawl credits spent in this session (firecrawl-credits)',
108    })
109
110    return result
111  })
112
113  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
114    const command = String(e.command ?? '')
115
116    // Let every other Bash command through untouched.
117    if (!isFirecrawlCommand(command) || isFreeCommand(command)) return next(e)
118
119    // A real reading before the command, so the fallback below is a true difference
120    // rather than a guess against a number we have been adjusting ourselves.
121    inFlight += 1
122    const alone = inFlight === 1
123    const before = await readBalance($)
124
125    let result
126    try {
127      result = await next(e)
128    } finally {
129      inFlight -= 1
130    }
131
132    // The command's own output is exact and free to read, so prefer it.
133    let spent = creditsReportedIn(String((result as any)?.result?.stdout ?? ''))
134    let exact = spent !== undefined
135
136    // Either way, end on a real reading: the band should never show arithmetic we did.
137    const after = await readBalance($)
138    balance = after ?? before ?? balance
139
140    if (spent === undefined && before !== undefined && after !== undefined) {
141      spent = Math.max(0, before.remaining - after.remaining)
142      // Two overlapping commands share one difference, so neither can claim it.
143      exact = alone && inFlight === 0
144    }
145
146    if (spent !== undefined) {
147      spentThisSession += spent
148      callsThisSession += 1
149      lastCall = `${subcommandOf(command)} — ${exact ? '' : '~'}${spent} credit${spent === 1 ? '' : 's'}`
150      $.ui.invalidate('ui.render')
151    }
152
153    return result
154  })
155
156  on('command.run', { command: 'credits' }, async ($) => {
157    balance = (await readBalance($)) ?? balance
158    $.ui.invalidate('ui.render')
159    return { text: `Firecrawl: ${spentThisSession} credits over ${callsThisSession} calls this session` }
160  })
161
162  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
163    // Nothing to say until the first firecrawl command has run.
164    if (lastCall === undefined || e.surface !== 'terminal') return next(e)
165
166    const { Box, Text } = await $.ui.resolve(e)
167
168    const title = `Firecrawl credits · ${spentThisSession} this session`
169
170    const middle = balance
171      ? (() => {
172          const { bar, percent } = barFor(balance)
173          return `${bar}  ${percent}% · ${balance.remaining.toLocaleString()} left`
174        })()
175      : 'balance unknown'
176
177    const footer = `${lastCall} · /credits`
178
179    // Under three rows the band only gets the line that matters.
180    const isTall = e.props.maxRows >= 3
181
182    return (
183      <Box flexDirection="column">
184        <Box flexDirection="column" width={e.props.bodyColumns} paddingX={1}>
185          {isTall ? <Text color={ORANGE} dimColor>{title}</Text> : null}
186          <Text color={ORANGE} wrap="wrap">{middle}</Text>
187          {isTall ? <Text color={ORANGE} dimColor>{footer}</Text> : null}
188        </Box>
189        {await next(e)}
190      </Box>
191    )
192  })
193}
194