SLOPSHOPPER

openai-balance

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…

newbandcommandprocessnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · openai-balance
› 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 › /openai-balance ⎿ openai-balance: No OpenAI Admin key found. Needs an OpenAI organization **Admin key** (read-only is enough), from platform.ope ◆ OpenAI no admin key set, see /openai-balance ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ OpenAI no admin key set, see /openai-balance ⟨Claude Code's own drawing⟩
README

claude-code-mods-collection

A private reference collection of public Claude Code mods, with a security-oriented analysis.

  • ANALYSIS.md: what each mod can reach, which to install, which to avoid
  • SOURCES.md: upstream repo and pinned commit for every folder in mods/

Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.

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