Your OpenAI API credit above the prompt: an estimated balance with a gauge, today's spend, where the money mostly went, and the last call. /openai-balance…

<h1 align="center">Claude Code mods</h1>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml"><img src="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="#-install">Install</a> · <a href="#-the-mods">All mods</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>
<table> <tr> <td align="center" width="33%"><a href="docs/mods.md#-context-bar"><img src="images/context-bar.png" alt="context-bar: Claude Code context window usage as a stacked bar, a color per category" width="260"></a><br>📊 <b>context-bar</b><br>what fills your context</td> <td align="center" width="33%"><a href="docs/mods.md#-review-watch"><img src="images/review-watch.png" alt="review-watch: live lines for running Codex and subagent code reviews in Claude Code" width="260"></a><br>🔍 <b>review-watch</b><br>running code reviews, live</td> <td align="center" width="33%"><a href="docs/mods.md#-md-preview"><img src="images/md-preview.png" alt="md-preview: Markdown that Claude Code edits, rendered like GitHub next to the diff" width="260"></a><br>📝 <b>md-preview</b><br>Markdown rendered like GitHub</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-blast-radius"><img src="images/gallery/blast-radius.png" alt="blast-radius: a Claude Code hook holds rm -rf and lists the files it would delete" width="260"></a><br>💥 <b>blast-radius</b><br>see what <code>rm -rf</code> would delete</td> <td align="center" width="33%"><a href="docs/mods.md#-now-playing"><img src="images/now-playing.png" alt="now-playing: Spotify track, progress bar and synced lyrics inside Claude Code" width="260"></a><br>🎵 <b>now-playing</b><br>Spotify and its lyrics, live</td> <td align="center" width="33%"><a href="docs/mods.md#-reels-and-snake"><img src="images/reels-demo.gif" alt="reels: YouTube Shorts in a Claude Code pane while it works" width="260"></a><br>📱 <b>reels</b><br>Shorts while Claude works</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-where-am-i"><img src="images/gallery/where-am-i.png" alt="where-am-i: the session goal, current step and what waits on you, above the Claude Code prompt" width="260"></a><br>📍 <b>where-am-i</b><br>goal, now, waiting on you</td> <td align="center" width="33%"><a href="docs/mods.md#-lines-above-the-prompt"><img src="images/gallery/lines.png" alt="token-weather, usage-meter and other Claude Code status lines stacked above the prompt" width="260"></a><br>🌦️ <b>token-weather and friends</b><br>lines above the prompt</td> <td align="center" width="33%"><a href="#mission-control"><img src="images/gallery/mission-control.png" alt="mission-control: Claude Code subagents, tool calls and the files they touch, live" width="260"></a><br>🛰️ <b>mission-control</b><br>agents and the code they touch</td> </tr> </table>
Add the marketplace once, then install any mod by name:
claude plugin marketplace add hamzafer/claude-code-mods
claude plugin install context-bar@claude-code-mods
Or install the general-purpose set in one go:
for m in context-bar token-weather usage-meter where-am-i next-steps agent-radar review-watch replay-theater md-preview blast-radius mission-control; do
claude plugin install "$m@claude-code-mods"
done
Restart Claude Code after installing. To try one without installing:
git clone https://github.com/hamzafer/claude-code-mods && cd claude-code-mods
claude --plugin-dir mods/context-bar
Needs Claude Code 2.1.287+. A few mods need more (Chrome,
gh, a connector); the tables say which.
| Mod | What it does | Command | |
|---|---|---|---|
| 🛰️ | mission-control | Live map of agents, tool calls and the code they touch | /mission |
| 📊 | context-bar | Your context window as one stacked bar, a color per category, with token counts and where it compacts | /context-bar |
| 🌦️ | token-weather | Context fill from Clear to Compact soon, plus a prompt-cache countdown | |
| ⏱️ | cache-clock | A prompt-cache line under your status line, from Claude Code's own figures: time left, hit rate and misses, and the tokens your next message re-caches once it goes cold. Needs Node and Claude Code 2.1.251+ | /cache-clock setup |
| 📍 | where-am-i | Goal, doing now, waiting on you, next step | /where |
| ➡️ | next-steps | 2 or 3 likely next prompts after each turn, one key to draft one | 1 2 3, 0 hides |
| 💰 | usage-meter | 5-hour and 7-day plan usage, the reset countdown and the session's cost | |
| 💳 | openai-balance | Your OpenAI API credit: an estimated balance with a gauge, today's spend, where the money mostly went, and the last call. Needs an OpenAI organization Admin key | /openai-balance |
| 📡 | agent-radar | One live line per running subagent | /radar |
| 🔍 | review-watch | One live line per running code review (Codex or a review subagent) with the model, target, elapsed time and Codex's latest output. A toast lists the findings when it ends | |
| 🌐 | browser-lanes | Whether this session has a browser, and who holds it | /browser |
| 🕌 | prayer-times | The current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr | /prayers |
| 🎬 | replay-theater | Steps through the last turn's edits, one diff at a time | /replay |
| 📝 | md-preview | Renders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images | /md |
| Mod | What it does | Command | |
|---|---|---|---|
| 💥 | blast-radius | Holds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer |
| Mod | What it does | Command | |
|---|---|---|---|
| 🔀 | switchboard | Picks the model for each subagent that doesn't name one, with OpenAI's Decisions API or Jev, from its short label only. Shows what every subagent cost | /route |
These are built around my own tools and rules. Fork them and change the rules to yours.
| Mod | What it does | Command | |
|---|---|---|---|
| 👀 | glance | One line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors | /glance |
| 🚦 | merge-gate | Holds gh pr merge until CI is green and Codex reviewed once. Needs gh and the Codex CLI. Reviews run on one fixed model; change it to yours | /gate |
| 📏 | rulebook-guard | Enforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes | |
| 💾 | session-saver | Saves where you left off, shows it on resume. Needs unpause | /park [note] |
| Mod | What it does | Command | |
|---|---|---|---|
| 📱 | reels | YouTube Shorts while Claude works, pauses when it's done | /reels |
| 🐍 | snake | Snake while Claude works | /snake |
| 🎵 | now-playing | What Spotify is playing, with a progress bar, the lyric being sung, and ⏮ ⏸ ⏭ buttons. Needs macOS and the Spotify app | /music |
<a id="mission-control"></a>
Every subagent, every tool call and every file they touch, in a pane next to the chat. Shown at 4x: two subagents building a logout feature across four files.

Install it like any mod, restart, and type /mission (or /mission code to open the code map). q closes it.
w shows the agents and every tool call, livec shows the code map, with import arrowsThe Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.
hooks/register.tsx 426 lines1// OpenAI Balance: your OpenAI API credit, in a band above the prompt.
2// OpenAI has no balance API, so the balance is an estimate: the last balance you set with
3// /openai-balance <amount>, minus the real spend the Costs API reports since then.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register, Timer } from 'claude-code'
6
7import type { Line } from '../types'
8
9const EVERY_MS = 10 * 60 * 1000 // spend posts in daily buckets; no need to ask more often
10const TICK_MS = 60 * 1000 // the timer checks every minute; nextTry decides whether to ask
11const RETRY_MS = 60 * 1000 // after a failed refresh
12const STUCK_MS = 2 * 60 * 1000 // a refresh still running after this is given up on (fetch has no timeout)
13const KEYCHAIN_SERVICE = 'openai-admin-key'
14const API = 'https://api.openai.com/v1/organization'
15const DAY_S = 86_400
16const LOW = 2 // dollars: red below this
17const MID = 5 // dollars: yellow below this
18const BRAND = '#c792ea' // soft violet: apart from the green gauge and the other bands
19// Usage types to merge. `decisions` 404s while the Decisions API is in beta (Oct 2026); it is asked anyway, so keys
20// and times show up by themselves once OpenAI adds it. Until then its spend shows through cost line items.
21const USAGE = ['completions', 'decisions', 'embeddings', 'moderations']
22const GAUGE = 10
23const EMPTY: Line = { left: null, start: null, today: 0, last: null, top: null, error: null, isLoaded: false, hasData: false }
24const SETUP =
25 'Needs an OpenAI organization **Admin key** (read-only is enough), from platform.openai.com → Settings → Organization → Admin keys. Put it in `/config` → openai-balance, or in `OPENAI_ADMIN_KEY`, or on macOS in Keychain: `security add-generic-password -a "$USER" -s openai-admin-key -w`.'
26
27// The balance you set, and how much of that UTC day's spend it already included.
28type Anchor = { balance: number; dayStart: number; spentBefore: number }
29type Bucket = { start: number; dollars: number; items: Record<string, number> } // items: dollars per line item
30
31// Held by the host, so the line survives a hot reload of this file.
32const line = atom({ plugin: 'openai-balance', key: 'line' } as const, EMPTY)
33
34let timers: Timer[] = [] // restarted on each session.start
35let configKey = ''
36let key = '' // kept in memory only, never in state, the store or a message
37let nextTry = 0
38let generation = 0 // a newer refresh wins; an older one that ends later is dropped
39let running: { since: number } | null = null
40
41class NoKey extends Error {}
42class KeyRejected extends Error {}
43class HttpError extends Error {
44 constructor(readonly status: number) {
45 super(`OpenAI answered ${status}`)
46 }
47}
48
49export const register: Register = (on, options) => {
50 configKey = typeof options.adminKey === 'string' ? options.adminKey.trim() : ''
51
52 on('session.start', async ($, e, next) => {
53 const result = await next(e)
54 await $.command
55 .register({
56 name: 'openai-balance',
57 description: 'OpenAI API credit, spend and tokens. Add an amount to set the balance after a top-up',
58 argumentHint: '[amount]',
59 })
60 .catch(() => {}) // a name Claude Code already has is refused: start anyway
61 void refresh($, true).catch(() => {}) // in the background: a slow API never holds up the session
62 for (const t of timers) t.cancel()
63 timers = [$.clock.every(TICK_MS, () => void refresh($, false).catch(() => {}))]
64 return result
65 })
66
67 on('turn.complete', async ($, e, next) => {
68 const result = await next(e)
69 if (!e.agentId) void refresh($, false).catch(() => {}) // main-loop turns only, not subagents
70 return result
71 })
72
73 on('command.run', { command: 'openai-balance' }, async ($, e) => {
74 const arg = e.args.trim().replace(/^\$/, '')
75 try {
76 if (arg === '') return { text: await detail($) }
77 if (!/^\d+(\.\d+)?$/.test(arg) || Number(arg) <= 0) return { text: 'Usage: `/openai-balance` or `/openai-balance 25.00` (the balance from your Billing page)' }
78 const anchor = await setBalance($, Number(arg))
79 await refresh($, true)
80 return { text: `OpenAI balance set to ${usd(anchor.balance)}. From now on the band subtracts spend after this point.` }
81 } catch (err) {
82 return { text: problem(err) }
83 }
84 })
85
86 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
87 const rest = await next(e) // what other mods and Claude Code draw here stays
88 const now = { ...EMPTY, ...(await read($, line)) } // a value an older version saved may lack a field
89 if (e.props.hasSurvey || !now.isLoaded) return rest
90
91 const { Box, Text } = $.ui.resolve(e)
92 const isWide = e.props.bodyColumns >= 60
93 const sep = <Text dimColor>{' · '}</Text>
94 const head = <Text color={BRAND} bold>{'◆ OpenAI '}</Text>
95
96 let body
97 if (now.error === 'no-key') body = <Text color="red">no admin key set, see /openai-balance</Text>
98 else if (now.error === 'rejected') body = <Text color="red">admin key rejected, see /openai-balance</Text>
99 else if (!now.hasData) body = <Text dimColor>{"can't reach OpenAI yet"}</Text>
100 else if (now.left === null) {
101 body = (
102 <Text>
103 <Text bold>{usd(now.today)}</Text>
104 <Text dimColor>{' today'}</Text>
105 {sep}
106 <Text dimColor>{'/openai-balance <amount> to show credit'}</Text>
107 {now.error === 'offline' && <Text dimColor>{' · offline'}</Text>}
108 </Text>
109 )
110 } else {
111 // What today's money mostly went to, when that's not the last call's model: spend the Usage API can't
112 // attribute to a call (Decisions) still shows.
113 const mostly = isWide && now.top && (!now.last || same(now.top) !== same(now.last.model)) ? short(now.top) : null
114 const tone = now.left < LOW ? 'red' : now.left < MID ? 'yellow' : 'green'
115 const filled = now.start !== null && now.start > 0 ? Math.max(0, Math.min(GAUGE, Math.round((now.left / now.start) * GAUGE))) : 0
116 body = (
117 <Text>
118 {isWide && <Text color={tone}>{'█'.repeat(filled)}</Text>}
119 {isWide && <Text dimColor>{'▁'.repeat(GAUGE - filled)}</Text>}
120 <Text color={tone} bold>{`${isWide ? ' ' : ''}~${usd(now.left)}`}</Text>
121 <Text dimColor>{now.start !== null ? ` of ${usd(now.start)}` : ''}{now.left < LOW ? ' left, top up soon' : ' left'}</Text>
122 {sep}
123 <Text bold>{usd(now.today)}</Text>
124 <Text dimColor>{' today'}</Text>
125 {mostly && <Text dimColor>{', mostly '}</Text>}
126 {mostly && <Text color="cyan">{mostly}</Text>}
127 {now.last && sep}
128 {now.last && <Text dimColor>{'last '}</Text>}
129 {now.last && <Text color="cyan">{short(now.last.model)}</Text>}
130 {now.last && <Text dimColor>{` ${clock(now.last.at)}`}</Text>}
131 {now.error === 'offline' && <Text dimColor>{' · offline'}</Text>}
132 </Text>
133 )
134 }
135
136 return (
137 <Box flexDirection="column">
138 <Box paddingX={1}>
139 <Text wrap="truncate-end">
140 {head}
141 {body}
142 </Text>
143 </Box>
144 {rest}
145 </Box>
146 )
147 })
148}
149
150// Updates what the band draws. Every 10 minutes, a minute after a failure, or now when forced.
151async function refresh($: EngineInterface, isForced: boolean) {
152 const now = await $.clock.now()
153 if (!isForced && (now < nextTry || (running && now - running.since < STUCK_MS))) return
154 const mine = ++generation
155 running = { since: now }
156 try {
157 const anchor = await getAnchor($)
158 const nowS = Math.floor(now / 1000)
159 const buckets = await costs($, Math.min(anchor?.dayStart ?? nowS, dayStart(nowS)))
160 const today = spentFrom(buckets, dayStart(nowS))
161 const left = anchor ? estimate(anchor, buckets) : null
162 const last = await lastCall($, dayStart(nowS)).catch(() => null) // a usage hiccup never hides the balance
163 const top = topModel(itemsFrom(buckets, dayStart(nowS)))
164 if (mine !== generation) return
165 nextTry = now + EVERY_MS
166 await update($, line, () => ({ left, start: anchor?.balance ?? null, today, last, top, error: null, isLoaded: true, hasData: true }))
167 } catch (err) {
168 if (mine !== generation) return
169 nextTry = now + RETRY_MS // a fixed key or a network back shows within a minute
170 const error: Line['error'] = err instanceof NoKey ? 'no-key' : err instanceof KeyRejected ? 'rejected' : 'offline'
171 await update($, line, l => ({ ...EMPTY, ...l, error, isLoaded: true })) // offline keeps the last numbers, marked
172 } finally {
173 if (mine === generation) running = null
174 }
175}
176
177async function detail($: EngineInterface) {
178 const now = await $.clock.now()
179 const nowS = Math.floor(now / 1000)
180 const anchor = await getAnchor($)
181 const monthStart = Math.floor(Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), 1) / 1000)
182 const thirtyAgo = dayStart(nowS) - 29 * DAY_S
183 const buckets = await costs($, Math.min(anchor?.dayStart ?? thirtyAgo, monthStart, thirtyAgo))
184 const [models, keys, last] = await Promise.all([tokens($, thirtyAgo, 'model'), tokens($, thirtyAgo, 'api_key_id'), lastCall($, dayStart(nowS)).catch(() => null)])
185
186 const lines = ['## OpenAI balance', '']
187 if (anchor) {
188 const left = estimate(anchor, buckets)
189 lines.push(`- **Estimated left:** ~${usd(left)} (set to ${usd(anchor.balance)} on ${date(anchor.dayStart)}, ${usd(anchor.balance - left)} spent since)`)
190 } else {
191 lines.push('- **Estimated left:** not set yet. Run `/openai-balance <amount>` with the balance from the Billing page.')
192 }
193 lines.push(
194 `- **Today (UTC):** ${usd(spentFrom(buckets, dayStart(nowS)))}`,
195 `- **This month:** ${usd(spentFrom(buckets, monthStart))}`,
196 `- **Last 30 days:** ${usd(spentFrom(buckets, thirtyAgo))}`,
197 '',
198 '**Spend by item, last 30 days**',
199 )
200 const items = itemsFrom(buckets, thirtyAgo)
201 if (items.length === 0) lines.push('- none')
202 for (const [k, v] of items) lines.push(`- ${k}: ${usd4(v)}`)
203 lines.push('', '**Tokens, last 30 days**')
204 if (models.length === 0) lines.push('- none')
205 for (const m of models) lines.push(`- ${m.model}: ${num(m.input)} in / ${num(m.output)} out, ${num(m.requests)} requests`)
206 lines.push('', '**By key, last 30 days**')
207 if (keys.length === 0) lines.push('- none')
208 for (const k of keys) lines.push(`- ${k.model}: ${num(k.input + k.output)} tokens, ${num(k.requests)} requests`)
209 lines.push('', last ? `**Last call today:** ${last.model} via ${last.key} at ${clock(last.at)}` : '**Last call today:** none')
210 lines.push('', '_The balance is an estimate (OpenAI has no balance API). After a top-up, run `/openai-balance <new amount>`. Decisions API calls show as spend only until OpenAI adds them to the Usage API._')
211 return lines.join('\n')
212}
213
214async function setBalance($: EngineInterface, balance: number): Promise<Anchor> {
215 const start = dayStart(Math.floor((await $.clock.now()) / 1000))
216 const anchor = { balance, dayStart: start, spentBefore: spentFrom(await costs($, start), start) }
217 await $.store.set('anchor', anchor)
218 return anchor
219}
220
221async function getAnchor($: EngineInterface): Promise<Anchor | undefined> {
222 const a = (await $.store.get('anchor')) as Anchor | undefined
223 return a && typeof a.balance === 'number' ? a : undefined
224}
225
226function estimate(anchor: Anchor, buckets: Bucket[]) {
227 return anchor.balance - (spentFrom(buckets, anchor.dayStart) - anchor.spentBefore)
228}
229
230// Where the money since `start` went, biggest first.
231function itemsFrom(buckets: Bucket[], start: number) {
232 const sum: Record<string, number> = {}
233 for (const b of buckets) if (b.start >= start) for (const [k, v] of Object.entries(b.items)) sum[k] = (sum[k] ?? 0) + v
234 return Object.entries(sum).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1])
235}
236
237// The model today's money mostly went to, its line items (input, output, ...) added up first.
238function topModel(items: [string, number][]) {
239 const byModel = new Map<string, number>()
240 for (const [name, dollars] of items) byModel.set(short(name), (byModel.get(short(name)) ?? 0) + dollars)
241 return [...byModel].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null
242}
243
244function spentFrom(buckets: Bucket[], start: number) {
245 return buckets.filter(b => b.start >= start).reduce((sum, b) => sum + b.dollars, 0)
246}
247
248// Daily spend in dollars from `start` (unix seconds) until now, all pages.
249async function costs($: EngineInterface, start: number): Promise<Bucket[]> {
250 const out: Bucket[] = []
251 let page = ''
252 do {
253 const d = await call($, `costs?start_time=${start}&bucket_width=1d&limit=180&group_by=line_item${page && `&page=${encodeURIComponent(page)}`}`)
254 for (const b of d.data ?? []) {
255 const items: Record<string, number> = {}
256 for (const r of b.results ?? []) {
257 const name = r.line_item ?? 'other'
258 items[name] = (items[name] ?? 0) + Number(r.amount?.value ?? 0)
259 }
260 out.push({ start: b.start_time, dollars: Object.values(items).reduce((a, v) => a + v, 0), items })
261 }
262 page = d.has_more && d.next_page ? d.next_page : ''
263 } while (page)
264 return out
265}
266
267async function tokens($: EngineInterface, start: number, by: 'model' | 'api_key_id') {
268 const d = await usage($, `start_time=${start}&bucket_width=1d&limit=31&group_by=${by}`)
269 const names = by === 'api_key_id' ? await keyNames($) : null
270 const byName = new Map<string, { model: string; input: number; output: number; requests: number }>()
271 for (const b of d.data ?? []) {
272 for (const r of b.results ?? []) {
273 const name = names ? keyName(names, r.api_key_id) : (r.model ?? '?')
274 const m = byName.get(name) ?? { model: name, input: 0, output: 0, requests: 0 }
275 m.input += r.input_tokens ?? 0
276 m.output += r.output_tokens ?? 0
277 m.requests += r.num_model_requests ?? 0
278 byName.set(name, m)
279 }
280 }
281 return [...byName.values()].sort((a, b) => b.input + b.output - (a.input + a.output))
282}
283
284// The newest minute today with a request in it: which model, through which key. The hour first, then its minutes,
285// so no request needs more than 60 buckets. Model and key come from two groupings, so with two keys busy in the
286// same minute the pair is a best guess.
287async function lastCall($: EngineInterface, start: number): Promise<Line['last']> {
288 const hour = newest(await usage($, `start_time=${start}&bucket_width=1h&limit=24&group_by=model`))
289 if (!hour) return null
290 const window = `start_time=${hour.at}&end_time=${hour.at + 3600}&bucket_width=1m&limit=60`
291 const [byModel, byKey] = await Promise.all((['model', 'api_key_id'] as const).map(by => usage($, `${window}&group_by=${by}`)))
292 const m = newest(byModel)
293 if (!m) return null
294 const k = newest(byKey)
295 return { at: m.at, model: m.r.model ?? '?', key: k ? keyName(await keyNames($), k.r.api_key_id) : '?' }
296}
297
298// The newest bucket with a request in it, and its busiest row.
299function newest(d: { data: any[] }) {
300 let best: { at: number; r: any } | null = null
301 for (const b of d.data) {
302 for (const r of b.results ?? []) {
303 const n = r.num_model_requests ?? 0
304 if (n > 0 && (!best || b.start_time > best.at || (b.start_time === best.at && n > (best.r.num_model_requests ?? 0)))) best = { at: b.start_time, r }
305 }
306 }
307 return best
308}
309
310// The same query over every usage type, buckets merged. Only a type the API doesn't have (404) is skipped.
311async function usage($: EngineInterface, query: string) {
312 const answers = await Promise.all(
313 USAGE.map(type =>
314 pages($, `usage/${type}?${query}`).catch(err => {
315 if (err instanceof HttpError && err.status === 404) return []
316 throw err
317 }),
318 ),
319 )
320 return { data: answers.flat() }
321}
322
323// Every bucket of a usage query, following next_page.
324async function pages($: EngineInterface, path: string) {
325 const out: any[] = []
326 let page = ''
327 do {
328 const d = await call($, `${path}${page && `&page=${encodeURIComponent(page)}`}`)
329 out.push(...(d.data ?? []))
330 page = d.has_more && d.next_page ? d.next_page : ''
331 } while (page)
332 return out
333}
334
335// Key id → the name it was given, across every project. One lookup shared by concurrent callers, kept an hour.
336let keyCache: { at: number; names: Promise<Map<string, string>> } | null = null
337async function keyNames($: EngineInterface) {
338 const now = await $.clock.now()
339 if (!keyCache || now - keyCache.at > 60 * 60 * 1000) {
340 const entry = { at: now, names: loadKeyNames($).then(r => {
341 if (!r.isComplete && keyCache === entry) keyCache = null // a lookup that missed some keys is tried again next time
342 return r.names
343 }) }
344 keyCache = entry
345 }
346 return keyCache.names
347}
348
349// A key without access to the key lists still gets its usage, shown by id (key …abcd).
350async function loadKeyNames($: EngineInterface) {
351 const names = new Map<string, string>()
352 let isComplete = true
353 const projects = await list($, 'projects?limit=100').catch(() => ((isComplete = false), []))
354 for (const p of projects) {
355 const keys = await list($, `projects/${p.id}/api_keys?limit=100`).catch(() => ((isComplete = false), []))
356 for (const k of keys) names.set(k.id, k.name || k.redacted_value || k.id)
357 }
358 return { names, isComplete }
359}
360
361// Every item of a paged list endpoint.
362async function list($: EngineInterface, path: string) {
363 const out: any[] = []
364 let after = ''
365 do {
366 const d = await call($, `${path}${after && `&after=${encodeURIComponent(after)}`}`)
367 out.push(...(d.data ?? []))
368 after = d.has_more && d.last_id ? d.last_id : ''
369 } while (after)
370 return out
371}
372
373function keyName(map: Map<string, string>, id: string | null | undefined) {
374 if (!id) return 'no key'
375 return map.get(id) ?? `key …${id.slice(-4)}`
376}
377
378async function call($: EngineInterface, path: string) {
379 const res = await $.http.fetch(`${API}/${path}`, { headers: { authorization: `Bearer ${await adminKey($)}` } })
380 if (res.status === 401 || res.status === 403) {
381 key = '' // read it again next time, in case it was replaced
382 throw new KeyRejected()
383 }
384 if (!res.ok) throw new HttpError(res.status)
385 return JSON.parse(res.text)
386}
387
388// From /config first, then OPENAI_ADMIN_KEY, then the macOS Keychain.
389async function adminKey($: EngineInterface) {
390 if (key) return key
391 const run = async (argv: string[]) => {
392 const r = await $.process.run(argv).catch(() => null)
393 return r && r.exitCode === 0 ? r.stdout.trim() : ''
394 }
395 const k = configKey || (await run(['printenv', 'OPENAI_ADMIN_KEY'])) || (await run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-w']))
396 if (!k.startsWith('sk-admin-')) throw new NoKey()
397 return (key = k)
398}
399
400function problem(err: unknown) {
401 if (err instanceof NoKey) return `No OpenAI Admin key found. ${SETUP}`
402 if (err instanceof KeyRejected) return `OpenAI refused the Admin key (401/403): it may be revoked, or not an Admin key. ${SETUP}`
403 return `Couldn't reach OpenAI: ${err instanceof Error ? err.message : String(err)}`
404}
405
406// A model or line item without its billing part, fine-tune suffix or snapshot date:
407// "x, input" → "x", "ft:gpt-4o-mini-2024-07-18:org::id" → "gpt-4o-mini", "gpt-5-2025-08-07" → "gpt-5".
408const short = (name: string) => {
409 const base = name.split(',')[0].trim()
410 return (base.startsWith('ft:') ? base.split(':')[1] : base).replace(/-\d{4}-\d{2}-\d{2}$/, '')
411}
412// For comparing a Costs line item with a Usage model id: case, spaces and dashes don't count.
413const same = (name: string) => short(name).toLowerCase().replace(/[^a-z0-9]/g, '')
414const dayStart = (s: number) => s - (s % DAY_S)
415const usd = (n: number) => {
416 const cents = Math.round(n * 100)
417 return `${cents < 0 ? '-' : ''}$${(Math.abs(cents) / 100).toFixed(2)}`
418}
419const num = (n: number) => Math.round(n).toLocaleString('en-US')
420const clock = (s: number) => {
421 const d = new Date(s * 1000)
422 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
423}
424const usd4 = (n: number) => `$${n.toFixed(4)}`
425const date = (s: number) => new Date(s * 1000).toISOString().slice(0, 10)
426types/index.d.ts 18 lines1// What the band above the prompt draws.
2export type Line = {
3 left: number | null // estimated balance, null until one is set
4 start: number | null // the balance it was set to, for the gauge
5 today: number // spend in the current UTC day
6 last: { at: number; model: string; key: string } | null // the newest request today (UTC)
7 top: string | null // today's biggest cost line item, for spend with no usage row (Decisions)
8 error: 'no-key' | 'rejected' | 'offline' | null // offline keeps the last numbers, marked
9 isLoaded: boolean
10 hasData: boolean // a refresh has succeeded at least once
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'openai-balance': { line: Line }
16 }
17}
18