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.

Mods for Claude Code, built for SEO and content work. Install only the ones you want.
Every mod installs on its own. The quick version:
claude plugin marketplace add krysmalskipl/claude-code-mods
claude plugin install <mod>@krysmalskipl
Then restart Claude Code. Each mod below has the full steps, including any key it needs.
Needs Claude Code 2.1.287+ in the terminal, or the Claude desktop app's Code tab. Plugins installed from the terminal show up in both.
| Mod | What it does | Command | ||
|---|---|---|---|---|
| 🪙 | 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 its own spend and limit instead | /openrouter-balance | |
| 📏 | meta-lint | The SEO basics of the page or post Claude just edited: title and meta description length, H1 count, images without alt, word count. Markdown, MDX, Astro and HTML | /meta-lint [file] | |
| 🔗 | link-check | Checks the external links Claude adds. Broken ones go back to Claude so it can fix them, and show above the prompt | /links [file] | |
| 🪧 | share-preview | How the page looks in Google (desktop and mobile, cut by pixel width) and as a shared link on LinkedIn, Facebook, Slack and X, with what would cost you clicks. Live while Claude edits | `/preview [url \ | file]` |
| ⏱️ | cache-stats | Your prompt cache above the prompt: hit rate, how long it stays warm, what a cold restart re-caches, and how long the session has run. /cache lists every miss and why | `/cache [hide\ | show]` |

One line above the prompt after Claude edits a page or post:
◆ SEO post.md title 58/60 ✓ · description 186/160 ✗ · H1 1 ✓ · 2 images without alt · 2,726 words
claude plugin marketplace add krysmalskipl/claude-code-mods # once per machine
claude plugin install meta-lint@krysmalskipl
Restart Claude Code or type /reload-plugins. No key, no setup: it reads files on your machine and sends nothing anywhere. The line appears after Claude's first Write or Edit of a .md, .mdx, .markdown, .astro, .html or .htm file.
| Check | Where it looks | Green | Yellow | Red |
|---|---|---|---|---|
| Title | Frontmatter seo_title, meta_title or title (the first one set), else <title>, else the first # H1 | 30 to 60 characters | under 30 | over 60, or missing |
| Description | Frontmatter seo_description, meta_description, description or excerpt, else <meta name="description"> | 70 to 160 characters | under 70 | over 160, or missing |
| H1 | # lines and <h1> tags in the body, outside code blocks | one, or none when the frontmatter has a title (templates usually render it as the H1) | more than one, or none at all | |
| Images without alt | and <img> without alt | any | ||
| Words | the body, without code | shown, not judged |
/meta-lint shows the details of the last page: each value, where it came from, how far over or under it is, and a search snippet preview cut where Google would likely cut it/meta-lint path/to/file.md checks any file, edited or notLimits are in characters. Google cuts titles by pixel width (about 600 px), so a title of wide letters can be cut sooner: treat the numbers as a guide.
Checks every external link Claude adds to a page, post or template, while Claude is still working.
◆ Links 2 not working · https://gone.dev/page (404) in post.md and 1 more · /links
claude plugin marketplace add krysmalskipl/claude-code-mods # once per machine
claude plugin install link-check@krysmalskipl
Restart Claude Code or type /reload-plugins. No key, no setup.
Write or Edit of a .md, .mdx, .astro, .html, .tsx, .jsx, .vue, .svelte, .php, .njk, .liquid or .twig file, it finds the http(s) links new in that edit and asks each one (a HEAD, or a GET when the host refuses HEAD)/links lists themlocalhost, 127.0.0.1, example.com, .local, .test and links with template placeholders (${…}, {{…}})/links lists the links that don't work, and where/links path/to/file.md checks every link in a file now (up to 100), with ✓ working, ↪ redirect, ~ blocks bots and ✗ brokenSee the page you're building the way people will meet it: as a Google result and as a link someone shares. A pane next to the chat, updated while Claude edits.
✗ og:image "/og.png" is relative: platforms need a full https:// URL
! Title is 668px wide, Google shows about 600px: the end is cut
! No twitter:card: X shows a small thumbnail, add summary_large_image for the big card
✓ Description fits desktop and mobile (512px)
claude plugin marketplace add krysmalskipl/claude-code-mods # once per machine
claude plugin install share-preview@krysmalskipl
Restart Claude Code or type /reload-plugins. No key, no setup.
/preview shows the page Claude edited last: HTML, Astro, Vue, Svelte, JSX with <Head>, a Next.js metadata export or Markdown frontmatter. The pane follows every later edit of that file/preview localhost:3000/pricing reads the page your dev server renders, the most accurate view. It refreshes after each of Claude's turns/preview https://example.com checks a live pageg Google, s shared link, r refreshtwitter:card), with the fallbacks each platform uses when a tag is missingnoindex, a missing title, a missing or relative or local og:image, an og:image that doesn't load (it asks the URL, or looks in public/ and static/ for a local file), a canonical pointing at localhost, titles and descriptions too long or too short, a missing twitter:card, og:image:alt or <html lang>{post.title}, generateMetadata) are marked as such: /preview <url> reads them renderedThe findings also go to Claude as context, so "fix these" just works. Pages and images are fetched from your machine; nothing is sent anywhere else.
Google publishes no snippet limits: the pixel widths are the ones SEO tools have measured for years. Treat them as a close guide, not a promise.
Claude Code resends the whole conversation with every request, and the prompt cache is what keeps that cheap and fast. This shows what the cache is doing, live.
◆ Cache █████████░ 96% hit · warm 58m · 133k cached · session 1h 12m (23m working) · avg 94% · 38 requests
◆ Cache ░░░░░░░░░░ 0% hit · cold, next message re-caches 133k · session 2h 05m (31m working)
claude plugin marketplace add krysmalskipl/claude-code-mods # once per machine
claude plugin install cache-stats@krysmalskipl
Restart Claude Code or type /reload-plugins. The line appears after Claude's first reply. It reads Claude Code's own usage figures and sends nothing anywhere.
The hit rate is measured, but the warm countdown needs to know how long your cache lives: 1h on a Claude subscription (the default) or 5m on an API key. On an API key, set it in /config → cache-stats, or from a shell:
echo '{"ttl":"5m"}' | claude plugin configure cache-stats@krysmalskipl --values-stdin
Not sure which you have? Look at your session transcript: ephemeral_1h_input_tokens in the usage rows means a 1h cache.
On a narrow terminal the right-hand parts drop out first.
/cache shows the details: state, session totals (read from cache, written, sent uncached, generated), every miss with the reason, and the last requests/cache hide and /cache show take the line away and bring it backA miss is a large prompt (5,000+ tokens) the cache mostly did not serve. The reason is told by the gap before it: idle longer than the cache lives means it simply expired; soon after the last request means something under the cache changed, such as /model, /compact, an edited CLAUDE.md or a new MCP tool. That second kind is the one worth hunting down, since each costs a full re-cache.
Subagents run on caches of their own and are not counted.

Your OpenRouter credit in one line above the prompt, refreshed every 5 minutes and after each turn.
claude --version
You need 2.1.287 or newer. Older? Update with claude update (or npm install -g @anthropic-ai/claude-code). In the desktop app, type /status in a Code tab session.
Once per machine, in a terminal:
claude plugin marketplace add krysmalskipl/claude-code-mods
Or inside a Claude Code session: /plugin marketplace add krysmalskipl/claude-code-mods.
claude plugin install openrouter-balance@krysmalskipl
Or inside a session: /plugin install openrouter-balance@krysmalskipl.
It ends with "1 userConfig option not yet set". That's the optional key from step 4. If your key is already in your environment or Keychain, ignore it.
Pick the kind of key first:
| Key | What the line shows | Where to make one |
|---|---|---|
| Management key (recommended) | The account's real balance, today's spend and the model the money mostly went to | openrouter.ai/settings/management-keys. It can't call models, so it's safe to keep around |
| Regular API key | Only that key's own spend today and this month, and what its limit leaves | openrouter.ai/settings/keys. The OPENROUTER_API_KEY you already use works |
Then put it in one place. The mod looks in this order and takes the first it finds:
/config → openrouter-balance in an interactive claude terminal session, or from a shell: echo '{"apiKey":"sk-or-v1-..."}' | claude plugin configure openrouter-balance@krysmalskipl --values-stdin
That line lands in your shell history; the Keychain (3) keeps the key out of it.
OPENROUTER_MANAGEMENT_KEY for a management key, OPENROUTER_API_KEY for a regular one. Add it to ~/.zshrc (or your shell's startup file): export OPENROUTER_MANAGEMENT_KEY="sk-or-v1-..."
security add-generic-password -a "$USER" -s openrouter-key -w
~/.zshrc), the mod asks your shell for OPENROUTER_MANAGEMENT_KEY and OPENROUTER_API_KEY. So a key exported in ~/.zshrc works there too.The key is kept in memory only and sent only to openrouter.ai.
Start a new Claude Code session (or type /reload-plugins in an open one). Within a few seconds a line appears above the prompt:
◆ OpenRouter █████████▁ $2.67 of $3.00 key limit left · $0.33 today
Type /openrouter-balance for the breakdown: balance, today, yesterday, last 30 days and spend, requests and tokens by model. With a regular key: today, this week, this month, all time, its limit and budget.
claude plugin marketplace update krysmalskipl
claude plugin update openrouter-balance@krysmalskipl
claude plugin uninstall openrouter-balance@krysmalskipl
| The line says | Why | Fix |
|---|---|---|
| Nothing at all | The mod isn't loaded yet | Start a new session, or /reload-plugins. Check claude plugin list shows it enabled |
no key set | No key in any of the four places | Step 4. In the desktop app, the Keychain is the surest place |
key rejected | The key was revoked or disabled | Make a new one, put it where the old one was |
can't reach OpenRouter yet | No network, or OpenRouter is down | It retries every minute by itself |
$0.42 today · $12.30 this month and no balance | A regular key with no limit: it can't see the account | Use a management key for the balance, or set a limit on the key |
… · offline | The last refresh failed | The numbers are the last good ones; it retries every minute |
/activity, which holds completed UTC days onlyclaude plugin validate mods/<name>
CLAUDE_CONFIG_DIR=$(mktemp -d) claude plugin test mods/<name>
More mods: hamzafer/claude-code-mods.
MIT
hooks/register.tsx 348 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 only itself: its spend today and this month, and what its limit leaves.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register, Timer } from 'claude-code'
6
7import type { Line } from '../types'
8
9const EVERY_MS = 5 * 60 * 1000 // the balance is live, but nobody needs it by the second
10const TICK_MS = 60 * 1000 // the timer checks every minute; nextTry decides whether to ask
11const RETRY_MS = 60 * 1000 // after a failed refresh
12const STUCK_MS = 2 * 60 * 1000 // a refresh still running after this is given up on (fetch has no timeout)
13const ACTIVITY_MS = 60 * 60 * 1000 // /activity holds completed days only, so once an hour is plenty
14const GRACE_S = 15 * 60 // a day first seen this soon after midnight UTC counts as seen from its start
15const KEYCHAIN_SERVICE = 'openrouter-key'
16const API = 'https://openrouter.ai/api/v1'
17const DAY_S = 86_400
18const LOW = 2 // dollars: red below this
19const MID = 5 // dollars: yellow below this
20const BRAND = '#8b9cff' // periwinkle: apart from the green gauge and openai-balance's violet
21const GAUGE = 10
22const EMPTY: Line = { kind: null, left: null, start: null, today: null, month: null, since: null, top: null, error: null, isLoaded: false, hasData: false }
23const SETUP =
24 '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`.'
25
26// The account's all-time usage when this UTC day was first seen, so today's spend is the difference.
27type Mark = { day: number; usage: number; at: number }
28type Activity = { date: string; model: string; usage: number; requests: number; prompt_tokens: number; completion_tokens: number }
29
30// Held by the host, so the line survives a hot reload of this file.
31const line = atom({ plugin: 'openrouter-balance', key: 'line' } as const, EMPTY)
32
33let timers: Timer[] = [] // restarted on each session.start
34let configKey = ''
35let key = '' // kept in memory only, never in state, the store or a message
36let nextTry = 0
37let generation = 0 // a newer refresh wins; an older one that ends later is dropped
38let running: { since: number } | null = null
39let activityCache: { at: number; items: Promise<Activity[]> } | null = null
40let lastKey = '' // the key the cached numbers came from, in memory only
41
42class NoKey extends Error {}
43class KeyRejected extends Error {}
44class HttpError extends Error {
45 constructor(readonly status: number) {
46 super(`OpenRouter answered ${status}`)
47 }
48}
49
50export const register: Register = (on, options) => {
51 configKey = typeof options.apiKey === 'string' ? options.apiKey.trim() : ''
52 key = '' // a key changed in /config is read again
53
54 on('session.start', async ($, e, next) => {
55 const result = await next(e)
56 await $.command
57 .register({ name: 'openrouter-balance', description: 'OpenRouter credit, spend today and spend by model' })
58 .catch(() => {}) // a name Claude Code already has is refused: start anyway
59 void refresh($, true).catch(() => {}) // in the background: a slow API never holds up the session
60 for (const t of timers) t.cancel()
61 timers = [$.clock.every(TICK_MS, () => void refresh($, false).catch(() => {}))]
62 return result
63 })
64
65 on('turn.complete', async ($, e, next) => {
66 const result = await next(e)
67 if (!e.agentId) void refresh($, false).catch(() => {}) // main-loop turns only, not subagents
68 return result
69 })
70
71 on('command.run', { command: 'openrouter-balance' }, async $ => {
72 try {
73 return { text: await detail($) }
74 } catch (err) {
75 return { text: problem(err) }
76 }
77 })
78
79 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
80 const rest = await next(e) // what other mods and Claude Code draw here stays
81 const now = { ...EMPTY, ...(await read($, line)) } // a value an older version saved may lack a field
82 if (e.props.hasSurvey || !now.isLoaded) return rest
83
84 const { Box, Text } = $.ui.resolve(e)
85 const isWide = e.props.bodyColumns >= 60
86 const sep = <Text dimColor>{' · '}</Text>
87 const head = <Text color={BRAND} bold>{'◆ OpenRouter '}</Text>
88 const today = now.today !== null && (
89 <Text>
90 <Text bold>{usd(now.today)}</Text>
91 <Text dimColor>{now.since !== null ? ` since ${clock(now.since)}` : ' today'}</Text>
92 </Text>
93 )
94
95 let body
96 if (now.error === 'no-key') body = <Text color="red">no key set, see /openrouter-balance</Text>
97 else if (now.error === 'rejected') body = <Text color="red">key rejected, see /openrouter-balance</Text>
98 else if (!now.hasData) body = <Text dimColor>{"can't reach OpenRouter yet"}</Text>
99 else if (now.left === null) {
100 // A regular key with no limit: its spend is all there is to show.
101 body = (
102 <Text>
103 {today}
104 {now.month !== null && sep}
105 {now.month !== null && <Text bold>{usd(now.month)}</Text>}
106 {now.month !== null && <Text dimColor>{' this month'}</Text>}
107 {isWide && sep}
108 {isWide && <Text dimColor>{'a management key shows the balance'}</Text>}
109 {now.error === 'offline' && <Text dimColor>{' · offline'}</Text>}
110 </Text>
111 )
112 } else {
113 const tone = now.left < LOW ? 'red' : now.left < MID ? 'yellow' : 'green'
114 const filled = now.start !== null && now.start > 0 ? Math.max(0, Math.min(GAUGE, Math.round((now.left / now.start) * GAUGE))) : 0
115 const of = now.start !== null ? ` of ${usd(now.start)}${now.kind === 'key' ? ' key limit' : ''}` : ''
116 body = (
117 <Text>
118 {isWide && <Text color={tone}>{'█'.repeat(filled)}</Text>}
119 {isWide && <Text dimColor>{'▁'.repeat(GAUGE - filled)}</Text>}
120 <Text color={tone} bold>{`${isWide ? ' ' : ''}${usd(now.left)}`}</Text>
121 <Text dimColor>{of}{now.left < LOW ? ' left, top up soon' : ' left'}</Text>
122 {today && sep}
123 {today}
124 {isWide && now.top && <Text dimColor>{', mostly '}</Text>}
125 {isWide && now.top && <Text color="cyan">{short(now.top)}</Text>}
126 {now.error === 'offline' && <Text dimColor>{' · offline'}</Text>}
127 </Text>
128 )
129 }
130
131 return (
132 <Box flexDirection="column">
133 <Box paddingX={1}>
134 <Text wrap="truncate-end">
135 {head}
136 {body}
137 </Text>
138 </Box>
139 {rest}
140 </Box>
141 )
142 })
143}
144
145// Updates what the band draws. Every 5 minutes, a minute after a failure, or now when forced.
146async function refresh($: EngineInterface, isForced: boolean) {
147 const now = await $.clock.now()
148 if (!isForced && (now < nextTry || (running && now - running.since < STUCK_MS))) return
149 const mine = ++generation
150 running = { since: now }
151 try {
152 const next = await snapshot($, now)
153 if (mine !== generation) return
154 nextTry = now + EVERY_MS
155 await update($, line, () => next)
156 } catch (err) {
157 if (mine !== generation) return
158 nextTry = now + RETRY_MS // a fixed key or a network back shows within a minute
159 const error: Line['error'] = err instanceof NoKey ? 'no-key' : err instanceof KeyRejected ? 'rejected' : 'offline'
160 await update($, line, l => ({ ...EMPTY, ...l, error, isLoaded: true })) // offline keeps the last numbers, marked
161 } finally {
162 if (mine === generation) running = null
163 }
164}
165
166async function snapshot($: EngineInterface, now: number): Promise<Line> {
167 const info = await keyInfo($)
168 const loaded = { ...EMPTY, isLoaded: true, hasData: true }
169 if (!isManagement(info)) {
170 return { ...loaded, kind: 'key', left: num(info.limit_remaining), start: num(info.limit), today: num(info.usage_daily), month: num(info.usage_monthly) }
171 }
172 const credits = await account($)
173 const mark = await dayMark($, Math.floor(now / 1000), credits.total_usage)
174 const items = await activity($, now).catch(() => []) // a hiccup here never hides the balance
175 return {
176 ...loaded,
177 kind: 'account',
178 left: credits.total_credits - credits.total_usage,
179 start: credits.total_credits,
180 today: Math.max(0, credits.total_usage - mark.usage),
181 since: mark.at - mark.day > GRACE_S ? mark.at : null,
182 top: byModel(items)[0]?.model ?? null,
183 }
184}
185
186// The first all-time usage seen this UTC day. A day first seen late says "since" instead of "today".
187async function dayMark($: EngineInterface, nowS: number, usage: number): Promise<Mark> {
188 const day = dayStart(nowS)
189 const saved = (await $.store.get('mark')) as Mark | undefined
190 if (saved && saved.day === day && typeof saved.usage === 'number') return saved
191 const mark = { day, usage, at: nowS }
192 await $.store.set('mark', mark)
193 return mark
194}
195
196async function detail($: EngineInterface) {
197 const now = await $.clock.now()
198 const info = await keyInfo($)
199 const lines = ['## OpenRouter balance', '']
200
201 if (!isManagement(info)) {
202 lines.push(
203 `A regular API key: it sees only its own spend. A management key shows the account balance.`,
204 '',
205 `- **Today (UTC):** ${usd(num(info.usage_daily) ?? 0)}`,
206 `- **This week:** ${usd(num(info.usage_weekly) ?? 0)}`,
207 `- **This month:** ${usd(num(info.usage_monthly) ?? 0)}`,
208 `- **All time:** ${usd(num(info.usage) ?? 0)}`,
209 )
210 if (num(info.limit) !== null) {
211 const reset = info.limit_reset ? `, resets ${info.limit_reset}` : ''
212 lines.push(`- **Key limit:** ${usd(num(info.limit_remaining) ?? 0)} left of ${usd(info.limit)}${reset}`)
213 } else lines.push('- **Key limit:** none')
214 const b = info.effective_budget
215 if (b && typeof b.limit_usd === 'number') lines.push(`- **Budget:** ${usd(b.remaining_usd ?? 0)} left of ${usd(b.limit_usd)}${b.reset_interval ? ` ${b.reset_interval}` : ''}`)
216 const free = info.free_model_daily_requests
217 if (info.is_free_tier && free) lines.push(`- **Free model requests today:** ${free.remaining} of ${free.limit} left`)
218 return lines.join('\n')
219 }
220
221 const credits = await account($)
222 const mark = await dayMark($, Math.floor(now / 1000), credits.total_usage)
223 const items = await activity($, now)
224 const yesterday = new Date((dayStart(Math.floor(now / 1000)) - DAY_S) * 1000).toISOString().slice(0, 10)
225 const today = Math.max(0, credits.total_usage - mark.usage)
226 lines.push(
227 `- **Balance:** ${usd(credits.total_credits - credits.total_usage)} (${usd(credits.total_credits)} bought, ${usd(credits.total_usage)} used)`,
228 `- **Today (UTC):** ${usd(today)}${mark.at - mark.day > GRACE_S ? ` since ${clock(mark.at)} UTC, when this mod first saw the day` : ''}`,
229 `- **Yesterday:** ${usd(sum(items.filter(i => i.date === yesterday)))}`,
230 `- **Last 30 days:** ${usd(sum(items))}`,
231 '',
232 '**By model, last 30 days**',
233 )
234 const models = byModel(items)
235 if (models.length === 0) lines.push('- none')
236 for (const m of models) lines.push(`- ${m.model}: ${usd4(m.usage)}, ${int(m.requests)} requests, ${int(m.input)} in / ${int(m.output)} out`)
237 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._')
238 return lines.join('\n')
239}
240
241// The last 30 days' spend per model, biggest first.
242function byModel(items: Activity[]) {
243 const map = new Map<string, { model: string; usage: number; requests: number; input: number; output: number }>()
244 for (const i of items) {
245 const m = map.get(i.model) ?? { model: i.model, usage: 0, requests: 0, input: 0, output: 0 }
246 m.usage += Number(i.usage) || 0
247 m.requests += Number(i.requests) || 0
248 m.input += Number(i.prompt_tokens) || 0
249 m.output += Number(i.completion_tokens) || 0
250 map.set(i.model, m)
251 }
252 return [...map.values()].filter(m => m.usage > 0).sort((a, b) => b.usage - a.usage)
253}
254
255const sum = (items: Activity[]) => items.reduce((s, i) => s + (Number(i.usage) || 0), 0)
256
257const keyInfo = async ($: EngineInterface) => (await call($, 'key')).data ?? {}
258const account = async ($: EngineInterface) => {
259 const d = (await call($, 'credits')).data ?? {}
260 const total_credits = num(d.total_credits)
261 const total_usage = num(d.total_usage)
262 if (total_credits === null || total_usage === null) throw new Error('OpenRouter sent no credit numbers')
263 return { total_credits, total_usage }
264}
265const isManagement = (info: any) => info.is_management_key === true || info.is_provisioning_key === true
266
267// One lookup shared by concurrent callers, kept an hour. A failed one is tried again next time.
268async function activity($: EngineInterface, now: number): Promise<Activity[]> {
269 if (!activityCache || now - activityCache.at > ACTIVITY_MS) {
270 const entry = {
271 at: now,
272 items: call($, 'activity').then(
273 d => (Array.isArray(d.data) ? d.data : []),
274 err => {
275 if (activityCache === entry) activityCache = null
276 throw err
277 },
278 ),
279 }
280 activityCache = entry
281 }
282 return activityCache.items
283}
284
285async function call($: EngineInterface, path: string) {
286 const res = await $.http.fetch(`${API}/${path}`, { headers: { authorization: `Bearer ${await apiKey($)}` } })
287 if (res.status === 401 || res.status === 403) {
288 key = '' // read it again next time, in case it was replaced
289 throw new KeyRejected()
290 }
291 if (!res.ok) throw new HttpError(res.status)
292 return JSON.parse(res.text)
293}
294
295// From /config first, then OPENROUTER_MANAGEMENT_KEY, OPENROUTER_API_KEY, then the macOS Keychain, and last the
296// same two variables as your login shell sets them: an app started from the Dock never read ~/.zshrc.
297async function apiKey($: EngineInterface) {
298 if (key) return key
299 const run = async (argv: string[], timeoutMs?: number) => {
300 const r = await $.process.run(argv, { timeoutMs }).catch(() => null)
301 return r && r.exitCode === 0 ? r.stdout.trim() : ''
302 }
303 const k =
304 configKey ||
305 (await run(['printenv', 'OPENROUTER_MANAGEMENT_KEY'])) ||
306 (await run(['printenv', 'OPENROUTER_API_KEY'])) ||
307 (await run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'])) ||
308 fromShell(await run([(await run(['printenv', 'SHELL'])) || '/bin/zsh', '-ilc', SHELL_PRINT], SHELL_MS))
309 if (!k.startsWith('sk-or-')) throw new NoKey()
310 if (lastKey && k !== lastKey) {
311 activityCache = null // another key may be another account: drop its numbers
312 await $.store.set('mark', null).catch(() => {}) // and its start-of-day mark
313 }
314 lastKey = k
315 return (key = k)
316}
317
318// Each variable on its own marked line, so whatever the shell's startup files print is skipped.
319const SHELL_PRINT = 'printf "\\n@or@%s\\n@or@%s\\n" "$OPENROUTER_MANAGEMENT_KEY" "$OPENROUTER_API_KEY"'
320const SHELL_MS = 10_000 // a slow ~/.zshrc must not hold the line up for long
321const fromShell = (out: string) =>
322 out
323 .split('\n')
324 .filter(l => l.startsWith('@or@sk-or-'))
325 .map(l => l.slice(4).trim())[0] ?? ''
326
327function problem(err: unknown) {
328 if (err instanceof NoKey) return `No OpenRouter key found. ${SETUP}`
329 if (err instanceof KeyRejected) return `OpenRouter refused the key (401/403): it may be revoked or disabled. ${SETUP}`
330 return `Couldn't reach OpenRouter: ${err instanceof Error ? err.message : String(err)}`
331}
332
333// A model id without its provider or variant: "anthropic/claude-sonnet-4.5" → "claude-sonnet-4.5",
334// "meta-llama/llama-3.3-70b-instruct:free" → "llama-3.3-70b-instruct".
335const short = (model: string) => model.split('/').pop()!.split(':')[0]
336const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
337const dayStart = (s: number) => s - (s % DAY_S)
338const usd = (n: number) => {
339 const cents = Math.round(n * 100)
340 return `${cents < 0 ? '-' : ''}$${(Math.abs(cents) / 100).toFixed(2)}`
341}
342const usd4 = (n: number) => `$${n.toFixed(4)}`
343const int = (n: number) => Math.round(n).toLocaleString('en-US')
344const clock = (s: number) => {
345 const d = new Date(s * 1000)
346 return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
347}
348types/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