SLOPSHOPPER

gh-quota-meter

查 GitHub API 剩餘額度(REST/GraphQL)寫進 meter.log,低於 1000 時提醒一次;狀態列顯示為選用(prompt-glance 的 HUD 已顯示額度)。

newtoaststatusprocesstimer
v0.1.6no licenseupdated 2026-10-24audichuang/mods/gh-quota-meter
A shopper browsing a rack in a slop shop
README

audi-mods

Personal Claude Code plugins and mods.

PluginWhat it does
gh-quotaKeeps agents from burning the shared GitHub API quota: gh-wait (waits on CI with ETags), a hook that blocks gh polling loops and anonymous curl, and a skill on how to wait
gh-quota-meterLogs the GitHub API quota (REST/GraphQL) to meter.log every 2 min and toasts once per reset window when it drops below 1000; the status-line entry is opt-in (status option), and prompt-glance's HUD shows the quota by default
prompt-glanceTwo-row HUD above the prompt: model, branch, context and plan-window gauges, prompt-cache hit rate and countdown
zh-steadyNudges the model back to Traditional Chinese when a step drifts into English

Install

/plugin marketplace add audichuang/mods
/plugin install gh-quota --marketplace audichuang/mods
/plugin install gh-quota-meter --marketplace audichuang/mods
/plugin install prompt-glance --marketplace audichuang/mods
/plugin install zh-steady --marketplace audichuang/mods
Source 1 files
hooks/register.ts 112 lines
1import type { Register } from 'claude-code'
2
3// The rate_limit endpoint itself does not count against the quota.
4const EVERY_MS = 120_000
5const WARN_BELOW = 1000
6// Without a usable token (an SSH session can't reach the macOS keychain) gh reports the anonymous 60.
7const ANON_LIMIT = 60
8const JQ = '[.resources.core.remaining, .resources.core.limit, .resources.core.reset, .resources.graphql.remaining] | @tsv'
9// $.process.run takes no shell, and the Claude process's PATH may lack Homebrew's bin.
10const GH = ['gh', '/opt/homebrew/bin/gh', '/home/linuxbrew/.linuxbrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh'] as const
11// Shared with the gh-quota hook's hook.log; one line per state change, newest LOG_MAX kept.
12const LOG_DIR = '.local/state/gh-quota'
13const LOG_MAX = 500
14
15type Kind = 'ok' | 'anon' | 'no-gh' | 'timeout' | 'fail' | 'bad-output'
16
17export const register: Register = (on, options) => {
18  // prompt-glance draws the quota in its HUD; the engine's status entry is opt-in
19  const showStatus = options.status === true
20  let warnedForReset = 0
21  let gh: string | undefined
22  let lastKind: Kind | undefined
23
24  on('session.start', async ($, e, next) => {
25    // Nobody sees a status line in -p or SDK runs.
26    if (!e.isInteractive) {
27      return next(e)
28    }
29
30    const home = await $.env.get('HOME')
31    // gh runs in HOME: the session's cwd can be deleted under it (a removed worktree), and then nothing starts there.
32    const cwd = home ?? '/'
33
34    // $.fs.write replaces the whole file, so append by rewriting the tail.
35    const log = async (event: string, detail: string) => {
36      if (home === undefined) {
37        return
38      }
39      const path = `${home}/${LOG_DIR}/meter.log`
40      const before = await $.fs.read(path).catch(() => '')
41      const line = `${new Date().toISOString()}\t${event}\t${detail}`
42      const kept = [...before.split('\n').filter(l => l !== ''), line].slice(-LOG_MAX)
43      await $.fs.write(path, kept.join('\n') + '\n').catch(() => undefined)
44    }
45
46    // run() rejects both when the binary can't start and when it outlives timeoutMs, so find
47    // gh with an offline `--version` first; a rejected rate_limit call then means a timeout.
48    const findGh = async () => {
49      for (const bin of GH) {
50        const ran = await $.process.run([bin, '--version'], { cwd, timeoutMs: 5_000 }).catch(() => undefined)
51        if (ran?.exitCode === 0) {
52          return bin
53        }
54      }
55      return undefined
56    }
57
58    const check = async (): Promise<{ kind: Kind; text: string; detail: string }> => {
59      gh ??= await findGh()
60      if (gh === undefined) {
61        return { kind: 'no-gh', text: 'GH 額度:找不到 gh', detail: GH.join(' ') }
62      }
63      const ran = await $.process.run([gh, 'api', 'rate_limit', '--jq', JQ], { cwd, timeoutMs: 15_000 }).catch(() => undefined)
64      if (ran === undefined) {
65        return { kind: 'timeout', text: 'GH 額度:查詢逾時', detail: gh }
66      }
67      // gh exits 4 when it has no credentials at all, without asking GitHub.
68      if (ran.exitCode === 4) {
69        return { kind: 'anon', text: 'GH 未登入 · gh auth login', detail: `exit 4: ${ran.stderr.trim().slice(0, 200)}` }
70      }
71      if (ran.exitCode !== 0) {
72        return { kind: 'fail', text: 'GH 額度:查詢失敗', detail: `exit ${ran.exitCode}: ${ran.stderr.trim().slice(0, 200)}` }
73      }
74      // Number('') is 0, so reject empty fields before converting.
75      const fields = ran.stdout.trim().split('\t')
76      const [remaining, limit, reset, graphql] = fields.map(Number)
77      if (fields.length !== 4 || fields.includes('') || remaining === undefined || limit === undefined || reset === undefined || graphql === undefined || [remaining, limit, reset, graphql].some(Number.isNaN)) {
78        return { kind: 'bad-output', text: 'GH 額度:回應格式不對', detail: ran.stdout.trim().slice(0, 200) }
79      }
80      const resetAt = new Date(reset * 1000).toTimeString().slice(0, 5)
81      // The account's quota isn't this one, so say not logged in instead of warning about it.
82      if (limit <= ANON_LIMIT) {
83        return { kind: 'anon', text: `GH 未登入(匿名 ${remaining}/${limit})· gh auth login`, detail: `${remaining}/${limit}` }
84      }
85      if (remaining < WARN_BELOW && warnedForReset !== reset) {
86        warnedForReset = reset
87        $.ui.toast(`GitHub API 額度只剩 ${remaining}/${limit},${resetAt} 重置。等 CI 用 gh-wait,別輪詢。`)
88        await log('warn', `${remaining}/${limit} reset ${resetAt}`)
89      }
90      return { kind: 'ok', text: `GH ${remaining}/${limit} · GQL ${graphql} · ${resetAt} 重置`, detail: `${remaining}/${limit} gql ${graphql}` }
91    }
92
93    const refresh = async () => {
94      const state = await check()
95      if (showStatus) $.ui.status(state.text)
96      if (state.kind !== lastKind) {
97        lastKind = state.kind
98        await log(state.kind, state.detail)
99      }
100    }
101
102    // a reload keeps the entry an earlier version pinned
103    if (!showStatus) $.ui.status(undefined)
104    await log('start', e.cwd)
105    // Don't hold the session start on a network call.
106    void refresh()
107    $.clock.every(EVERY_MS, refresh)
108
109    return next(e)
110  })
111}
112