SLOPSHOPPER

faf-usage-meter

Shows context usage and quota consumption (5h, 7 days) against their limits

newpanespinnercommandnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · faf-usage-meter
│ ┃ Claude usage ✕ › fix the failing auth test and add an audit log call │ ┃ Context │ ┃ ███████████████████▋░░░░░░░░░░░░░░░░░░░░ 49% ⏺ Read(src/auth.ts) │ ┃ 97k of 200k tokens ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Quota ⎿ Added 2 lines, removed 1 line │ ┃ 5h ████████████▍░░░░░░░░░░░░░░░░░░░ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Monthly spend (account) │ ┃ No data: sign in with a claude.ai account. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Estimated cost of this session (API ✻ Worked for 42s · done 4:20 PM │ ┃ pricing): $0.42 │ › /faf-usage │ ⎿ faf-usage-meter: Usage pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Claude usage
Context ███████████████████▋░░░░░░░░░░░░░░░░░░░░ 49% 97k of 200k tokens Quota 5h ████████████▍░░░░░░░░░░░░░░░░░░░░░░░░░░░ 31% / 1 Monthly spend (account) No data: sign in with a claude.ai account. Estimated cost of this session (API pricing): $0.42
README

claude-mods

Mods propios de Claude Code y los instaladores para llevarlos a cualquier equipo.

Un mod es un plugin que cambia la interfaz o el comportamiento de Claude Code: paneles, barras bajo el prompt, comandos /…, hooks… Este repositorio los guarda en un sitio, con su historial, y permite instalarlos de una vez en Windows, Linux, macOS y WSL.

Mods incluidos

ModQué haceComandos
faf-usage-meterMuestra bajo el prompt la ocupación del contexto y la cuota usada (5 h y 7 días) con sus límites, y el gasto del mes./faf-usage abre el panel con el desglose · /faf-usage-band muestra u oculta la barra

Estructura

claude-mods/
├── install/
│   ├── install.sh     instalador para Linux, macOS, WSL y Git Bash
│   ├── install.ps1    instalador para PowerShell (Windows; también pwsh en Linux/macOS)
│   └── install.cmd    lanzador de doble clic para install.ps1
└── mods/
    └── <nombre>/      un mod por carpeta, con su .claude-plugin/plugin.json

Instalación

Clona el repositorio y ejecuta el instalador que corresponda. Sin argumentos instala y activa todos los mods.

Windows: doble clic en install\install.cmd, o desde PowerShell:

.\install\install.ps1

Linux, macOS o WSL:

./install/install.sh

Después cierra y vuelve a abrir Claude Code. Para actualizar, haz git pull y vuelve a ejecutar el instalador.

Qué hace el instalador

  1. Copia cada mod a ~/.claude/mods/<nombre>, sustituyendo la versión anterior. Si Claude Code ha generado tipos de desarrollo en .claude-plugin/types, los conserva.
  2. Guarda una copia de ~/.claude/settings.json en ~/.claude/backups/.
  3. Activa cada mod añadiéndolo a env.CLAUDE_CODE_PLUGIN_DIRS en settings.json. Respeta otras rutas que ya haya en esa variable y el resto de la configuración.

Si CLAUDE_CONFIG_DIR está definida, usa esa carpeta en lugar de ~/.claude.

Opciones

Los dos scripts tienen las mismas opciones:

bashPowerShellQué hace
mod1 mod2mod1 mod2instala solo esos mods
--list-Listmuestra los mods del repositorio y si están instalados y activos
--uninstall [mods]-Uninstall [mods]desactiva y borra esos mods (sin nombres, todos los del repositorio)
--target DESTINO-Target DESTINOdónde instalar (ver abajo)
--distro NOMBRE-Distro NOMBREdistribución de WSL; por defecto, la predeterminada
--helpGet-Help .\install\install.ps1ayuda

Windows con WSL: instalar en los dos de una vez

Claude Code de Windows y Claude Code de WSL tienen configuraciones separadas. Con --target / -Target se elige dónde instalar:

DestinoDesde WSL (install.sh)Desde Windows (install.ps1)
local (por defecto)Claude de WSLClaude de Windows
windowsClaude de WindowsClaude de Windows
wslClaude de WSLClaude de WSL
bothambosambos
./install/install.sh --target both          # desde WSL
.\install\install.ps1 -Target both          # desde Windows

Cada script le pasa al otro la parte del otro sistema: install.sh usa powershell.exe y install.ps1 usa wsl.exe. Si no se puede llegar al otro sistema, el script avisa y no instala nada.

Requisitos

  • install.sh: bash y python3 (para editar settings.json). En Git Bash no hace falta python3, porque el trabajo lo hace install.ps1.
  • install.ps1: Windows PowerShell 5.1 o PowerShell 7.
  • Algunos mods necesitan haber iniciado sesión en Claude Code con una cuenta de claude.ai. Por ejemplo, faf-usage-meter la necesita para mostrar la cuota.

Añadir un mod

  1. Copia la carpeta del mod a mods/<nombre>. Debe contener .claude-plugin/plugin.json, con name igual al nombre de la carpeta.
  2. Añádelo a la tabla de Mods incluidos.
  3. Ejecuta el instalador.

La carpeta .claude-plugin/types que genera Claude Code al desarrollar un mod está en .gitignore: no se sube y se regenera sola.

Notas

  • Claude Code separa las rutas de CLAUDE_CODE_PLUGIN_DIRS con ; en Windows y con : en Linux y macOS. Los instaladores eligen el separador correcto.
  • En Windows, install.ps1 reescribe settings.json con la sangría de PowerShell. El contenido no cambia.
Source 2 files
hooks/register.tsx 408 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Breakdown, Limit, Monthly, Snapshot } from '../types'
5
6const PANE = 'faf-usage-meter'
7const TITLE = 'Claude usage'
8
9const snap = atom({ plugin: 'faf-usage-meter', key: 'snap' } as const, null)
10const breakdown = atom({ plugin: 'faf-usage-meter', key: 'breakdown' } as const, null)
11const isBandHidden = atom({ plugin: 'faf-usage-meter', key: 'isBandHidden' } as const, false)
12const monthly = atom({ plugin: 'faf-usage-meter', key: 'monthly' } as const, null)
13
14// Endpoint que usa /usage: gasto de extra usage de la cuenta, común a todos los equipos.
15const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
16const MONTHLY_TTL_MS = 5 * 60_000
17
18const LIMIT_NAMES: Record<string, string> = {
19  five_hour: '5h',
20  seven_day: '7 days',
21  seven_day_opus: '7 days Opus',
22  seven_day_sonnet: '7 days Sonnet',
23  spend_limit: 'Spend',
24}
25
26const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
27
28// Barra de `width` celdas con resolución de 1/8 de celda.
29export function bar(percent: number, width: number): { full: string; empty: string } {
30  const p = Math.max(0, Math.min(100, percent))
31  const eighths = Math.round((p / 100) * width * 8)
32  const whole = Math.floor(eighths / 8)
33  const part = EIGHTHS[eighths % 8]
34  const used = '█'.repeat(whole) + part
35  const rest = Math.max(0, width - whole - (part ? 1 : 0))
36
37  return { full: used, empty: '░'.repeat(rest) }
38}
39
40export function levelColor(percent: number): 'success' | 'warning' | 'error' {
41  if (percent >= 70) return 'error'
42  if (percent >= 50) return 'warning'
43  return 'success'
44}
45
46export function tokens(n: number | undefined): string {
47  if (n === undefined) return '—'
48  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
49  if (n >= 1000) return `${Math.round(n / 1000)}k`
50  return String(n)
51}
52
53export function limitName(kind: string): string {
54  return LIMIT_NAMES[kind] ?? kind.replace(/_/g, ' ')
55}
56
57// "2h 13m", "3d 4h", "12m"
58export function untilReset(resetsAt: string | undefined, now: number): string | null {
59  if (!resetsAt) return null
60  const ms = Date.parse(resetsAt) - now
61  if (!Number.isFinite(ms)) return null
62  if (ms <= 0) return 'now'
63  const min = Math.round(ms / 60_000)
64  const d = Math.floor(min / 1440)
65  const h = Math.floor((min % 1440) / 60)
66  const m = min % 60
67  if (d > 0) return `${d}d ${h}h`
68  if (h > 0) return `${h}h ${m}m`
69  return `${m}m`
70}
71
72function resetClock(resetsAt: string | undefined): string | null {
73  if (!resetsAt) return null
74  const date = new Date(resetsAt)
75  if (Number.isNaN(date.getTime())) return null
76  const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
77  const hh = String(date.getHours()).padStart(2, '0')
78  const mm = String(date.getMinutes()).padStart(2, '0')
79
80  return `${days[date.getDay()]} ${hh}:${mm}`
81}
82
83const CURRENCY_SYMBOLS: Record<string, string> = { EUR: '€', USD: '$', GBP: '£' }
84
85// "12,34 €"
86export function money(amount: number, currency: string): string {
87  const sign = CURRENCY_SYMBOLS[currency] ?? currency
88
89  return `${amount.toFixed(2).replace('.', ',')} ${sign}`
90}
91
92type Money = { amount_minor: number; currency: string; exponent: number }
93
94type UsageReply = {
95  spend?: { used?: Money; limit?: Money; enabled?: boolean } | null
96  extra_usage?: {
97    is_enabled?: boolean
98    monthly_limit?: number | null
99    used_credits?: number | null
100    currency?: string
101  } | null
102}
103
104const fromMinor = (m: Money): number => m.amount_minor / 10 ** m.exponent
105
106export function toMonthly(reply: UsageReply, now: number): Monthly | null {
107  const spend = reply.spend
108  if (spend?.used) {
109    return {
110      used: fromMinor(spend.used),
111      limit: spend.limit ? fromMinor(spend.limit) : undefined,
112      currency: spend.used.currency,
113      enabled: spend.enabled ?? true,
114      fetchedAt: now,
115    }
116  }
117  const extra = reply.extra_usage
118  if (extra && typeof extra.used_credits === 'number') {
119    return {
120      used: extra.used_credits,
121      limit: extra.monthly_limit ?? undefined,
122      currency: extra.currency ?? 'USD',
123      enabled: extra.is_enabled ?? false,
124      fetchedAt: now,
125    }
126  }
127
128  return null
129}
130
131async function refreshMonthly($: EngineInterface, force = false): Promise<void> {
132  try {
133    const now = await $.clock.now()
134    const current = await read($, monthly)
135    if (!force && current && now - current.fetchedAt < MONTHLY_TTL_MS) return
136    const auth = await $.session.authorize()
137    if (!auth) return
138    const res = await $.http.fetch(USAGE_URL, {
139      auth: auth.handle,
140      headers: { 'anthropic-beta': 'oauth-2025-04-20' },
141    })
142    if (!res.ok) return
143    const value = toMonthly(JSON.parse(res.text) as UsageReply, now)
144    if (value) await update($, monthly, () => value)
145  } catch {
146    // Sin red o sin login de claude.ai: se queda el último dato.
147  }
148}
149
150type Measured = {
151  context: { tokens?: number; window: number; percent?: number }
152  rateLimits: Limit[]
153  cost?: { usd: number }
154}
155
156function toSnapshot(m: Measured): Snapshot {
157  return {
158    tokens: m.context.tokens,
159    window: m.context.window,
160    percent: m.context.percent,
161    limits: m.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
162    usd: m.cost?.usd,
163  }
164}
165
166async function refreshBreakdown($: EngineInterface): Promise<void> {
167  try {
168    const usage = await $.session.usage({ breakdown: 'summary' })
169    const b = usage.context.breakdown
170    if (!b) return
171    const value: Breakdown = {
172      categories: b.categories
173        .filter(c => !c.isDeferred)
174        .map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind })),
175      totalTokens: b.totalTokens,
176      maxTokens: b.rawMaxTokens,
177      autoCompactAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
178    }
179    await update($, breakdown, () => value)
180  } catch {
181    // Sin sesión enlazada todavía: se reintenta en la siguiente medición.
182  }
183}
184
185export const register: Register = on => {
186  let isPaneOpen = false
187
188  on('session.start', async ($, e, next) => {
189    await $.command.register({
190      name: 'faf-usage',
191      description: 'Open the pane with the context breakdown and quotas',
192    })
193    await $.command.register({
194      name: 'faf-usage-band',
195      description: 'Show or hide the context and quota bar below the prompt',
196    })
197    const ran = await next(e)
198    await update($, snap, () => null)
199    try {
200      const usage = await $.session.usage()
201      await update($, snap, () => toSnapshot(usage))
202    } catch {
203      // Se rellenará con la primera medición.
204    }
205    await refreshMonthly($, true)
206
207    return ran
208  })
209
210  on('session.measure', async ($, e, next) => {
211    await update($, snap, () => toSnapshot(e))
212    if (isPaneOpen && e.changed.includes('context')) {
213      await refreshBreakdown($)
214    }
215    if (e.changed.includes('rateLimits') || e.changed.includes('cost')) {
216      await refreshMonthly($)
217    }
218
219    return next(e)
220  })
221
222  on('command.run', { command: 'faf-usage' }, async $ => {
223    await refreshBreakdown($)
224    await refreshMonthly($, true)
225    const { isPlaced } = await $.ui.open({ id: PANE, title: TITLE })
226    isPaneOpen = true
227
228    return { text: isPlaced ? 'Usage pane opened.' : 'Usage pane ready; it will appear when there is room.' }
229  })
230
231  on('command.run', { command: 'faf-usage-band' }, async $ => {
232    const hidden = await update($, isBandHidden, v => !v)
233
234    return { text: hidden ? 'Usage bar hidden.' : 'Usage bar shown.' }
235  })
236
237  // Bajo la caja de entrada: la línea de ayuda del motor tal cual y, debajo, las barras.
238  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
239    const hint = await next(e)
240    const s = await read($, snap)
241    const mo = await read($, monthly)
242    if (s === null || (await read($, isBandHidden))) {
243      return hint
244    }
245
246    const { Box, Text } = $.ui.resolve(e)
247    const now = await $.clock.now()
248    const columns = e.viewport?.columns ?? 100
249    const width = columns >= 140 ? 16 : columns >= 100 ? 10 : 6
250    const pct = s.percent ?? 0
251    const ctx = bar(pct, width)
252
253    return (
254      <Box flexDirection="column" rowGap={1}>
255        {hint}
256        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
257          <Box flexDirection="row">
258            <Text dimColor>ctx </Text>
259            <Text color={levelColor(pct)}>{ctx.full}</Text>
260            <Text color="inactive">{ctx.empty}</Text>
261            <Text>
262              {' '}
263              {s.percent ?? '—'}% {tokens(s.tokens)}/{tokens(s.window)}
264            </Text>
265          </Box>
266          {s.limits.length === 0 && <Text dimColor>quota: no data yet</Text>}
267          {s.limits.map(l => {
268            const b = bar(l.percentUsed, width)
269            const left = untilReset(l.resetsAt, now)
270
271            return (
272              <Box flexDirection="row" key={l.kind}>
273                <Text dimColor>{limitName(l.kind)} </Text>
274                <Text color={levelColor(l.percentUsed)}>{b.full}</Text>
275                <Text color="inactive">{b.empty}</Text>
276                <Text> {Math.round(l.percentUsed)}%</Text>
277                {left && <Text dimColor> ↻ {left}</Text>}
278              </Box>
279            )
280          })}
281          {s.usd !== undefined && <Text dimColor>${s.usd.toFixed(2)}</Text>}
282          {mo && (
283            <Text dimColor>
284              month {money(mo.used, mo.currency)}
285              {mo.limit ? `/${money(mo.limit, mo.currency)}` : ''}
286            </Text>
287          )}
288        </Box>
289      </Box>
290    )
291  })
292
293  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
294    const { Box, Text } = $.ui.resolve(e)
295    const s = await read($, snap)
296    const bd = await read($, breakdown)
297    const mo = await read($, monthly)
298    const now = await $.clock.now()
299    const columns = e.viewport?.columns ?? 60
300    const width = Math.max(10, Math.min(40, columns - 30))
301
302    if (s === null) {
303      return <Text dimColor>No measurements yet: they appear after the first response.</Text>
304    }
305
306    const pct = s.percent ?? 0
307    const ctx = bar(pct, width)
308    const stackMax = bd?.maxTokens ?? s.window
309    const used = bd?.categories.filter(c => c.kind === 'used' && c.tokens > 0) ?? []
310
311    return (
312      <Box flexDirection="column" rowGap={1}>
313        <Box flexDirection="column">
314          <Text weight="bold">Context</Text>
315          <Box flexDirection="row">
316            <Text color={levelColor(pct)}>{ctx.full}</Text>
317            <Text color="inactive">{ctx.empty}</Text>
318            <Text> {s.percent ?? '—'}%</Text>
319          </Box>
320          <Text dimColor>
321            {tokens(s.tokens)} of {tokens(s.window)} tokens
322            {bd?.autoCompactAt ? ` · auto-compacts at ${tokens(bd.autoCompactAt)}` : ''}
323          </Text>
324        </Box>
325
326        {used.length > 0 && (
327          <Box flexDirection="column">
328            <Text weight="bold">Breakdown (estimated)</Text>
329            <Box flexDirection="row">
330              {used.map(c => (
331                <Text key={c.name} color={c.color}>
332                  {'█'.repeat(Math.max(1, Math.round((c.tokens / stackMax) * width)))}
333                </Text>
334              ))}
335            </Box>
336            {used.map(c => (
337              <Box flexDirection="row" key={`row-${c.name}`}>
338                <Text color={c.color}>■ </Text>
339                <Text>{c.name}</Text>
340                <Text dimColor>
341                  {' '}
342                  {tokens(c.tokens)} ({((c.tokens / stackMax) * 100).toFixed(1)}%)
343                </Text>
344              </Box>
345            ))}
346          </Box>
347        )}
348
349        <Box flexDirection="column">
350          <Text weight="bold">Quota</Text>
351          {s.limits.length === 0 && (
352            <Text dimColor>No data (arrives with the first response; subscriptions only).</Text>
353          )}
354          {s.limits.map(l => {
355            const b = bar(l.percentUsed, width)
356            const left = untilReset(l.resetsAt, now)
357            const at = resetClock(l.resetsAt)
358
359            return (
360              <Box flexDirection="column" key={`lim-${l.kind}`}>
361                <Box flexDirection="row">
362                  <Text>{limitName(l.kind).padEnd(12)}</Text>
363                  <Text color={levelColor(l.percentUsed)}>{b.full}</Text>
364                  <Text color="inactive">{b.empty}</Text>
365                  <Text> {l.percentUsed}% / 100%</Text>
366                </Box>
367                {left && (
368                  <Text dimColor>
369                    {'            '}resets in {left}
370                    {at ? ` (${at})` : ''}
371                  </Text>
372                )}
373              </Box>
374            )
375          })}
376        </Box>
377
378        <Box flexDirection="column">
379          <Text weight="bold">Monthly spend (account)</Text>
380          {mo === null && <Text dimColor>No data: sign in with a claude.ai account.</Text>}
381          {mo && !mo.enabled && <Text dimColor>Extra usage disabled: no spend beyond the subscription.</Text>}
382          {mo && mo.enabled && (
383            <Box flexDirection="column">
384              {mo.limit ? (
385                <Box flexDirection="row">
386                  <Text color={levelColor((mo.used / mo.limit) * 100)}>{bar((mo.used / mo.limit) * 100, width).full}</Text>
387                  <Text color="inactive">{bar((mo.used / mo.limit) * 100, width).empty}</Text>
388                  <Text>
389                    {' '}
390                    {money(mo.used, mo.currency)} of {money(mo.limit, mo.currency)}
391                  </Text>
392                </Box>
393              ) : (
394                <Text>
395                  {money(mo.used, mo.currency)} <Text dimColor>(no limit set)</Text>
396                </Text>
397              )}
398              <Text dimColor>Extra usage across all your teams; the fixed subscription quota is separate.</Text>
399            </Box>
400          )}
401        </Box>
402
403        {s.usd !== undefined && <Text dimColor>Estimated cost of this session (API pricing): ${s.usd.toFixed(2)}</Text>}
404      </Box>
405    )
406  })
407}
408
types/index.d.ts 39 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Snapshot = {
4  tokens?: number
5  window: number
6  percent?: number
7  limits: Limit[]
8  usd?: number
9}
10
11export type Category = { name: string; tokens: number; color: string; kind: string }
12
13export type Breakdown = {
14  categories: Category[]
15  totalTokens: number
16  maxTokens: number
17  autoCompactAt?: number
18}
19
20// Gasto de la cuenta en el mes (extra usage), en unidades de la moneda.
21export type Monthly = {
22  used: number
23  limit?: number
24  currency: string
25  enabled: boolean
26  fetchedAt: number
27}
28
29declare module 'claude-code' {
30  interface PluginState {
31    'faf-usage-meter': {
32      snap: Snapshot | null
33      breakdown: Breakdown | null
34      isBandHidden: boolean
35      monthly: Monthly | null
36    }
37  }
38}
39