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

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.
| Mod | Qué hace | Comandos |
|---|---|---|
faf-usage-meter | Muestra 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 |
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
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.
~/.claude/mods/<nombre>, sustituyendo la versión anterior. Si Claude Code ha generado tipos de desarrollo en .claude-plugin/types, los conserva.~/.claude/settings.json en ~/.claude/backups/.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.
Los dos scripts tienen las mismas opciones:
| bash | PowerShell | Qué hace |
|---|---|---|
mod1 mod2 | mod1 mod2 | instala solo esos mods |
--list | -List | muestra 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 DESTINO | dónde instalar (ver abajo) |
--distro NOMBRE | -Distro NOMBRE | distribución de WSL; por defecto, la predeterminada |
--help | Get-Help .\install\install.ps1 | ayuda |
Claude Code de Windows y Claude Code de WSL tienen configuraciones separadas. Con --target / -Target se elige dónde instalar:
| Destino | Desde WSL (install.sh) | Desde Windows (install.ps1) |
|---|---|---|
local (por defecto) | Claude de WSL | Claude de Windows |
windows | Claude de Windows | Claude de Windows |
wsl | Claude de WSL | Claude de WSL |
both | ambos | ambos |
./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.
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.faf-usage-meter la necesita para mostrar la cuota.mods/<nombre>. Debe contener .claude-plugin/plugin.json, con name igual al nombre de la carpeta.La carpeta .claude-plugin/types que genera Claude Code al desarrollar un mod está en .gitignore: no se sube y se regenera sola.
CLAUDE_CODE_PLUGIN_DIRS con ; en Windows y con : en Linux y macOS. Los instaladores eligen el separador correcto.install.ps1 reescribe settings.json con la sangría de PowerShell. El contenido no cambia.hooks/register.tsx 408 lines1import { 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}
408types/index.d.ts 39 lines1export 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