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.

<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>
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 checklistIt 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.
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.
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.
| 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. language sets their language | 1 2 3, 0 hides |
| 💰 | usage-meter | 5-hour and 7-day plan usage, the reset countdown and the session's cost | |
| 🔥 | heatmap | A GitHub-style grid of your Claude Code time, with your streak, peak hours and top projects | /heatmap |
| 💳 | 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 |
| 🪙 | openrouter-balance | Your 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-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 |
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.
| Mod | What it does | Install | |
|---|---|---|---|
| 🖼️ | image-view by @jarrodwatts | See the images you paste into Claude Code: thumbnails above the prompt instead of bare [Image #1] tags | claude plugin marketplace add jarrodwatts/claude-image-view then claude plugin install image-view@claude-image-view |
| 🏷️ | last-prompt-title by @zhangbububu | Session titles that follow your latest prompt: cut for the CLI, written by Haiku for the desktop sidebar | claude plugin marketplace add zhangbububu/session-titles then claude plugin install last-prompt-title@session-titles |
| 🌏 | cc-zh by @jiangnanquan | Simplified 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.296 | claude plugin marketplace add jiangnanquan/claude-code-zh then claude plugin install cc-zh@claude-code-zh |
| 🎛️ | subagent-tuner by @Kruhlikau | Picks 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 for | claude plugin marketplace add Kruhlikau/subagent-tuner then claude plugin install subagent-tuner@subagent-tuner |
| 🛒 | mod-store by @hellosverre | An 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 one | claude plugin marketplace add hellosverre/mod-store then claude plugin install mod-store@mod-store |
| 📈 | cache-meter by @T-AI-Labs | The share of each turn the prompt cache served, next to the context fill, with an auto-compact threshold and Compact and Clear buttons | claude plugin marketplace add T-AI-Labs/claude-code-mods then claude plugin install cache-meter@T-AI-Labs |
| ⏳ | usage-limits by @T-AI-Labs | 5-hour and weekly plan limits in percent, with the time left until each resets | claude 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>
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 377 lines1// 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}
377hooks/bands.ts 73 lines1// 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}
73types/index.d.ts 20 lines1// 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