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
★ 262v0.1.2MITupdated 2026-10-15hamzafer/claude-code-mods/mods/openrouter-balance
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

<h1 align="center">Claude Code mods</h1>

<a href="https://www.npmjs.com/package/claude-code-mods"><img src="https://img.shields.io/npm/v/claude-code-mods.svg" alt="npm"></a> <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>

🚀 Install

npx claude-code-mods@latest

Tick the mods you want in a checklist, see what each one adds, and press Enter to install. Untick a mod to disable it (its settings stay). Restart Claude Code after. @latest makes npx skip an old cached copy.

  • --project installs for this project only
  • --dry-run prints the claude commands instead of running them
  • --pick a,b turns on exactly these mods, no checklist

It edits the Claude profile that claude uses here, shown at the top. For another profile, set it first: CLAUDE_CONFIG_DIR=~/.claude-other npx claude-code-mods@latest.

Order your bands

Press o in the checklist to order your bands (the lines above the prompt). Move the cursor with up/down or j/k, move a band with Shift+up/down or K/J, and press Enter to save. Esc goes back without saving. The order goes to <config dir>/claude-code-mods/bands.json, where the config dir is CLAUDE_CONFIG_DIR or ~/.claude. Restart Claude Code to apply it. Bands not in the file sit on top.

Other ways

By hand:

claude plugin marketplace add hamzafer/claude-code-mods
claude plugin install context-bar@claude-code-mods

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.

🧩 The mods

👀 See what's happening

ModWhat it doesCommand
🛰️mission-controlLive map of agents, tool calls and the code they touch/mission
📊context-barYour context window as one stacked bar, a color per category, with token counts and where it compacts/context-bar
🌦️token-weatherContext fill from Clear to Compact soon, plus a prompt-cache countdown
⏱️cache-clockA 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-iGoal, doing now, waiting on you, next step/where
➡️next-steps2 or 3 likely next prompts after each turn, one key to draft one. language sets their language1 2 3, 0 hides
💰usage-meter5-hour and 7-day plan usage, the reset countdown and the session's cost
🔥heatmapA GitHub-style grid of your Claude Code time, with your streak, peak hours and top projects/heatmap
💳openai-balanceYour 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
🪙openrouter-balanceYour OpenRouter credit: the real balance with a gauge, today's spend and the model the money mostly went to. A regular API key shows the balance too when OpenRouter allows it, plus its own spend and limit/openrouter-balance
📡agent-radarOne live line per running subagent/radar
🔍review-watchOne 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-lanesWhether this session has a browser, and who holds it/browser
🕌prayer-timesThe current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr/prayers
🎬replay-theaterSteps through the last turn's edits, one diff at a time/replay
📝md-previewRenders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images/md

🛡️ Guard your repo

ModWhat it doesCommand
💥blast-radiusHolds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer

💸 Spend less

ModWhat it doesCommand
🔀switchboardPicks 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

🔧 My setup (fork and adapt)

These are built around my own tools and rules. Fork them and change the rules to yours.

ModWhat it doesCommand
👀glanceOne line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors/glance
🚦merge-gateHolds 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-guardEnforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes
💾session-saverSaves where you left off, shows it on resume. Needs unpause/park [note]

🎮 For fun (opt-in)

ModWhat it doesCommand
📱reelsYouTube Shorts while Claude works, pauses when it's done/reels
🐍snakeSnake while Claude works/snake
🎵now-playingWhat Spotify is playing, with a progress bar, the lyric being sung, and ⏮ ⏸ ⏭ buttons. Needs macOS and the Spotify app/music

🤝 Community mods

Mods by other people, in their own marketplaces. They're not maintained here, so read the code before you install. They also show up last in the npx claude-code-mods@latest checklist.

ModWhat it doesInstall
🖼️image-view by @jarrodwattsSee the images you paste into Claude Code: thumbnails above the prompt instead of bare [Image #1] tagsclaude plugin marketplace add jarrodwatts/claude-image-view then claude plugin install image-view@claude-image-view
🏷️last-prompt-title by @zhangbububuSession titles that follow your latest prompt: cut for the CLI, written by Haiku for the desktop sidebarclaude plugin marketplace add zhangbububu/session-titles then claude plugin install last-prompt-title@session-titles
🌏cc-zh by @jiangnanquanSimplified Chinese UI localization via static dictionary: slash-command descriptions, /config labels, spinner, hints, notices, command output, tool summaries and turn duration. Zero tokens / network; verified on CC 2.1.296claude plugin marketplace add jiangnanquan/claude-code-zh then claude plugin install cc-zh@claude-code-zh
🎛️subagent-tuner by @KruhlikauPicks the model and effort of every subagent: a Manual/Auto band, a pick pane, review → Opus and search → Haiku, and a saved default that overrides what the agent asked forclaude plugin marketplace add Kruhlikau/subagent-tuner then claude plugin install subagent-tuner@subagent-tuner
🛒mod-store by @hellosverreAn app store in a pane: /mods browses, searches and installs about 2,700 community mods, with what each one can access shown before you install. Or ask Claude to find oneclaude plugin marketplace add hellosverre/mod-store then claude plugin install mod-store@mod-store
📈cache-meter by @T-AI-LabsThe share of each turn the prompt cache served, next to the context fill, with an auto-compact threshold and Compact and Clear buttonsclaude plugin marketplace add T-AI-Labs/claude-code-mods then claude plugin install cache-meter@T-AI-Labs
⏳usage-limits by @T-AI-Labs5-hour and weekly plan limits in percent, with the time left until each resetsclaude plugin marketplace add T-AI-Labs/claude-code-mods then claude plugin install usage-limits@T-AI-Labs

Built one? Open a PR that adds a row.

<a id="mission-control"></a>

🛰️ Flagship: mission-control

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.

mission-control at 4x: two subagents and a logout feature landing 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, live
  • 🗺️ c shows the code map, with import arrows
  • 🔵 Blue while the agent reads a file
  • 🟠 Orange while it writes
  • 🟢 Green when done, with one line on what changed

The Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.

📚 More

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