Reports Claude Code subscription rate limits to the CCUsage app over a local Unix socket and writes them to the app's log.

Claude Code 用量统计与订阅额度追踪:读取本地会话记录,计算 API 等值花费、按模型/时间聚合, 追踪 5 小时 / 每周订阅窗口;桌面端为 Electron(macOS / Windows / Linux),附带 Claude Code mod 与自托管多设备同步服务端。
| 组件 | 要求 |
|---|---|
| Node.js | >= 22.5(服务端使用 node:sqlite) |
| npm | 随 Node.js 提供 |
| 桌面端平台 | macOS / Windows / Linux |
| 构建依赖 | TypeScript + esbuild(npm 安装);Electron 44(npm 安装) |
| 打包环境 | macOS 包需在 macOS 构建;Windows 包可在 macOS/Windows 交叉构建 |
npm install
npm run build # gen:mod + core + server + desktop
npm test # 105 个单元/集成测试
npm run smoke:mod # mod <-> app socket 端到端冒烟
npm run parity # 与本机 ~/.claude 的对照报告(可选)
npm start # 桌面 app(读取真实 ~/.claude)
npm run demo # demo 数据模式
npm run server:init # 命令行引导创建同步服务端账号
npm run server # 同步服务端(默认 127.0.0.1:8787)
npm run seed && npm run screenshot # 生成 demo 数据并截图
npm run dist:mac # -> dist/packages/CCUsage-<version>-mac-arm64.zip(含 .app)
npm run dist:win # -> dist/packages/CCUsage-<version>-win-x64.zip(含 CCUsage.exe)
npm run dist:server # -> dist/server/(单文件服务端 + start.sh / start.cmd)
npm run dist # 以上全部
服务端产物是单个 Node.js 文件,运行环境只需 Node.js >= 22.5;默认监听 127.0.0.1:8787, 数据保存在 SQLite(默认 ./data/ccusage.sqlite)。
cd dist/server
./start.sh init # 命令行引导:用户名 + 两次密码
PORT=8787 HOST=0.0.0.0 ./start.sh # 对外监听示例
环境变量:CCUSAGE_DB(数据库路径)、PORT(端口)、HOST(绑定地址)。
systemd 部署示例(路径按需修改):
[Unit]
Description=CCUsage sync server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/ccusage-server
Environment=HOST=0.0.0.0
Environment=PORT=8787
Environment=CCUSAGE_DB=/opt/ccusage-server/data/ccusage.sqlite
ExecStart=/usr/bin/node /opt/ccusage-server/ccusage-server.cjs serve
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
安全提示:服务端是 HTTP 明文,公网部署请置于 nginx/caddy 反向代理后并启用 HTTPS, 并使用强密码;服务端无需也不应把密码写入仓库。
docs/ARCHITECTURE.md — 架构与数据流docs/CONTRACTS.md — 接口/协议契约docs/UI-STYLE.md — UI 风格docs/DEMO.md — 演示与验收docs/DEPLOY.md — 服务端部署指南(通用模板)docs/STATUS.md — 交付状态与已知边界legacy/swift/ — 原 SwiftUI 实现(参考)hooks/register.ts 366 lines1import type { EngineInterface, Register, SessionCost, SessionRateLimit } from 'claude-code'
2
3// CCUsage mod (v0.2): reports the subscription rate-limit windows Claude Code
4// measures to the CCUsage desktop app over a local socket, with a file log as
5// fallback. The app controls how often we poll and can ask for an immediate
6// report; the mod never makes network requests except to the local app.
7
8const LOG_DIRECTORY = 'Library/Application Support/CCUsage'
9const APPEND = 'mkdir -p "$1" && printf \'%s\\n\' "$2" >> "$1/rate-limits.jsonl"'
10/** An unchanged reading is written to the fallback log again once it is this old. */
11export const REPEAT_AFTER_MS = 300_000
12/** How often the timer wakes to look for app requests. */
13export const TICK_MS = 2_000
14export const DEFAULT_POLL_MS = 60_000
15const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
16
17type Usage = { rateLimits: readonly SessionRateLimit[]; cost?: SessionCost; entrypoint?: string }
18
19export type BridgeConfig = {
20 version?: number
21 socketPath?: string
22 url?: string
23 token: string
24 pollMs?: number
25 refreshId?: number
26 appVersion?: string
27}
28
29export type AuthFingerprint = {
30 /** managedKey = /login subscription; anything else is an API-key / token path. */
31 credential: 'managedKey' | 'oauthToken' | 'authToken' | 'apiKey' | 'apiKeyHelper' | 'unknown'
32 /** Hostname of ANTHROPIC_BASE_URL, when one is set. */
33 baseUrlHost?: string
34 /** True when the base URL does not point at *.anthropic.com. */
35 thirdPartyBase?: boolean
36}
37
38export type BridgeReading = {
39 sessionId: string
40 /** Authoritative auth source read from the Claude Code process (env + settings). */
41 auth?: AuthFingerprint
42 entrypoint?: string
43 rateLimits: Array<{ kind: string; percentUsed: number; resetsAt?: number }>
44 cost?: { usd: number }
45 changed?: boolean
46 at: number
47}
48
49export type BridgeReply = { ok?: boolean; pollMs?: number; reportNow?: boolean; refreshId?: number }
50
51export type BridgeCandidate = { home?: string; appdata?: string; xdg?: string }
52
53/** Where the app may have written bridge.json, in probe order. */
54export function bridgeCandidatePaths(paths: BridgeCandidate): string[] {
55 const out: string[] = []
56 const join = (base: string, rest: string) => base.replace(/[\\/]+$/, '') + '/' + rest
57 if (paths.home) {
58 out.push(join(paths.home, 'Library/Application Support/CCUsage/bridge.json'))
59 }
60 if (paths.appdata) {
61 out.push(join(paths.appdata, 'CCUsage/bridge.json'))
62 }
63 if (paths.xdg) {
64 out.push(join(paths.xdg, 'CCUsage/bridge.json'))
65 }
66 if (paths.home) {
67 out.push(join(paths.home, '.config/CCUsage/bridge.json'))
68 }
69 return out
70}
71
72const findLimit = (usage: Usage, kind: string) => usage.rateLimits.find(limit => limit.kind === kind)
73
74const toEpoch = (value: string | number | undefined): number | undefined => {
75 if (value === undefined) return undefined
76 if (typeof value === 'number') return value
77 const parsed = Date.parse(value)
78 return Number.isFinite(parsed) ? parsed : undefined
79}
80
81/** The wire payload the app's bridge expects. */
82export function buildReading(sessionId: string, usage: Usage, at: number, auth?: AuthFingerprint): BridgeReading {
83 return {
84 sessionId,
85 ...(auth !== undefined ? { auth } : {}),
86 entrypoint: usage.entrypoint,
87 rateLimits: usage.rateLimits.map(limit => ({
88 kind: limit.kind,
89 percentUsed: limit.percentUsed,
90 resetsAt: toEpoch(limit.resetsAt),
91 })),
92 cost: usage.cost === undefined ? undefined : { usd: usage.cost.usd },
93 at,
94 }
95}
96
97const pad = (value: number) => String(value).padStart(2, '0')
98
99const labelOf = (name: string, limit: SessionRateLimit | undefined, hasWeekday: boolean) => {
100 if (limit === undefined) return undefined
101 const label = name + ' ' + Math.floor(limit.percentUsed) + '%'
102 if (limit.resetsAt === undefined) return label
103 const reset = new Date(limit.resetsAt)
104 const time = pad(reset.getHours()) + ':' + pad(reset.getMinutes())
105 return label + ' ↻' + (hasWeekday ? WEEKDAYS[reset.getDay()] + ' ' : '') + time
106}
107
108/** The status line Claude Code pins under the prompt. */
109export function statusText(usage: Usage): string | undefined {
110 const windows = [
111 labelOf('5h', findLimit(usage, 'five_hour'), false),
112 labelOf('7d', findLimit(usage, 'seven_day'), true),
113 ].filter(label => label !== undefined) as string[]
114 if (windows.length === 0) return undefined
115 const cost = usage.cost === undefined ? [] : ['$' + usage.cost.usd.toFixed(2)]
116 return [...windows, ...cost].join(' · ')
117}
118
119export type ReportState = {
120 lastPushAt: number
121 lastRefreshId: number
122 lastRateLimits: string
123 lastAuth: string
124 lastJsonlAt: number
125 lastJsonlReading: string
126 pollMs: number
127}
128
129export function initialReportState(): ReportState {
130 return { lastPushAt: 0, lastRefreshId: -1, lastRateLimits: '', lastAuth: '', lastJsonlAt: 0, lastJsonlReading: '', pollMs: DEFAULT_POLL_MS }
131}
132
133/** Whether the app should be told about this reading now. */
134export function shouldReport(state: ReportState, config: BridgeConfig, reading: BridgeReading, now: number): boolean {
135 const refreshId = config.refreshId ?? 0
136 if (state.lastPushAt === 0) return true
137 if (refreshId !== state.lastRefreshId) return true
138 const rates = JSON.stringify(reading.rateLimits)
139 if (rates !== state.lastRateLimits) return true
140 const auth = JSON.stringify(reading.auth ?? null)
141 if (state.lastAuth !== '' && auth !== state.lastAuth) return true
142 const pollMs = Math.max(config.pollMs ?? state.pollMs ?? DEFAULT_POLL_MS, 250)
143 return now - state.lastPushAt >= pollMs
144}
145
146const windowOf = (limit: SessionRateLimit | undefined) =>
147 limit && {
148 used_percentage: limit.percentUsed,
149 resets_at: toEpoch(limit.resetsAt) === undefined ? undefined : Math.floor((toEpoch(limit.resetsAt) as number) / 1000),
150 }
151
152/** The legacy JSONL line the app also reads when the bridge is not up. */
153export function jsonlRecord(sessionId: string, usage: Usage, now: number): string | undefined {
154 const fiveHour = windowOf(findLimit(usage, 'five_hour'))
155 const sevenDay = windowOf(findLimit(usage, 'seven_day'))
156 if (fiveHour === undefined && sevenDay === undefined) return undefined
157 return JSON.stringify({ ts: Math.floor(now / 1000), session_id: sessionId, five_hour: fiveHour, seven_day: sevenDay })
158}
159
160export function shouldWriteJsonl(state: ReportState, record: string | undefined, now: number): boolean {
161 if (record === undefined) return false
162 if (record !== state.lastJsonlReading) return true
163 return now - state.lastJsonlAt >= REPEAT_AFTER_MS
164}
165
166async function readBridge($: EngineInterface): Promise<{ config: BridgeConfig; path: string } | undefined> {
167 const home = await $.env.get('HOME')
168 const appdata = await $.env.get('APPDATA')
169 const xdg = await $.env.get('XDG_CONFIG_HOME')
170 for (const path of bridgeCandidatePaths({ home, appdata, xdg })) {
171 try {
172 const text = await $.fs.read(path)
173 const config = JSON.parse(text) as BridgeConfig
174 if (config === null || typeof config !== 'object') continue
175 if (typeof config.token !== 'string') continue
176 if (typeof config.socketPath !== 'string' && typeof config.url !== 'string') continue
177 return { config, path }
178 } catch {
179 // try the next candidate
180 }
181 }
182 return undefined
183}
184
185const hostOf = (value: string | undefined): string | undefined => {
186 if (value === undefined || value.length === 0) return undefined
187 try {
188 return new URL(value).hostname
189 } catch {
190 return undefined
191 }
192}
193
194/**
195 * Reads the auth source of the process Claude Code runs in: the env overrides plus the
196 * merged settings. This is authoritative even when the model name, response ids and
197 * pricing all look like the official subscription (e.g. a reverse proxy).
198 */
199export async function readAuthFingerprint($: EngineInterface): Promise<AuthFingerprint> {
200 let settings: { env?: Record<string, string>; apiKeyHelper?: string } | undefined
201 try {
202 settings = (await $.settings.read()) as { env?: Record<string, string>; apiKeyHelper?: string }
203 } catch {
204 settings = undefined
205 }
206 const settingsEnv = settings?.env ?? {}
207 const [baseUrlEnv, authToken, apiKey, oauthToken] = await Promise.all([
208 $.env.get('ANTHROPIC_BASE_URL'),
209 $.env.get('ANTHROPIC_AUTH_TOKEN'),
210 $.env.get('ANTHROPIC_API_KEY'),
211 $.env.get('CLAUDE_CODE_OAUTH_TOKEN'),
212 ])
213 const baseUrl = baseUrlEnv ?? settingsEnv.ANTHROPIC_BASE_URL
214 const apiKeyHelper = typeof settings?.apiKeyHelper === 'string' && settings.apiKeyHelper.length > 0 ? settings.apiKeyHelper : undefined
215 const credential: AuthFingerprint['credential'] = apiKeyHelper !== undefined
216 ? 'apiKeyHelper'
217 : authToken !== undefined && authToken.length > 0
218 ? 'authToken'
219 : apiKey !== undefined && apiKey.length > 0
220 ? 'apiKey'
221 : oauthToken !== undefined && oauthToken.length > 0
222 ? 'oauthToken'
223 : 'managedKey'
224 const baseUrlHost = hostOf(baseUrl)
225 const thirdPartyBase = baseUrlHost !== undefined && !(baseUrlHost === 'anthropic.com' || baseUrlHost.endsWith('.anthropic.com'))
226 return {
227 credential,
228 ...(baseUrlHost !== undefined ? { baseUrlHost } : {}),
229 ...(thirdPartyBase ? { thirdPartyBase: true } : {}),
230 }
231}
232
233let fingerprint: AuthFingerprint | undefined
234let fingerprintAt = 0
235
236async function currentFingerprint($: EngineInterface, now: number): Promise<AuthFingerprint | undefined> {
237 if (fingerprint === undefined || now - fingerprintAt > 60_000) {
238 try {
239 fingerprint = await readAuthFingerprint($)
240 fingerprintAt = now
241 } catch {
242 // keep the previous fingerprint; auth reporting must never break a session
243 }
244 }
245 return fingerprint
246}
247
248async function postReading($: EngineInterface, config: BridgeConfig, reading: BridgeReading): Promise<BridgeReply | undefined> {
249 const url = typeof config.socketPath === 'string'
250 ? 'http://ccusage.local/report'
251 : (config.url as string).replace(/\/+$/, '') + '/report'
252 const response = await $.http.fetch(url, {
253 method: 'POST',
254 headers: { 'content-type': 'application/json', 'x-ccusage-token': config.token },
255 body: JSON.stringify(reading),
256 ...(typeof config.socketPath === 'string' ? { socketPath: config.socketPath } : {}),
257 })
258 if (!response.ok) return undefined
259 try {
260 return JSON.parse(response.text) as BridgeReply
261 } catch {
262 return { ok: true }
263 }
264}
265
266async function writeJsonl($: EngineInterface, record: string): Promise<void> {
267 const home = await $.env.get('HOME')
268 if (home === undefined) return
269 try {
270 await $.process.run(['/bin/sh', '-c', APPEND, 'sh', home + '/' + LOG_DIRECTORY, record])
271 } catch {
272 // non-POSIX hosts: the bridge is the only path
273 }
274}
275
276// Per-load state; a reload starts it over. Kept at the top level because the
277// engine only lets `$` flow into functions declared at the top of the module.
278let timer: { cancel?: () => void } | undefined
279let state = initialReportState()
280let busy = false
281let sessionId: string | undefined
282
283async function push($: EngineInterface, usage: Usage, now: number, changed: boolean): Promise<void> {
284 const bridge = await readBridge($)
285 if (bridge === undefined) {
286 const record = jsonlRecord(sessionId ?? 'unknown', usage, now)
287 if (shouldWriteJsonl(state, record, now) && record !== undefined) {
288 await writeJsonl($, record)
289 state.lastJsonlAt = now
290 state.lastJsonlReading = record
291 }
292 return
293 }
294 const id = sessionId ?? (await $.session.id())
295 sessionId = id
296 const reading = buildReading(id, usage, now, await currentFingerprint($, now))
297 reading.changed = changed
298 if (reading.rateLimits.length === 0 && reading.auth === undefined) return
299 const reply = await postReading($, bridge.config, reading)
300 if (reply === undefined) {
301 const record = jsonlRecord(id, usage, now)
302 if (shouldWriteJsonl(state, record, now) && record !== undefined) {
303 await writeJsonl($, record)
304 state.lastJsonlAt = now
305 state.lastJsonlReading = record
306 }
307 return
308 }
309 state.lastPushAt = now
310 state.lastRefreshId = bridge.config.refreshId ?? 0
311 state.lastRateLimits = JSON.stringify(reading.rateLimits)
312 state.lastAuth = JSON.stringify(reading.auth ?? null)
313 if (typeof reply.pollMs === 'number' && reply.pollMs > 0) state.pollMs = reply.pollMs
314 if (reply.reportNow === true) {
315 // The app asked for a fresh reading; answer once more on the next tick.
316 state.lastPushAt = 0
317 }
318}
319
320async function tick($: EngineInterface): Promise<void> {
321 if (busy) return
322 busy = true
323 try {
324 const now = await $.clock.now()
325 const bridge = await readBridge($)
326 if (bridge === undefined) return
327 const usage = (await $.session.usage()) as Usage
328 const id = sessionId ?? (await $.session.id())
329 sessionId = id
330 const reading = buildReading(id, usage, now, await currentFingerprint($, now))
331 state.pollMs = bridge.config.pollMs ?? state.pollMs
332 if (!shouldReport(state, bridge.config, reading, now)) return
333 await push($, usage, now, false)
334 } catch {
335 // never break the session
336 } finally {
337 busy = false
338 }
339}
340
341export const register: Register = on => {
342 on('session.start', async ($, e, next) => {
343 const started = await next(e)
344 sessionId = await $.session.id()
345 const usage = (await $.session.usage()) as Usage
346 $.ui.status(statusText(usage))
347 await push($, usage, await $.clock.now(), true)
348 timer?.cancel?.()
349 timer = $.clock.every(TICK_MS, () => tick($))
350 return started
351 })
352
353 on('session.measure', async ($, e, next) => {
354 const usage = e as unknown as Usage
355 $.ui.status(statusText(usage))
356 await push($, usage, await $.clock.now(), true)
357 return next(e)
358 })
359
360 on('session.end', async ($, e, next) => {
361 timer?.cancel?.()
362 timer = undefined
363 return next(e)
364 })
365}
366