Shows the weekly limit reading and, once configured, POSTs it hourly to your HTTPS endpoint. Off by default.

Keeps the weekly subscription limit reading (seven_day window: percent used and reset time) that Claude Code already has after each main-thread turn, shows it in /usage-reporter, and, only when you set it up, POSTs each new reading once to the account's usage endpoint.
Off by default. With enabled false, or with postUrl, accountId or postToken missing, nothing is sent; /usage-reporter says which.
준비물: Claude Code 2.1.287 이상(최신 버전 권장), Python 3, git. 플러그인 ID는 usage-reporter@claude-code-mods입니다.
GitHub와 복제한 Git 저장소는 같은 마켓플레이스 이름 claude-code-mods를 씁니다. 한 곳만 추가하세요. 다른 곳으로 바꾸려면 claude plugin marketplace remove claude-code-mods로 먼저 지웁니다.
GitHub에서 설치:
git clone https://github.com/cskwork/claude-code-mods.git
cd claude-code-mods
python3 scripts/setup-usage-reporter.py
다른 Git 서버에서 설치 (git.example.com/your-team은 실제 저장소 주소로 바꾸세요):
git clone https://git.example.com/your-team/claude-code-mods.git
cd claude-code-mods
python3 scripts/setup-usage-reporter.py --marketplace https://git.example.com/your-team/claude-code-mods.git
Windows에서는 python3 대신 py -3를 사용하세요. macOS와 Claude Code 2.1.300에서 검증했습니다.
스크립트가 하는 일:
https://여야 하고, user:password@를 넣을 수 없으며, 같은 UUID를 포함해야 합니다.claude plugin install usage-reporter --marketplace <저장소> --scope user로 설치합니다. 마켓플레이스가 없으면 Claude Code가 먼저 추가합니다.claude plugin configure usage-reporter@claude-code-mods --values-stdin의 stdin으로만 넘깁니다. postUrl과 postToken은 Claude Code 보안 저장소에 저장됩니다.각 설치자가 본인의 계정 UUID와 수신 키를 넣습니다. 이 저장소에는 운영 주소, 실제 UUID, 키가 없습니다. 수신 URL은 직접 입력해야 합니다. 키를 명령줄 인자, 셸 기록, 채팅에 붙여 넣지 마세요.
보고는 마지막 질문에 y라고 답할 때만 켜집니다. 기본값은 꺼짐입니다. 나중에 켜려면 --skip-install을 붙여 스크립트를 다시 실행합니다. 설정 후 Claude Code에서 /reload-plugins를 실행하고 /usage-reporter로 상태를 확인하세요.
Claude Code 안에서 직접 설치해도 됩니다. 이때 설정은 위 스크립트를 --skip-install로 실행해서 넣습니다.
/plugin marketplace add cskwork/claude-code-mods
/plugin install usage-reporter@claude-code-mods
/reload-plugins
업데이트는 이 플러그인만 대상으로 합니다. 적용하려면 Claude Code를 다시 시작하세요.
claude plugin marketplace update claude-code-mods
claude plugin update usage-reporter@claude-code-mods
스크립트를 쓸 수 없을 때는 편집기로 저장소 밖(예: ~/usage-reporter.json)에 JSON 파일을 만들고 권한을 600으로 제한한 뒤 stdin으로 넘깁니다. 아래 값은 예시이며 모든 값은 한 줄 문자열입니다. 적용 후 파일은 지우세요.
{"enabled": "false", "postUrl": "https://receiver.example/api/accounts/00000000-0000-0000-0000-000000000000/usage", "accountId": "00000000-0000-0000-0000-000000000000", "postToken": "FAKE-RECEIVER-KEY"}
chmod 600 ~/usage-reporter.json
claude plugin configure usage-reporter@claude-code-mods --values-stdin < ~/usage-reporter.json
보내는 내용과 한계(아래 영어 절에 자세히 있음):
weekly_usage_percent, weekly_resets_at 두 필드뿐입니다.| Option | Default | Meaning |
|---|---|---|
enabled | false | Send reports. |
postUrl | "" | Sensitive, kept in Claude secure storage. The account's usage URL. Must be https:; user:password@ is rejected. |
accountId | "" | The account UUID. Used only locally, to label the receiver in /usage-reporter; it must match the UUID in postUrl. Not sent in the body. |
postToken | "" | Required, sensitive, kept in Claude secure storage. The per-account receiver key, sent only as Authorization: Bearer <key>. Never a Claude credential. |
/usage-reporter prints the reading, its age, why a report is waiting, whether reporting is off, misconfigured or on (the receiver's origin only, never its path or the token), and the last POST and last success.
POST https://receiver.example/api/accounts/{account UUID}/usage with content-type: application/json and Authorization: Bearer <receiver key>. The body has exactly two fields:
{ "weekly_usage_percent": 42.5, "weekly_resets_at": "2026-10-20T08:00:00.000Z" }
weekly_usage_percent: the seven_day window's percentUsed, 0 to 100, as Claude Code reports it.weekly_resets_at: the window's reset time, ISO 8601. A reading without one is not sent. Never guessed./usage-reporter; there are no retries between hourly ticks.The plugin stamps each reading with a local observedAt: when it read Claude Code's cached usage after a main-thread turn. It is not a timestamp from Anthropic, and the server has no confirmed input field for it yet, so it is not sent. Until that contract is confirmed, the plugin cannot guarantee that the receiver preserves the original observation time, and does not replay:
seven_day window, or a percentage outside 0 to 100, is "unknown", never 0. It clears the prior reading; reporting resumes after the next valid one.$.session.usage() returns what the last API response reported; no background query, cron, launchd or daemon exists. No turn, no new reading, no report.claude plugin validate usage-reporter && claude plugin test usage-reporter
python3 usage-reporter/scripts/test-enabled.py
Tests mock the clock, usage, config and HTTP; they make no network calls. The script runs the same hooks in an isolated plugin copy with enabled fixture defaults, without changing user settings.
hooks/register.ts 59 lines1// usage-reporter: keeps the weekly (seven_day) limit reading seen after each main-thread turn
2// and, only when configured, POSTs it hourly while this Claude Code session runs.
3// observe -> turn.complete, after `next(e)`, main thread only
4// answer -> /usage-reporter
5// timer -> one `$.clock.every(HOUR)` per load; a hot reload cancels the old one
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, Timer } from 'claude-code'
8
9import type { UsageReporterState } from '../types'
10import { HOUR, describeStatus, hourlySender, observe, readConfig } from './report'
11import type { Io } from './report'
12
13const NAME = 'usage-reporter'
14const reporter = atom({ plugin: 'usage-reporter', key: 'reporter' } as const, {} as UsageReporterState)
15
16const io = ($: EngineInterface): Io => ({
17 now: () => $.clock.now(),
18 fetch: (url, init) => $.http.fetch(url, init),
19 load: () => read($, reporter),
20 save: fn => update($, reporter, fn),
21})
22
23export const register: Register = (on, options) => {
24 const config = readConfig(options)
25 // Per load: session.start runs once per load, so these never outlive this module.
26 let timer: Timer | undefined
27 let tick: ReturnType<typeof hourlySender> | undefined
28
29 on('session.start', async ($, e, next) => {
30 await $.command.register({ name: NAME, description: 'Show the weekly limit reading and reporting status' })
31 if (config.kind === 'invalid') $.ui.status(`${NAME}: setup error, see /${NAME}`)
32 timer?.cancel()
33 tick ??= hourlySender(io($), config)
34 const runTick = tick
35 timer = $.clock.every(HOUR, () => {
36 void runTick().catch(() => $.ui.log(`${NAME}: tick failed (request details hidden)`, { to: 'debug' }))
37 })
38 return next(e)
39 })
40
41 on('turn.complete', async ($, e, next) => {
42 const result = await next(e)
43 if (!e.agentId) {
44 try {
45 await observe(io($), (await $.session.usage()).rateLimits)
46 } catch {
47 await update($, reporter, s => ({ ...s, weekly: undefined, unreadable: { reason: 'could not read usage' } }))
48 .catch(() => $.ui.log(`${NAME}: could not clear unavailable usage`, { to: 'debug' }))
49 $.ui.log(`${NAME}: could not read usage (details hidden)`, { to: 'debug' })
50 }
51 }
52 return result
53 })
54
55 on('command.run', { command: NAME }, async $ => ({
56 text: describeStatus(config, await read($, reporter), await $.clock.now()),
57 }))
58}
59hooks/report.ts 154 lines1// usage-reporter's logic, kept free of `$` so tests can drive it with a fake clock,
2// fetch and store. register.ts wires it to `$.clock`, `$.http` and `$.state`.
3import type { HttpInit, HttpResponse, PluginOptions, SessionRateLimit } from 'claude-code'
4
5import type { UsageReporterState, UsageReporterWeekly } from '../types'
6
7export const HOUR = 3_600_000
8const WEEKLY_KIND = 'seven_day'
9
10export type Config =
11 | { kind: 'disabled' }
12 | { kind: 'invalid'; problems: string[] }
13 | { kind: 'ready'; url: string; origin: string; accountId: string; token: string }
14
15export type Io = {
16 now: () => Promise<number>
17 fetch: (url: string, init: HttpInit) => Promise<HttpResponse>
18 load: () => Promise<UsageReporterState>
19 save: (fn: (s: UsageReporterState) => UsageReporterState) => Promise<unknown>
20}
21
22// The plugin's userConfig, checked. Off unless `enabled` is true and every field is usable.
23export function readConfig(options: PluginOptions): Config {
24 if (options.enabled !== true) return { kind: 'disabled' }
25 const problems: string[] = []
26 const rawUrl = typeof options.postUrl === 'string' ? options.postUrl.trim() : ''
27 const accountId = typeof options.accountId === 'string' ? options.accountId.trim() : ''
28 const token = typeof options.postToken === 'string' ? options.postToken.trim() : ''
29 let url: URL | undefined
30 if (!rawUrl) problems.push('postUrl is not set')
31 else {
32 try {
33 url = new URL(rawUrl)
34 } catch {
35 problems.push('postUrl is not a valid URL')
36 }
37 if (url && url.protocol !== 'https:') problems.push('postUrl must use https')
38 if (url && (url.username || url.password)) problems.push('postUrl must not carry user:password; use postToken')
39 }
40 if (!accountId) problems.push('accountId is not set')
41 if (!token) problems.push('postToken is not set')
42 if (/[\r\n]/.test(token)) problems.push('postToken must be one line')
43 if (problems.length || !url) return { kind: 'invalid', problems }
44 return { kind: 'ready', url: url.href, origin: url.origin, accountId, token }
45}
46
47// The seven_day window from a usage reading, or why there is none. Never a made-up 0.
48export function weeklyFrom(
49 rateLimits: readonly SessionRateLimit[] | undefined,
50): { ok: true; percentUsed: number; resetsAt: string | null } | { ok: false; reason: string } {
51 const week = rateLimits?.find(r => r.kind === WEEKLY_KIND)
52 if (!week) return { ok: false, reason: 'no seven_day window in the last API response' }
53 const p = week.percentUsed
54 if (typeof p !== 'number' || !Number.isFinite(p) || p < 0 || p > 100) {
55 return { ok: false, reason: `seven_day percentUsed out of range: ${String(p)}` }
56 }
57 const ms = typeof week.resetsAt === 'string' ? Date.parse(week.resetsAt) : NaN
58 return { ok: true, percentUsed: p, resetsAt: Number.isFinite(ms) ? new Date(ms).toISOString() : null }
59}
60
61// After a main-thread turn: record what the cached usage says, stamped with when we looked.
62export async function observe(io: Io, rateLimits: readonly SessionRateLimit[] | undefined) {
63 const at = new Date(await io.now()).toISOString()
64 const week = weeklyFrom(rateLimits)
65 await io.save(s =>
66 week.ok
67 ? { ...s, weekly: { percentUsed: week.percentUsed, resetsAt: week.resetsAt, observedAt: at }, unreadable: undefined }
68 : { ...s, weekly: undefined, unreadable: { at, reason: week.reason } },
69 )
70}
71
72export function isStale(weekly: UsageReporterWeekly, now: number) {
73 const resetMs = weekly.resetsAt ? Date.parse(weekly.resetsAt) : NaN
74 return now - Date.parse(weekly.observedAt) >= HOUR || now >= resetMs
75}
76
77export function reportBody(weekly: UsageReporterWeekly) {
78 return {
79 weekly_usage_percent: weekly.percentUsed,
80 weekly_resets_at: weekly.resetsAt,
81 }
82}
83
84// One hourly tick: POST the last observed reading, or do nothing when not set up or nothing observed.
85// The outcome lands in state; a failure waits for the next tick, never a retry loop.
86export async function send(io: Io, config: Config) {
87 if (config.kind !== 'ready') return
88 const { weekly, lastSuccess } = await io.load()
89 if (!weekly) return
90 const now = await io.now()
91 // The provided receiver contract has no source timestamp field. Do not make
92 // old or already submitted observations appear newly collected on the site.
93 if (!weekly.resetsAt || isStale(weekly, now) || lastSuccess?.observedAt === weekly.observedAt) return
94 const at = new Date(now).toISOString()
95 const headers: Record<string, string> = { 'content-type': 'application/json' }
96 if (config.token) headers.authorization = `Bearer ${config.token}`
97 try {
98 const res = await io.fetch(config.url, { method: 'POST', headers, body: JSON.stringify(reportBody(weekly)) })
99 await io.save(s =>
100 res.ok
101 ? { ...s, lastPost: { at, ok: true, status: res.status }, lastSuccess: { at, status: res.status, observedAt: weekly.observedAt } }
102 : { ...s, lastPost: { at, ok: false, status: res.status, error: `HTTP ${res.status}` } },
103 )
104 } catch {
105 // Transport errors can contain request headers or capability URLs.
106 await io.save(s => ({ ...s, lastPost: { at, ok: false, error: 'network request failed (receiver credentials hidden)' } }))
107 }
108}
109
110// Keep one request in flight even if a receiver takes longer than an hourly period.
111export function hourlySender(io: Io, config: Config) {
112 let inFlight = false
113 return async () => {
114 if (inFlight) return
115 inFlight = true
116 try {
117 await send(io, config)
118 } finally {
119 inFlight = false
120 }
121 }
122}
123
124// What /usage-reporter prints. Shows the receiver's origin only: its path may be a capability.
125export function describeStatus(config: Config, s: UsageReporterState, now: number) {
126 const lines = ['usage-reporter']
127 const w = s.weekly
128 if (w) {
129 lines.push(`Weekly limit: ${w.percentUsed}% used, resets ${w.resetsAt ?? 'unknown (not in the API response)'}`)
130 lines.push(`Observed: ${w.observedAt} (${age(now - Date.parse(w.observedAt))} ago, locally after a turn; not a server update time)`)
131 if (w.resetsAt && now >= Date.parse(w.resetsAt)) lines.push('Reset time has passed: the reading is out of date until the next turn.')
132 if (!w.resetsAt) lines.push('Reporting waits for a known weekly reset time.')
133 else if (isStale(w, now)) lines.push('Reporting waits for a fresh observation; old readings are not re-submitted as new collections.')
134 if (s.lastSuccess?.observedAt === w.observedAt) lines.push('This observation has already been reported; waiting for the next main-thread turn.')
135 } else {
136 lines.push('Weekly limit: unknown, no seven_day reading observed yet (it arrives after a turn on a subscription)')
137 }
138 if (s.unreadable) lines.push(`Last turn without a reading${s.unreadable.at ? ` at ${s.unreadable.at}` : ''}: ${s.unreadable.reason}`)
139 if (config.kind === 'disabled') lines.push('Reporting: off (enabled is false). Nothing is sent.')
140 else if (config.kind === 'invalid') lines.push(`Reporting: setup error, nothing is sent: ${config.problems.join('; ')}`)
141 else lines.push(`Reporting: hourly POST to ${config.origin} (path hidden) as account ${config.accountId}`)
142 const p = s.lastPost
143 if (!p) lines.push('Last POST: none yet')
144 else if (p.ok) lines.push(`Last POST: ok (${p.status}) at ${p.at}`)
145 else lines.push(`Last POST: failed at ${p.at}: ${p.error}; retries at the next hourly tick`)
146 if (s.lastSuccess && !p?.ok) lines.push(`Last success: ${s.lastSuccess.at} (${s.lastSuccess.status})`)
147 return lines.join('\n')
148}
149
150function age(ms: number) {
151 const min = Math.max(0, Math.floor(ms / 60_000))
152 return min < 60 ? `${min} min` : `${Math.floor(min / 60)} h ${min % 60} min`
153}
154types/index.d.ts 17 lines1// The mod's state contract: every `$.state` value the module names, under the mod's name.
2// `observedAt` is when this mod read the cached usage after a turn, not a provider timestamp.
3export type UsageReporterWeekly = { percentUsed: number; resetsAt: string | null; observedAt: string }
4
5export type UsageReporterState = {
6 weekly?: UsageReporterWeekly
7 unreadable?: { at?: string; reason: string }
8 lastPost?: { at: string; ok: boolean; status?: number; error?: string }
9 lastSuccess?: { at: string; status: number; observedAt: string }
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'usage-reporter': { reporter: UsageReporterState }
15 }
16}
17