SLOPSHOPPER

credit-meter

ElevenLabs characters and Higgsfield credits at a glance, with a heads-up before a tool call spends them

newpaneguardcommandtoasttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · credit-meter
│ ┃ ◆ Credits ✕ › fix the failing auth test and add an audit log call │ ┃ ElevenLabs not connected · Higgsfield not │ ┃ connected ⏺ 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 │ │ › /credits │ ⎿ credit-meter: Credits: ElevenLabs not connected · Higgsfield not │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ◆ Credits
ElevenLabs not connected · Higgsfield not connected
README

credit-meter

/credits opens a pane with two balances: ElevenLabs characters left (with a ring and the reset date) and Higgsfield credits left. Before Claude runs a tool that spends either one, a toast shows what you have; after it runs, a second toast shows what the call cost.

Calls it watches: ElevenLabs text-to-speech, sound effects, music and voice cloning (through the ElevenLabs MCP server or a curl to the API), and Higgsfield image, video, audio, 3D, upscale, preset and motion tools.

Settings

SettingDefaultWhat it is
elevenlabs_api_keyemptyOptional, stored as a secret. If empty, the mod uses $ELEVENLABS_API_KEY, then the key your elevenlabs MCP server has in ~/.claude.json.
higgsfield_serverclaude.ai HiggsfieldThe Higgsfield MCP server name as /mcp shows it. higgsfield is tried too.

A secret setting is not a row in the /plugin menu. Set it with claude plugin configure credit-meter@buildalone-mods --values-stdin, or just export ELEVENLABS_API_KEY.

If a provider isn't set up, its half of the card says "not connected" and the rest works.

What it touches

Reads ELEVENLABS_API_KEY and HOME from the environment and ~/.claude.json (only for the ElevenLabs key). Calls api.elevenlabs.io/v1/user/subscription and the Higgsfield balance tool. Run claude plugin validate on this folder to see the full list.

Source 1 files
hooks/register.tsx 138 lines
1// credit-meter: /credits opens a card with ElevenLabs characters left and Higgsfield credits.
2// Before a tool call that spends credits (voice generation, image/video generation) it toasts
3// what you have left, and after it toasts what the call cost.
4import type { Register } from 'claude-code'
5
6type Balances = { el?: { left: number; limit: number; resetsAt?: number }; hf?: number; at: number }
7const PANE = 'credits'
8let last: Balances = { at: 0 }
9let configuredKey = ''
10let servers: string[] = []
11
12// Key order: the plugin setting, then $ELEVENLABS_API_KEY, then the ElevenLabs MCP server's env in ~/.claude.json.
13async function elevenLabsKey($: any, configured: string): Promise<string | undefined> {
14  if (configured) return configured
15  const fromEnv = await $.env.get('ELEVENLABS_API_KEY')
16  if (fromEnv) return fromEnv
17  try {
18    const home = await $.env.get('HOME')
19    if (!home) return undefined
20    const cfg = JSON.parse(await $.fs.read(`${home}/.claude.json`))
21    return cfg?.mcpServers?.elevenlabs?.env?.ELEVENLABS_API_KEY || undefined
22  } catch { return undefined }
23}
24async function elevenLabs($: any, configured: string): Promise<Balances['el']> {
25  try {
26    const key = await elevenLabsKey($, configured)
27    if (!key) return undefined
28    const r = await $.http.fetch('https://api.elevenlabs.io/v1/user/subscription', { headers: { 'xi-api-key': key } })
29    if (!r.ok) return undefined
30    const j = JSON.parse(r.text)
31    return { left: j.character_limit - j.character_count, limit: j.character_limit, resetsAt: j.next_character_count_reset_unix }
32  } catch { return undefined }
33}
34async function higgsfield($: any, servers: string[]): Promise<number | undefined> {
35  for (const server of servers) {
36    try {
37      const res = await $.mcp.call(server, 'balance', {})
38      const txt = (res.content ?? []).map((c: any) => c.text ?? '').join(' ')
39      const m = txt.match(/"?credits"?\s*[:=]\s*([\d.]+)/i) ?? txt.match(/([\d][\d,.]*)\s*credits/i)
40      if (m) return parseFloat(m[1].replace(/,/g, ''))
41    } catch { /* try next name */ }
42  }
43  return undefined
44}
45async function refresh($: any) {
46  const [el, hf] = await Promise.all([elevenLabs($, configuredKey), higgsfield($, servers)])
47  last = { el, hf, at: await $.clock.now() }
48  $.ui.invalidate('ui.render')
49  return last
50}
51// Connectors from claude.ai carry an id rather than a name in their tool names, so Higgsfield's
52// spending tools are matched by tool name on any MCP server.
53const SPENDS = /(api\.elevenlabs\.io\/v1\/(text-to-speech|sound-generation))|(^mcp__.*__(generate_(image|video|audio|3d)(_batch)?|upscale_(image|video)|execute_preset|motion_control)$)|(^mcp__elevenlabs__(text_to_speech|text_to_sound|compose_music|voice_clone))/i
54
55const STYLE = `<style>
56  svg{--bg:#ffffff;--fg:#141413;--mu:#73726c;--ln:#e8e6dc;--tr:#f0eee6;--ac:#d97757;--ok:#788c5d;--wa:#c98a3a;--bad:#bf4d43}
57  @media (prefers-color-scheme: dark){svg{--bg:#262624;--fg:#faf9f5;--mu:#a3a199;--ln:#3d3d3a;--tr:#30302e}}
58  .card{fill:var(--bg);stroke:var(--ln);stroke-width:1}
59  .fg{fill:var(--fg)} .mu{fill:var(--mu)} .tr{fill:var(--tr)} .trs{stroke:var(--tr)} .ln{stroke:var(--ln)}
60  .ac{fill:var(--ac)} .acs{stroke:var(--ac)} .ok{fill:var(--ok)} .oks{stroke:var(--ok)} .wa{fill:var(--wa)} .was{stroke:var(--wa)} .bad{fill:var(--bad)} .bads{stroke:var(--bad)}
61  .t{font-family:ui-serif,"Tiempos Text",Georgia,serif}
62  text{font-family:-apple-system,BlinkMacSystemFont,"Inter",sans-serif}
63</style>`
64function card(b: Balances) {
65  const el = b.el, pct = el ? Math.round((el.left / el.limit) * 100) : 0
66  const cls = pct < 15 ? 'bad' : pct < 35 ? 'wa' : 'ok'
67  const c = 2 * Math.PI * 54, dash = (c * pct) / 100
68  const reset = el?.resetsAt ? new Date(el.resetsAt * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : ''
69  const hfCls = b.hf === undefined ? 'mu' : b.hf < 60 ? 'wa' : 'fg'
70  return `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="250" viewBox="0 0 460 250">${STYLE}
71  <rect x="0.5" y="0.5" width="459" height="249" rx="14" class="card"/>
72  <text x="24" y="34" font-size="13" class="mu">◆ Credits</text>
73  <circle cx="110" cy="122" r="54" fill="none" class="trs" stroke-width="10"/>
74  <circle cx="110" cy="122" r="54" fill="none" class="${cls}s" stroke-width="10" stroke-linecap="round" stroke-dasharray="${dash} ${c}" transform="rotate(-90 110 122)">
75    <animate attributeName="stroke-dasharray" from="0 ${c}" to="${dash} ${c}" dur="1s" fill="freeze"/></circle>
76  <text x="110" y="122" text-anchor="middle" font-size="22" font-weight="600" class="fg">${el ? (el.left / 1000).toFixed(1) + 'k' : '—'}</text>
77  <text x="110" y="141" text-anchor="middle" font-size="11" class="mu">characters left</text>
78  <text x="110" y="208" text-anchor="middle" font-size="15" class="fg t">ElevenLabs</text>
79  <text x="110" y="228" text-anchor="middle" font-size="12" class="mu">${el ? `${pct}% left · resets ${reset}` : 'not connected'}</text>
80  <line x1="222" y1="64" x2="222" y2="226" class="ln"/>
81  <text x="340" y="112" text-anchor="middle" font-size="40" font-weight="600" class="${hfCls}">${b.hf !== undefined ? b.hf.toFixed(0) : '—'}</text>
82  <text x="340" y="134" text-anchor="middle" font-size="11" class="mu">credits left</text>
83  <text x="340" y="208" text-anchor="middle" font-size="15" class="fg t">Higgsfield</text>
84  <text x="340" y="228" text-anchor="middle" font-size="12" class="mu">${b.hf === undefined ? 'not connected' : b.hf < 60 ? 'running low' : 'plenty'}</text>
85</svg>`
86}
87
88export const register: Register = (on, options) => {
89  configuredKey = String(options.elevenlabs_api_key ?? '').trim()
90  servers = [...new Set([String(options.higgsfield_server ?? '').trim(), 'higgsfield'].filter(Boolean))]
91
92  on('session.start', async ($, e, next) => {
93    await $.tool.register({ name: 'show', description: 'Open the ◆ Credits pane in Claude Code for the person' })
94    await $.command.register({ name: 'credits', description: 'Show ElevenLabs characters and Higgsfield credits left' })
95    return next(e)
96  })
97
98  on('command.run', { command: 'credits' }, async $ => {
99    await $.ui.open({ id: PANE, title: '◆ Credits' })
100    const b = await refresh($)
101    return { text: `Credits: ElevenLabs ${b.el ? b.el.left.toLocaleString() + ' chars' : 'not connected'} · Higgsfield ${b.hf ?? 'not connected'}` }
102  })
103
104  on('tool.call', async ($, e, next) => {
105    const probe = e.tool === 'Bash' ? String((e as any).command ?? '') : e.tool
106    if (!SPENDS.test(probe)) return next(e)
107    const before = await refresh($)
108    $.ui.toast(`◆ This spends credits. Left now: ElevenLabs ${before.el ? before.el.left.toLocaleString() : '?'} chars · Higgsfield ${before.hf ?? '?'}`)
109    const ran = await next(e)
110    const after = await refresh($)
111    const elSpent = before.el && after.el ? before.el.left - after.el.left : 0
112    const hfSpent = before.hf !== undefined && after.hf !== undefined ? before.hf - after.hf : 0
113    if (elSpent > 0 || hfSpent > 0) $.ui.toast(`◆ Spent ${elSpent > 0 ? elSpent.toLocaleString() + ' ElevenLabs chars ' : ''}${hfSpent > 0 ? hfSpent + ' Higgsfield credits' : ''}`)
114    return ran
115  })
116
117  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
118    if (!last.at) await refresh($)
119    if (e.surface === 'terminal') {
120      const { Text } = $.ui.resolve(e)
121      return <Text>ElevenLabs {last.el ? last.el.left + ' chars' : 'not connected'} · Higgsfield {last.hf ?? 'not connected'}</Text>
122    }
123    const { Box, Svg, Button } = $.ui.resolve(e)
124    return (
125      <Box flexDirection="column">
126        <Svg source={card(last)} alt="ElevenLabs and Higgsfield credit balances" width={460} isInteractive />
127        <Button key="refresh" label="Refresh" onPress={() => { void refresh($) }} />
128      </Box>
129    )
130  })
131
132  on('tool.call', { tool: 'mcp__credit-meter__show' }, async $ => {
133    try { await $.ui.close({ id: PANE }) } catch {}
134    await $.ui.open({ id: PANE, title: '◆ Credits' })
135    return { result: '◆ Credits pane opened.' }
136  })
137}
138