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

Personal Claude Code plugins and mods.
| Plugin | What it does |
|---|---|
gh-quota | Keeps 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-meter | Logs 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-glance | Two-row HUD above the prompt: model, branch, context and plan-window gauges, prompt-cache hit rate and countdown |
zh-steady | Nudges the model back to Traditional Chinese when a step drifts into English |
/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/modshooks/register.ts 112 lines1import 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