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

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.
firecrawl credit-usage --json, so it never needs your API key itself.git clone https://github.com/RichardBray/firecrawl-credits-mod
claude --plugin-dir ./firecrawl-credits-mod
Run /credits any time for the session total.
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.
.claude-plugin/plugin.json: the plugin manifesthooks/hooks.json: points Claude Code at the hooks modulehooks/register.tsx: the modhooks/register.tsx 194 lines1/* @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