SLOPSHOPPER

ccusage

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

statusprocessnetworktimer
v0.2.0MITupdated 2026-10-19yuemingruoan/CCUsage/packages/mod/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ccusage
› fix the failing auth test and add an audit log call ⏺ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ ccusage: 5h 31% ↻09:53 · $0.42
README

CCUsage

Claude Code 用量统计与订阅额度追踪:读取本地会话记录,计算 API 等值花费、按模型/时间聚合, 追踪 5 小时 / 每周订阅窗口;桌面端为 Electron(macOS / Windows / Linux),附带 Claude Code mod 与自托管多设备同步服务端。

  • 多设备视图:设备列表可点进设备详情(按日期用量 + 该设备的会话);远端会话显示真实标题与 具体时间段,未定价模型标记「未定价」而不是 $0.00。
  • 额度归属:Claude 模型(含中转/共享登录)计入 5h/7d 订阅额度,非 Claude 模型(deepseek/kimi/glm 等)不消耗订阅额度;会话列表带「订阅 / 第三方模型 / 混合」徽标。

环境要求

组件要求
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 实现(参考)
Source 1 files
hooks/register.ts 366 lines
1import 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