SLOPSHOPPER

openrouter-balance

Your OpenRouter credit above the prompt: the balance with a gauge, today's spend and where the money mostly went. /openrouter-balance shows the breakdown.

newbandcommandprocessnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · openrouter-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 › /openrouter-balance ⎿ openrouter-balance: No OpenRouter key found. Needs an OpenRouter key. A **management key** (openrouter.ai → Settings → Management ◆ OpenRouter no key set, see /openrouter-balance ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ OpenRouter no key set, see /openrouter-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 377 lines
1// OpenRouter Balance: your OpenRouter credit, in a band above the prompt.
2// A management key sees the account: the real balance from /credits, and spend by model from /activity.
3// A regular API key sees the balance too, when OpenRouter lets it, and its own spend and limit.
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 = 5 * 60 * 1000 // the balance is live, but nobody needs it by the second
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 ACTIVITY_MS = 60 * 60 * 1000 // /activity holds completed days only, so once an hour is plenty
15const GRACE_S = 15 * 60 // a day first seen this soon after midnight UTC counts as seen from its start
16const KEYCHAIN_SERVICE = 'openrouter-key'
17const API = 'https://openrouter.ai/api/v1'
18const DAY_S = 86_400
19const LOW = 2 // dollars: red below this
20const MID = 5 // dollars: yellow below this
21const BRAND = '#8b9cff' // periwinkle: apart from the green gauge and openai-balance's violet
22const GAUGE = 10
23const EMPTY: Line = { kind: null, left: null, start: null, today: null, month: null, since: null, top: null, error: null, isLoaded: false, hasData: false }
24const SETUP =
25  'Needs an OpenRouter key. A **management key** (openrouter.ai → Settings → Management keys) shows the account balance and spend by model; a regular API key shows its own spend and limit. Put it in `/config` → openrouter-balance, or in `OPENROUTER_MANAGEMENT_KEY` or `OPENROUTER_API_KEY`, or on macOS in Keychain: `security add-generic-password -a "$USER" -s openrouter-key -w`.'
26
27// The account's all-time usage when this UTC day was first seen, so today's spend is the difference.
28type Mark = { day: number; usage: number; at: number }
29type Activity = { date: string; model: string; usage: number; requests: number; prompt_tokens: number; completion_tokens: number }
30
31// Held by the host, so the line survives a hot reload of this file.
32const line = atom({ plugin: 'openrouter-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
40let activityCache: { at: number; items: Promise<Activity[]> } | null = null
41let lastKey = '' // the key the cached numbers came from, in memory only
42
43class NoKey extends Error {}
44class KeyRejected extends Error {}
45class HttpError extends Error {
46  constructor(readonly status: number) {
47    super(`OpenRouter answered ${status}`)
48  }
49}
50
51let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
52
53export const register: Register = (on, options) => {
54  configKey = typeof options.apiKey === 'string' ? options.apiKey.trim() : ''
55  key = '' // a key changed in /config is read again
56
57  on('session.start', async ($, e, next) => {
58    const result = await next(e)
59    await $.command
60      .register({ name: 'openrouter-balance', description: 'OpenRouter credit, spend today and spend by model' })
61      .catch(() => {}) // a name Claude Code already has is refused: start anyway
62    void refresh($, true).catch(() => {}) // in the background: a slow API never holds up the session
63    for (const t of timers) t.cancel()
64    timers = [$.clock.every(TICK_MS, () => void refresh($, false).catch(() => {}))]
65    return result
66  })
67
68  on('turn.complete', async ($, e, next) => {
69    const result = await next(e)
70    if (!e.agentId) void refresh($, false).catch(() => {}) // main-loop turns only, not subagents
71    return result
72  })
73
74  on('command.run', { command: 'openrouter-balance' }, async $ => {
75    try {
76      return { text: await detail($) }
77    } catch (err) {
78      return { text: problem(err) }
79    }
80  })
81
82  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
83    const rest = await next(e) // what other mods and Claude Code draw here stays
84    const now = { ...EMPTY, ...(await read($, line)) } // a value an older version saved may lack a field
85    if (e.props.hasSurvey || !now.isLoaded) return rest
86
87    const { Box, Text } = $.ui.resolve(e)
88    const isWide = e.props.bodyColumns >= 60
89    const sep = <Text dimColor>{' · '}</Text>
90    const head = <Text color={BRAND} bold>{'◆ OpenRouter  '}</Text>
91    const today = now.today !== null && (
92      <Text>
93        <Text bold>{usd(now.today)}</Text>
94        <Text dimColor>{now.since !== null ? ` since ${clock(now.since)}` : ' today'}</Text>
95      </Text>
96    )
97
98    let body
99    if (now.error === 'no-key') body = <Text color="red">no key set, see /openrouter-balance</Text>
100    else if (now.error === 'rejected') body = <Text color="red">key rejected, see /openrouter-balance</Text>
101    else if (!now.hasData) body = <Text dimColor>{"can't reach OpenRouter yet"}</Text>
102    else if (now.left === null) {
103      // A regular key with no limit: its spend is all there is to show.
104      body = (
105        <Text>
106          {today}
107          {now.month !== null && sep}
108          {now.month !== null && <Text bold>{usd(now.month)}</Text>}
109          {now.month !== null && <Text dimColor>{' this month'}</Text>}
110          {isWide && sep}
111          {isWide && <Text dimColor>{'a management key shows the balance'}</Text>}
112          {now.error === 'offline' && <Text dimColor>{' · offline'}</Text>}
113        </Text>
114      )
115    } else {
116      const tone = now.left < LOW ? 'red' : now.left < MID ? 'yellow' : 'green'
117      const filled = now.start !== null && now.start > 0 ? Math.max(now.left > 0 ? 1 : 0, Math.min(GAUGE, Math.round((now.left / now.start) * GAUGE))) : 0 // any credit left shows a block
118      const of = now.start !== null ? ` of ${usd(now.start)}${now.kind === 'key' ? ' key limit' : ' credit'}` : '' // which one binds: the key's limit or the account's credit
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>{of}{now.left < LOW ? ' left, top up soon' : ' left'}</Text>
125          {today && sep}
126          {today}
127          {isWide && now.top && <Text dimColor>{', mostly '}</Text>}
128          {isWide && now.top && <Text color="cyan">{short(now.top)}</Text>}
129          {now.error === 'offline' && <Text dimColor>{' · offline'}</Text>}
130        </Text>
131      )
132    }
133
134    const mine = (
135      <Box paddingX={1}>
136        <Text wrap="truncate-end">
137          {head}
138          {body}
139        </Text>
140      </Box>
141    )
142    return stackBands(rest, 'openrouter-balance', mine, 'above', await (bandOrder ??= readBandOrder($).catch(() => []))) // above what other mods draw, unless the band order says otherwise
143  })
144}
145
146// The band order file, as the claude-code-mods checklist saves it; [] on any error.
147async function readBandOrder($: EngineInterface): Promise<string[]> {
148  const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
149  return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
150}
151
152// Forgets the cached band order, for tests.
153export function resetBandOrder() {
154  bandOrder = undefined
155}
156
157// Updates what the band draws. Every 5 minutes, a minute after a failure, or now when forced.
158async function refresh($: EngineInterface, isForced: boolean) {
159  const now = await $.clock.now()
160  if (!isForced && (now < nextTry || (running && now - running.since < STUCK_MS))) return
161  const mine = ++generation
162  running = { since: now }
163  try {
164    const next = await snapshot($, now)
165    if (mine !== generation) return
166    nextTry = now + EVERY_MS
167    await update($, line, () => next)
168  } catch (err) {
169    if (mine !== generation) return
170    nextTry = now + RETRY_MS // a fixed key or a network back shows within a minute
171    const error: Line['error'] = err instanceof NoKey ? 'no-key' : err instanceof KeyRejected ? 'rejected' : 'offline'
172    await update($, line, l => ({ ...EMPTY, ...l, error, isLoaded: true })) // offline keeps the last numbers, marked
173  } finally {
174    if (mine === generation) running = null
175  }
176}
177
178async function snapshot($: EngineInterface, now: number): Promise<Line> {
179  const info = await keyInfo($)
180  const loaded = { ...EMPTY, isLoaded: true, hasData: true }
181  const isMgmt = isManagement(info)
182  const credits = isMgmt ? await account($) : await account($, true) // a regular key often sees the balance too
183  if (!credits) {
184    return { ...loaded, kind: 'key', left: num(info.limit_remaining), start: num(info.limit), today: num(info.usage_daily), month: num(info.usage_monthly) }
185  }
186  const keyLeft = num(info.limit_remaining)
187  if (!isMgmt && keyLeft !== null && keyLeft < credits.total_credits - credits.total_usage) {
188    return { ...loaded, kind: 'key', left: keyLeft, start: num(info.limit), today: num(info.usage_daily), month: num(info.usage_monthly) } // the key's limit runs out first
189  }
190  const mark = await dayMark($, Math.floor(now / 1000), credits.total_usage)
191  const items = isMgmt ? await activity($, now).catch(() => []) : [] // a hiccup here never hides the balance; a regular key is refused
192  return {
193    ...loaded,
194    kind: 'account',
195    left: credits.total_credits - credits.total_usage,
196    start: credits.total_credits,
197    today: Math.max(0, credits.total_usage - mark.usage),
198    since: mark.at - mark.day > GRACE_S ? mark.at : null,
199    top: byModel(items)[0]?.model ?? null,
200  }
201}
202
203// The first all-time usage seen this UTC day. A day first seen late says "since" instead of "today".
204async function dayMark($: EngineInterface, nowS: number, usage: number): Promise<Mark> {
205  const day = dayStart(nowS)
206  const saved = (await $.store.get('mark')) as Mark | undefined
207  if (saved && saved.day === day && typeof saved.usage === 'number') return saved
208  const mark = { day, usage, at: nowS }
209  await $.store.set('mark', mark)
210  return mark
211}
212
213async function detail($: EngineInterface) {
214  const now = await $.clock.now()
215  const info = await keyInfo($)
216  const lines = ['## OpenRouter balance', '']
217
218  if (!isManagement(info)) {
219    const credits = await account($, true)
220    if (credits) {
221      const mark = await dayMark($, Math.floor(now / 1000), credits.total_usage) // the same "today" the band shows
222      lines.push(
223        `- **Account balance:** ${usd(credits.total_credits - credits.total_usage)} (${usd(credits.total_credits)} bought, ${usd(credits.total_usage)} used)`,
224        `- **Account spend today (UTC):** ${usd(Math.max(0, credits.total_usage - mark.usage))}${mark.at - mark.day > GRACE_S ? ` since ${clock(mark.at)} your time, when this mod first saw the day` : ''}`,
225        '',
226      )
227    }
228    lines.push(
229      `This key's own spend (a regular API key; a management key shows spend by model):`,
230      '',
231      `- **Today (UTC):** ${usd(num(info.usage_daily) ?? 0)}`,
232      `- **This week:** ${usd(num(info.usage_weekly) ?? 0)}`,
233      `- **This month:** ${usd(num(info.usage_monthly) ?? 0)}`,
234      `- **All time:** ${usd(num(info.usage) ?? 0)}`,
235    )
236    if (num(info.limit) !== null) {
237      const reset = info.limit_reset ? `, resets ${info.limit_reset}` : ''
238      lines.push(`- **Key limit:** ${usd(num(info.limit_remaining) ?? 0)} left of ${usd(info.limit)}${reset}`)
239    } else lines.push('- **Key limit:** none')
240    const b = info.effective_budget
241    if (b && typeof b.limit_usd === 'number' && b.scope !== 'key_limit') lines.push(`- **Budget:** ${usd(b.remaining_usd ?? 0)} left of ${usd(b.limit_usd)}${b.reset_interval ? ` ${b.reset_interval}` : ''}`)
242    const free = info.free_model_daily_requests
243    if (info.is_free_tier && free) lines.push(`- **Free model requests today:** ${free.remaining} of ${free.limit} left`)
244    return lines.join('\n')
245  }
246
247  const credits = await account($)
248  const mark = await dayMark($, Math.floor(now / 1000), credits.total_usage)
249  const items = await activity($, now)
250  const yesterday = new Date((dayStart(Math.floor(now / 1000)) - DAY_S) * 1000).toISOString().slice(0, 10)
251  const today = Math.max(0, credits.total_usage - mark.usage)
252  lines.push(
253    `- **Balance:** ${usd(credits.total_credits - credits.total_usage)} (${usd(credits.total_credits)} bought, ${usd(credits.total_usage)} used)`,
254    `- **Today (UTC):** ${usd(today)}${mark.at - mark.day > GRACE_S ? ` since ${clock(mark.at)} your time, when this mod first saw the day` : ''}`,
255    `- **Yesterday:** ${usd(sum(items.filter(i => i.date === yesterday)))}`,
256    `- **Last 30 days:** ${usd(sum(items))}`,
257    '',
258    '**By model, last 30 days**',
259  )
260  const models = byModel(items)
261  if (models.length === 0) lines.push('- none')
262  for (const m of models) lines.push(`- ${m.model}: ${usd4(m.usage)}, ${int(m.requests)} requests, ${int(m.input)} in / ${int(m.output)} out`)
263  lines.push('', '_Spend by model counts completed UTC days only, as OpenRouter reports it. Today is the all-time usage now minus where it stood when the day began._')
264  return lines.join('\n')
265}
266
267// The last 30 days' spend per model, biggest first.
268function byModel(items: Activity[]) {
269  const map = new Map<string, { model: string; usage: number; requests: number; input: number; output: number }>()
270  for (const i of items) {
271    const m = map.get(i.model) ?? { model: i.model, usage: 0, requests: 0, input: 0, output: 0 }
272    m.usage += Number(i.usage) || 0
273    m.requests += Number(i.requests) || 0
274    m.input += Number(i.prompt_tokens) || 0
275    m.output += Number(i.completion_tokens) || 0
276    map.set(i.model, m)
277  }
278  return [...map.values()].filter(m => m.usage > 0).sort((a, b) => b.usage - a.usage)
279}
280
281const sum = (items: Activity[]) => items.reduce((s, i) => s + (Number(i.usage) || 0), 0)
282
283const keyInfo = async ($: EngineInterface) => (await call($, 'key')).data ?? {}
284// With isOptional, a refusal or an answer without numbers is null, not an error: the key's own numbers still show.
285async function account($: EngineInterface, isOptional?: false): Promise<{ total_credits: number; total_usage: number }>
286async function account($: EngineInterface, isOptional: true): Promise<{ total_credits: number; total_usage: number } | null>
287async function account($: EngineInterface, isOptional = false) {
288  const d = (await call($, 'credits', isOptional))?.data ?? {}
289  const total_credits = num(d.total_credits)
290  const total_usage = num(d.total_usage)
291  if (total_credits === null || total_usage === null) {
292    if (isOptional) return null
293    throw new Error('OpenRouter sent no credit numbers')
294  }
295  return { total_credits, total_usage }
296}
297const isManagement = (info: any) => info.is_management_key === true || info.is_provisioning_key === true
298
299// One lookup shared by concurrent callers, kept an hour. A failed one is tried again next time.
300async function activity($: EngineInterface, now: number): Promise<Activity[]> {
301  if (!activityCache || now - activityCache.at > ACTIVITY_MS) {
302    const entry = {
303      at: now,
304      items: call($, 'activity').then(
305        d => (Array.isArray(d.data) ? d.data : []),
306        err => {
307          if (activityCache === entry) activityCache = null
308          throw err
309        },
310      ),
311    }
312    activityCache = entry
313  }
314  return activityCache.items
315}
316
317async function call($: EngineInterface, path: string, isOptional = false) {
318  const auth = { authorization: `Bearer ${await apiKey($)}` }
319  const res = isOptional ? await $.http.fetch(`${API}/${path}`, { headers: auth }).catch(() => null) : await $.http.fetch(`${API}/${path}`, { headers: auth })
320  if (isOptional && (!res || !res.ok)) return null // refused or down: the key's own numbers still show, and the key is not rejected
321  if (!res) return null // for the type: only an optional fetch is ever null
322  if (res.status === 401 || res.status === 403) {
323    key = '' // read it again next time, in case it was replaced
324    throw new KeyRejected()
325  }
326  if (!res.ok) throw new HttpError(res.status)
327  try {
328    return JSON.parse(res.text)
329  } catch (err) {
330    if (isOptional) return null
331    throw err
332  }
333}
334
335// From /config first, then OPENROUTER_MANAGEMENT_KEY, OPENROUTER_API_KEY, then the macOS Keychain.
336async function apiKey($: EngineInterface) {
337  if (key) return key
338  const run = async (argv: string[]) => {
339    const r = await $.process.run(argv).catch(() => null)
340    return r && r.exitCode === 0 ? r.stdout.trim() : ''
341  }
342  const k =
343    configKey ||
344    (await run(['printenv', 'OPENROUTER_MANAGEMENT_KEY'])) ||
345    (await run(['printenv', 'OPENROUTER_API_KEY'])) ||
346    (await run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-w']))
347  if (!k.startsWith('sk-or-')) throw new NoKey()
348  if (lastKey && k !== lastKey) {
349    activityCache = null // another key may be another account: drop its numbers
350    await $.store.set('mark', null).catch(() => {}) // and its start-of-day mark
351  }
352  lastKey = k
353  return (key = k)
354}
355
356function problem(err: unknown) {
357  if (err instanceof NoKey) return `No OpenRouter key found. ${SETUP}`
358  if (err instanceof KeyRejected) return `OpenRouter refused the key (401/403): it may be revoked or disabled. ${SETUP}`
359  return `Couldn't reach OpenRouter: ${err instanceof Error ? err.message : String(err)}`
360}
361
362// A model id without its provider or variant: "anthropic/claude-sonnet-4.5" → "claude-sonnet-4.5",
363// "meta-llama/llama-3.3-70b-instruct:free" → "llama-3.3-70b-instruct".
364const short = (model: string) => model.split('/').pop()!.split(':')[0]
365const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
366const dayStart = (s: number) => s - (s % DAY_S)
367const usd = (n: number) => {
368  const cents = Math.round(n * 100)
369  return `${cents < 0 ? '-' : ''}$${(Math.abs(cents) / 100).toFixed(2)}`
370}
371const usd4 = (n: number) => `$${n.toFixed(4)}`
372const int = (n: number) => Math.round(n).toLocaleString('en-US')
373const clock = (s: number) => {
374  const d = new Date(s * 1000)
375  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` // your local time: the day itself is UTC
376}
377
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 20 lines
1// What the band above the prompt draws.
2export type Line = {
3  kind: 'account' | 'key' | null // a management key sees the account; a regular key only itself
4  left: number | null // account credit, or what the key's limit leaves; null when neither is known
5  start: number | null // credits bought, or the key's limit, for the gauge
6  today: number | null // spend in the current UTC day, null when it can't be told yet
7  month: number | null // the key's spend this UTC month (regular key only)
8  since: number | null // unix seconds: "today" counts from here when the mod first saw the day after it began
9  top: string | null // the model the last 30 days' money mostly went to (management key only)
10  error: 'no-key' | 'rejected' | 'offline' | null // offline keeps the last numbers, marked
11  isLoaded: boolean
12  hasData: boolean // a refresh has succeeded at least once
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'openrouter-balance': { line: Line }
18  }
19}
20