Keeps GitHub Actions spend in sight: the month's minutes and cost for the repo's organisation or account, GitHub budgets, cache and artifacts, risky workflows…

Keeps GitHub Actions spend in sight while Claude writes workflows and pushes, so an organisation's or an account's minutes do not run out by accident:
.github/workflows/*.yml file is written or edited, the spend risks in it go back to the model and show as a toastgit push or gh workflow run, the runs it started are followed; a job running too long is flagged with the command to cancel itblockAt (100% by default) gh workflow run is denied until you allow it/plugin install ci-budget@claude-mods
The status line reads Actions 62% · acme, Actions ~140 min (estimate) · acme/app or adds CI 2 running / CI ✓ / CI ✗ 1 failed while runs are watched. /ci-budget prints the full report, including what to set up for exact numbers.
The mod reads GitHub through the GitHub CLI you are logged into: it stores no token and asks for nothing more than your gh login allows. It works for anyone who installs it; how exact the numbers are depends on that access:
| Your access | What you get |
|---|---|
| Owner or billing manager of the organisation | Exact usage from GitHub billing: minutes by runner, cost, % of included minutes, every repo's share, the Actions budget |
Your own account with the user scope | The same for your personal repos |
| Member or collaborator (no billing access) | An estimate for the current repo: its finished jobs this month, rounded up to the minute and weighed by runner (Linux ×1, Windows ×2, macOS ×10, self-hosted free). Other repos of the owner are not counted. Marked as an estimate everywhere |
| Public repo | Its runs on GitHub-hosted runners are free; the report says so |
| No access to the repo's Actions | Workflow check and nothing else, with a hint |
Without billing access nothing breaks: the mod falls back to the estimate and says which access would give exact numbers.
1. GitHub CLI, logged in:
# https://cli.github.com
gh auth login
gh auth status # shows the scopes your token has
The default gh auth login scopes (repo, read:org, workflow) are enough for the repo level: runs, jobs, cache, artifacts, run watch.
2. Billing of an organisation (exact numbers): you must be an owner or billing manager of it. In testing, an owner's default read:org scope was enough. If the report says billing was refused although you are an owner or billing manager, add the scope:
gh auth refresh -h github.com -s admin:org
Members cannot read an organisation's billing; ask an owner, or rely on the estimate.
3. Billing of your personal account:
gh auth refresh -h github.com -s user
4. A GitHub budget that stops usage — the actual hard limit. ci-budget warns and can block gh workflow run inside Claude Code, but only GitHub can stop runs, including the ones a push starts. Create one once per organisation or account:
Pick the Actions product, set the amount ($0 allows the included minutes only) and turn on stop usage when the budget limit is reached. /ci-budget shows whether such a budget exists and warns when Actions has none.
Run /ci-budget refresh after changing access.
ci-budget warns inside the session: a toast and the status line. For a desktop notification when the budget runs low (useful while you are in another window), also install notify:
/plugin install notify@claude-mods
notify watches ci-budget's measurement and notifies once per owner and month when usage reaches its budgetPercent (80% by default), and once more at 100%. Its budget option turns that off. Neither mod needs the other: without notify ci-budget works as above, and /ci-budget reminds you that notify exists. Like the toasts, it needs exact billing numbers (see Setup).
Flags, with how to fix each:
timeout-minutes (a stuck job runs up to 6 hours)concurrency + cancel-in-progresspush to every branch together with pull_request (each PR push runs twice)workflow_run on the workflow's own name (each run starts another)The check reads the YAML text (a mod has no YAML parser), so it can miss an unusual layout; it never blocks a write.
| Option | Default | |
|---|---|---|
warnAt | 80 | Toast when usage crosses each of these percentages of the included minutes |
blockAt | 100 | Deny gh workflow run from this percentage (billing data only); 0 never blocks |
includedMinutes | 0 | Included minutes per month; 0 takes them from the plan (Free 2 000, Pro and Team 3 000, Enterprise 50 000) |
stuckMinutes | 30 | A watched job running longer than this is flagged |
refreshMinutes | 15 | How often usage is read again |
lint | true | Check workflows as they are written |
watch | true | Follow the runs a push or a dispatch starts |
/ci-budget off pauses the block for the session and /ci-budget on restores it; both are accepted only from you at the prompt, never from another agent or a channel.
GH_TOKEN works if it grants the same access; the mod reports whatever it is refused.hooks/register.ts 464 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CiBudgetSnapshot, CiBudgetWatch } from '../types'
5import { isWorkflowPath, lintWorkflow } from './lint'
6import {
7 INCLUDED_MINUTES,
8 actionsBudget,
9 formatBytes,
10 formatMinutes,
11 fromBilling,
12 fromJobs,
13 parseRemote,
14 period,
15 setupHint,
16} from './usage'
17import type { Job, UsageItem } from './usage'
18
19const snapshot = atom({ plugin: 'ci-budget', key: 'snapshot' } as const, null)
20const watch = atom({ plugin: 'ci-budget', key: 'watch' } as const, null)
21const isPaused = atom({ plugin: 'ci-budget', key: 'isPaused' } as const, false)
22
23/** Who may pause the block: the person, never another agent, a channel or a peer session. */
24const PERSON = new Set(['composer', 'bridge', 'sdk'])
25
26/** Runs whose jobs are counted per refresh at most, so a busy repo does not stall a refresh. */
27const MAX_NEW_RUNS = 40
28const MAX_RUN_PAGES = 3
29
30type Options = {
31 warnAt: number[]
32 blockAt: number
33 includedMinutes: number
34 stuckMs: number
35 refreshMs: number
36 isLintOn: boolean
37 isWatchOn: boolean
38}
39
40type Gh = { ok: true; data: unknown } | { ok: false; stderr: string }
41
42/** Finished runs' minutes by run id, per repo and month: a finished run never changes. */
43type RunCache = Record<string, { minutes: Record<string, number>; quotaMinutes: number }>
44
45let warnedAt = 0
46let stopWatch: (() => void) | undefined
47
48/** `gh api <path>` as the person is logged in. */
49async function gh($: EngineInterface, path: string): Promise<Gh> {
50 const ran = await $.process.run(['gh', 'api', path], { timeoutMs: 30_000 }).catch(() => undefined)
51 if (!ran) return { ok: false, stderr: 'gh could not start: install the GitHub CLI (https://cli.github.com) and run `gh auth login`.' }
52 if (ran.exitCode !== 0) return { ok: false, stderr: `${ran.stderr}\n${ran.stdout}` }
53 try {
54 return { ok: true, data: JSON.parse(ran.stdout) }
55 } catch {
56 return { ok: false, stderr: 'gh answered something that is not JSON' }
57 }
58}
59
60async function git($: EngineInterface, argv: string[]): Promise<string | undefined> {
61 const ran = await $.process.run(['git', ...argv], { timeoutMs: 10_000 }).catch(() => undefined)
62 return ran && ran.exitCode === 0 ? ran.stdout.trim() : undefined
63}
64
65/** The current repo's finished runs this month, their jobs read once and kept. */
66async function estimateRepo($: EngineInterface, repo: string, since: string, periodLabel: string) {
67 const key = `runs:${repo}:${periodLabel}`
68 const cache = ((await $.store.get(key)) ?? {}) as RunCache
69 const ids: number[] = []
70
71 for (let page = 1; page <= MAX_RUN_PAGES; page++) {
72 const listed = await gh($, `repos/${repo}/actions/runs?created=%3E%3D${since}&status=completed&per_page=100&page=${page}`)
73 if (!listed.ok) return { ok: false as const, stderr: listed.stderr }
74 const runs = (listed.data as { workflow_runs?: { id: number }[] }).workflow_runs ?? []
75 ids.push(...runs.map(run => run.id))
76 if (runs.length < 100) break
77 }
78
79 const missing = ids.filter(id => cache[id] === undefined)
80 for (const id of missing.slice(0, MAX_NEW_RUNS)) {
81 const jobs = await gh($, `repos/${repo}/actions/runs/${id}/jobs?filter=all&per_page=100`)
82 if (jobs.ok) cache[id] = fromJobs(((jobs.data as { jobs?: Job[] }).jobs ?? []) as Job[])
83 }
84 await $.store.set(key, cache)
85
86 const minutes: Record<string, number> = {}
87 let quotaMinutes = 0
88 for (const id of ids) {
89 const run = cache[id]
90 if (!run) continue
91 quotaMinutes += run.quotaMinutes
92 for (const [runner, n] of Object.entries(run.minutes)) minutes[runner] = (minutes[runner] ?? 0) + n
93 }
94
95 return { ok: true as const, minutes, quotaMinutes, pending: Math.max(0, missing.length - MAX_NEW_RUNS), runs: ids.length }
96}
97
98/** Measures the budget of whoever pays for the current repo's runs. */
99async function measure($: EngineInterface, options: Options): Promise<CiBudgetSnapshot> {
100 const now = new Date(await $.clock.now())
101 const { label, year, month, since } = period(now)
102 const hints: string[] = []
103
104 const me = await gh($, 'user')
105 if (!me.ok) {
106 const result: CiBudgetSnapshot = {
107 owner: '?',
108 ownerType: 'User',
109 period: label,
110 source: 'none',
111 minutes: {},
112 quotaMinutes: 0,
113 hints: [setupHint(me.stderr, { owner: 'GitHub', ownerType: 'User' })],
114 at: now.getTime(),
115 }
116 await update($, snapshot, () => result)
117 return result
118 }
119 const login = String((me.data as { login?: string }).login ?? '')
120
121 const remoteUrl = await git($, ['remote', 'get-url', 'origin'])
122 const remote = remoteUrl === undefined ? undefined : parseRemote(remoteUrl)
123 if (!remote) hints.push('This folder has no GitHub `origin` remote: showing your own account.')
124 const owner = remote?.owner ?? login
125 const repo = remote ? `${remote.owner}/${remote.name}` : undefined
126
127 const ownerInfo = await gh($, `users/${owner}`)
128 const ownerType = ownerInfo.ok && (ownerInfo.data as { type?: string }).type === 'Organization' ? 'Organization' : 'User'
129 const who = { owner, ownerType } as const
130
131 let isPrivate: boolean | undefined
132 if (repo) {
133 const repoInfo = await gh($, `repos/${repo}`)
134 if (repoInfo.ok) isPrivate = (repoInfo.data as { private?: boolean }).private === true
135 else hints.push(`Cannot read ${repo} with your gh login: ${setupHint(repoInfo.stderr, who)}`)
136 }
137
138 const base = ownerType === 'Organization' ? `organizations/${owner}` : `users/${owner}`
139 const canAskBilling = ownerType === 'Organization' || owner.toLowerCase() === login.toLowerCase()
140 const billing = canAskBilling ? await gh($, `${base}/settings/billing/usage?year=${year}&month=${month}`) : undefined
141
142 const result: CiBudgetSnapshot = {
143 owner,
144 ownerType,
145 ...(repo === undefined ? {} : { repo }),
146 ...(isPrivate === undefined ? {} : { isPrivate }),
147 period: label,
148 source: 'none',
149 minutes: {},
150 quotaMinutes: 0,
151 hints,
152 at: now.getTime(),
153 }
154
155 if (billing?.ok) {
156 const items = ((billing.data as { usageItems?: UsageItem[] }).usageItems ?? []) as UsageItem[]
157 const counted = fromBilling(items, label, remote?.name)
158 Object.assign(result, {
159 source: 'billing',
160 minutes: counted.minutes,
161 quotaMinutes: counted.quotaMinutes,
162 grossUsd: counted.grossUsd,
163 netUsd: counted.netUsd,
164 ...(repo === undefined ? {} : { repoQuotaMinutes: counted.repoQuotaMinutes }),
165 })
166
167 const budgets = await gh($, `${base}/settings/billing/budgets`)
168 if (budgets.ok) {
169 result.budget = actionsBudget(((budgets.data as { budgets?: Record<string, unknown>[] }).budgets ?? []) as Record<string, unknown>[])
170 }
171 } else {
172 if (billing) hints.push(setupHint(billing.stderr, who))
173 else hints.push(`Billing of ${owner} is visible to ${owner} only. Showing an estimate from this repo's jobs.`)
174
175 if (repo && isPrivate === false) {
176 hints.push(`${repo} is public: its runs on GitHub-hosted runners are free.`)
177 } else if (repo && isPrivate) {
178 const estimate = await estimateRepo($, repo, since, label)
179 if (estimate.ok) {
180 Object.assign(result, {
181 source: 'estimate',
182 minutes: estimate.minutes,
183 quotaMinutes: estimate.quotaMinutes,
184 repoQuotaMinutes: estimate.quotaMinutes,
185 })
186 if (estimate.pending > 0) hints.push(`${estimate.pending} of ${estimate.runs} runs are not counted yet; they are added on the next refreshes.`)
187 } else {
188 hints.push(`Cannot read the Actions runs of ${repo}: ${setupHint(estimate.stderr, who)}`)
189 }
190 }
191 }
192
193 const plan =
194 ownerType === 'Organization'
195 ? await gh($, `orgs/${owner}`)
196 : owner.toLowerCase() === login.toLowerCase()
197 ? me
198 : undefined
199 const planName = plan?.ok ? String((plan.data as { plan?: { name?: string } }).plan?.name ?? '').toLowerCase() : ''
200 const included = options.includedMinutes > 0 ? options.includedMinutes : INCLUDED_MINUTES[planName]
201 if (included !== undefined && result.source === 'billing') {
202 result.includedMinutes = included
203 result.percent = Math.round((result.quotaMinutes / included) * 100)
204 }
205
206 if (repo && isPrivate !== undefined) {
207 const cache = await gh($, `repos/${repo}/actions/cache/usage`)
208 if (cache.ok) result.cacheBytes = Number((cache.data as { active_caches_size_in_bytes?: number }).active_caches_size_in_bytes ?? 0)
209 const artifacts = await gh($, `repos/${repo}/actions/artifacts?per_page=100`)
210 if (artifacts.ok) {
211 const list = (artifacts.data as { artifacts?: { size_in_bytes?: number; expired?: boolean }[] }).artifacts ?? []
212 result.artifactBytes = list.filter(a => !a.expired).reduce((sum, a) => sum + Number(a.size_in_bytes ?? 0), 0)
213 }
214 }
215
216 await update($, snapshot, () => result)
217 await warnOnThreshold($, result, options)
218 await showStatus($)
219
220 return result
221}
222
223async function warnOnThreshold($: EngineInterface, measured: CiBudgetSnapshot, options: Options) {
224 if (measured.percent === undefined) return
225 const crossed = options.warnAt.filter(level => measured.percent! >= level && level > warnedAt).at(-1)
226 if (crossed === undefined) return
227 warnedAt = crossed
228 $.ui.toast(`ci-budget: ${measured.owner} has used ${measured.percent}% of its included Actions minutes this month`)
229}
230
231async function showStatus($: EngineInterface) {
232 const measured = await read($, snapshot)
233 const watching = await read($, watch)
234 const parts: string[] = []
235
236 if (measured?.source === 'billing' && measured.percent !== undefined) parts.push(`Actions ${measured.percent}% · ${measured.owner}`)
237 else if (measured?.source === 'billing') parts.push(`Actions ${formatMinutes(measured.quotaMinutes)} min · ${measured.owner}`)
238 else if (measured?.source === 'estimate') parts.push(`Actions ~${formatMinutes(measured.quotaMinutes)} min (estimate) · ${measured.repo}`)
239 if (await read($, isPaused)) parts.push('block paused')
240 if (watching && watching.runs.length > 0) parts.push(`CI ${watchSummary(watching)}`)
241
242 $.ui.status(parts.length > 0 ? parts.join(' · ') : undefined)
243}
244
245function watchSummary(watching: CiBudgetWatch): string {
246 const running = watching.runs.filter(run => run.status !== 'completed').length
247 const failed = watching.runs.filter(run => run.status === 'completed' && run.conclusion !== 'success' && run.conclusion !== 'skipped').length
248 if (running > 0) return `${running} running${failed > 0 ? `, ${failed} failed` : ''}`
249 return failed > 0 ? `✗ ${failed} failed` : '✓'
250}
251
252/** Polls the runs a push or a dispatch started until they finish. */
253function startWatch($: EngineInterface, repo: string, query: string, trigger: string, options: Options) {
254 stopWatch?.()
255 const startedAt = Date.now()
256 let isPolling = false
257
258 const poll = async () => {
259 if (isPolling) return
260 isPolling = true
261 try {
262 const listed = await gh($, `repos/${repo}/actions/runs?${query}&per_page=30`)
263 if (!listed.ok) return
264 const runs = ((listed.data as { workflow_runs?: Record<string, unknown>[] }).workflow_runs ?? []).map(run => ({
265 id: Number(run.id),
266 name: String(run.name ?? run.display_title ?? 'workflow'),
267 status: String(run.status),
268 conclusion: run.conclusion === null || run.conclusion === undefined ? null : String(run.conclusion),
269 url: String(run.html_url ?? ''),
270 }))
271 const previous = await read($, watch)
272 const stuck = previous?.stuck ?? []
273
274 for (const run of runs.filter(r => r.status === 'in_progress')) {
275 const jobs = await gh($, `repos/${repo}/actions/runs/${run.id}/jobs?per_page=100`)
276 if (!jobs.ok) continue
277 for (const job of ((jobs.data as { jobs?: Job[] }).jobs ?? []) as Job[]) {
278 if (job.status !== 'in_progress' || !job.started_at || stuck.includes(job.id)) continue
279 const runningMs = Date.now() - Date.parse(job.started_at)
280 if (runningMs < options.stuckMs) continue
281 stuck.push(job.id)
282 $.ui.toast(`ci-budget: "${job.name}" in ${run.name} has run ${Math.round(runningMs / 60_000)} min. Cancel: gh run cancel ${run.id}`)
283 }
284 }
285
286 await update($, watch, () => ({ trigger, runs, stuck }))
287 await showStatus($)
288
289 const isDone = runs.length > 0 && runs.every(run => run.status === 'completed')
290 const isQuiet = runs.length === 0 && Date.now() - startedAt > 5 * 60_000
291 if (isDone || isQuiet || Date.now() - startedAt > 3 * 60 * 60_000) {
292 stopWatch?.()
293 stopWatch = undefined
294 if (isDone) {
295 const failed = runs.filter(run => run.conclusion !== 'success' && run.conclusion !== 'skipped')
296 if (failed.length > 0) $.ui.toast(`ci-budget: ${failed.map(run => run.name).join(', ')} failed after ${trigger}`)
297 await measure($, options)
298 }
299 }
300 } finally {
301 isPolling = false
302 }
303 }
304
305 const timer = $.clock.every(60_000, () => void poll())
306 stopWatch = () => timer.cancel()
307 $.clock.after(15_000, () => void poll())
308}
309
310/** notify turns the budget warning into a desktop notification; it registers /notify. */
311async function hasNotify($: EngineInterface): Promise<boolean> {
312 const commands = await $.command.list().catch(() => [])
313 return commands.some(command => command.name === 'notify')
314}
315
316const NOTIFY_HINT =
317 'For a desktop notification when the budget runs low, install the notify mod: /plugin install notify@claude-mods.'
318
319function report(measured: CiBudgetSnapshot | null, watching: CiBudgetWatch | null, paused: boolean, blockAt: number, isNotifyMissing: boolean): string {
320 if (!measured) return 'Not measured yet: /ci-budget refresh.'
321 const lines: string[] = []
322 const source = { billing: 'from GitHub billing', estimate: "estimated from this repo's jobs", none: 'no usage data' }[measured.source]
323 lines.push(`${measured.owner} (${measured.ownerType === 'Organization' ? 'organisation' : 'account'}) · ${measured.period} · ${source}`)
324
325 const byRunner = Object.entries(measured.minutes)
326 .sort(([, a], [, b]) => b - a)
327 .map(([runner, n]) => `${runner} ${formatMinutes(n)}`)
328 .join(', ')
329 if (measured.source === 'billing') {
330 const of = measured.includedMinutes === undefined ? '' : ` of ${formatMinutes(measured.includedMinutes)} included (${measured.percent}%)`
331 lines.push(`Actions: ${formatMinutes(measured.quotaMinutes)} minutes${of}${byRunner ? ` · ${byRunner}` : ''}`)
332 lines.push(`Cost: $${(measured.grossUsd ?? 0).toFixed(2)} gross, $${(measured.netUsd ?? 0).toFixed(2)} charged beyond the included minutes`)
333 if (measured.budget === null) {
334 lines.push('No GitHub budget for Actions: usage past the included minutes is charged. Set one that stops usage: Settings → Billing and licensing → Budgets and alerts.')
335 } else if (measured.budget) {
336 lines.push(`GitHub budget for Actions: $${measured.budget.amount}${measured.budget.stops ? ', stops usage at the limit' : ', alerts only (does not stop usage)'}`)
337 }
338 } else if (measured.source === 'estimate') {
339 lines.push(`Actions: ~${formatMinutes(measured.quotaMinutes)} quota minutes in ${measured.repo} this month${byRunner ? ` · ${byRunner}` : ''} (this repo only; the owner's other repos are not counted)`)
340 }
341 if (measured.repo && measured.source === 'billing' && measured.repoQuotaMinutes !== undefined) {
342 lines.push(`${measured.repo}${measured.isPrivate ? ' (private)' : ' (public: free)'}: ${formatMinutes(measured.repoQuotaMinutes)} of those minutes`)
343 }
344 const storage = [
345 measured.cacheBytes === undefined ? '' : `cache ${formatBytes(measured.cacheBytes)} of 10 GB`,
346 measured.artifactBytes === undefined ? '' : `artifacts ${formatBytes(measured.artifactBytes)}`,
347 ].filter(Boolean)
348 if (storage.length > 0) lines.push(`Storage: ${storage.join(', ')}`)
349 if (watching && watching.runs.length > 0) {
350 lines.push(`Runs after ${watching.trigger}: ${watching.runs.map(run => `${run.name} ${run.status === 'completed' ? run.conclusion : run.status}`).join(', ')}`)
351 }
352 lines.push(
353 blockAt > 0
354 ? `gh workflow run is blocked from ${blockAt}% (billing data only)${paused ? ': paused for this session' : ''}.`
355 : 'gh workflow run is never blocked (blockAt 0).',
356 )
357 for (const hint of measured.hints) lines.push(`→ ${hint}`)
358 if (isNotifyMissing && measured.source === 'billing') lines.push(`→ ${NOTIFY_HINT}`)
359
360 return lines.join('\n')
361}
362
363export const register: Register = (on, raw) => {
364 const options: Options = {
365 warnAt: (Array.isArray(raw.warnAt) ? raw.warnAt : ['80']).map(Number).filter(n => n > 0).sort((a, b) => a - b),
366 blockAt: Math.max(0, Number(raw.blockAt ?? 100)),
367 includedMinutes: Math.max(0, Number(raw.includedMinutes ?? 0)),
368 stuckMs: Math.max(1, Number(raw.stuckMinutes ?? 30)) * 60_000,
369 refreshMs: Math.max(1, Number(raw.refreshMinutes ?? 15)) * 60_000,
370 isLintOn: raw.lint !== false,
371 isWatchOn: raw.watch !== false,
372 }
373
374 on('session.start', async ($, e, next) => {
375 await $.command.register({
376 name: 'ci-budget',
377 description: 'GitHub Actions minutes, budget and storage for this repo and its owner; refresh, off, on',
378 argumentHint: '[refresh|off|on]',
379 })
380 // Measured in the background: the first prompt does not wait for GitHub
381 $.clock.after(0, () => void measure($, options))
382 $.clock.every(options.refreshMs, () => void measure($, options))
383
384 return next(e)
385 })
386
387 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
388 const isDispatch = /\bgh\s+workflow\s+run\b/.test(e.command)
389 const isPush = /\bgit\s+push\b/.test(e.command)
390 if (!isDispatch && !isPush) return next(e)
391
392 const measured = await read($, snapshot)
393 const isOver = measured?.source === 'billing' && measured.percent !== undefined && options.blockAt > 0 && measured.percent >= options.blockAt
394 if (isDispatch && isOver && !(await read($, isPaused))) {
395 return {
396 deny: `ci-budget blocked this: ${measured!.owner} has used ${measured!.percent}% of its included Actions minutes this month (block at ${options.blockAt}%). Ask the user; they can allow it with /ci-budget off.`,
397 }
398 }
399
400 const ran = await next(e)
401 if (ran.deny !== undefined || ran.isError === true) return ran
402
403 const repo = measured?.repo
404 if (options.isWatchOn && repo) {
405 if (isPush) {
406 const sha = await git($, ['rev-parse', 'HEAD'])
407 if (sha) startWatch($, repo, `head_sha=${sha}`, 'git push', options)
408 } else {
409 const since = new Date(Date.now() - 60_000).toISOString().slice(0, 19)
410 startWatch($, repo, `event=workflow_dispatch&created=%3E%3D${since}`, 'gh workflow run', options)
411 }
412 }
413
414 const warnFrom = options.warnAt[0]
415 if (isPush && measured?.percent !== undefined && warnFrom !== undefined && measured.percent >= warnFrom) {
416 return { ...ran, context: [...(ran.context ?? []), `ci-budget: ${measured.owner} has used ${measured.percent}% of its included Actions minutes this month; this push starts CI runs. Mention it to the user if more pushes are planned.`] }
417 }
418
419 return ran
420 })
421
422 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
423 const ran = await next(e)
424 if (!options.isLintOn || !isWorkflowPath(e.file_path) || ran.deny !== undefined || ran.isError === true) return ran
425 const measured = await read($, snapshot)
426 return withLint($, ran, e.file_path, e.content, measured?.isPrivate)
427 })
428
429 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
430 const ran = await next(e)
431 if (!options.isLintOn || !isWorkflowPath(e.file_path) || ran.deny !== undefined || ran.isError === true) return ran
432 const text = await $.fs.read(e.file_path).catch(() => undefined)
433 if (typeof text !== 'string') return ran
434 const measured = await read($, snapshot)
435 return withLint($, ran, e.file_path, text, measured?.isPrivate)
436 })
437
438 on('command.run', { command: 'ci-budget' }, async ($, e) => {
439 const arg = e.args.trim().toLowerCase()
440
441 if (arg === 'off' || arg === 'on') {
442 if (!PERSON.has(e.origin.kind)) return { text: `/ci-budget ${arg} is only accepted from the person at the prompt (got ${e.origin.kind}).` }
443 await update($, isPaused, () => arg === 'off')
444 await showStatus($)
445 return { text: arg === 'off' ? 'The block is paused for this session.' : 'The block is on.' }
446 }
447 if (arg !== '' && arg !== 'refresh') return { text: 'Usage: /ci-budget [refresh|off|on]' }
448
449 const measured = arg === 'refresh' || (await read($, snapshot)) === null ? await measure($, options) : await read($, snapshot)
450 return { text: report(measured, await read($, watch), await read($, isPaused), options.blockAt, !(await hasNotify($))) }
451 })
452}
453
454function withLint<R extends { context?: readonly string[] }>($: EngineInterface, ran: R, path: string, text: string, isPrivate: boolean | undefined): R {
455 const warnings = lintWorkflow(text, isPrivate === undefined ? {} : { isPrivate })
456 if (warnings.length === 0) return ran
457
458 const file = path.replace(/\\/g, '/').split('/').pop()
459 $.ui.toast(`ci-budget: ${warnings.length} spend ${warnings.length === 1 ? 'risk' : 'risks'} in ${file}`)
460 const note = `ci-budget found spend risks in ${file}:\n${warnings.map(w => `- ${w}`).join('\n')}\nFix them unless the user wants them.`
461
462 return { ...ran, context: [...(ran.context ?? []), note] }
463}
464hooks/lint.ts 130 lines1// Spend risks in a workflow file, read from its text: a mod has no YAML
2// parser, so these are heuristics over the lines GitHub's syntax fixes.
3
4export const isWorkflowPath = (path: string) => /(^|[\\/])\.github[\\/]workflows[\\/][^\\/]+\.ya?ml$/i.test(path)
5
6const lines = (text: string) => text.replace(/\r\n/g, '\n').split('\n')
7const indentOf = (line: string) => line.length - line.trimStart().length
8const stripComment = (line: string) => line.replace(/\s+#.*$/, '')
9
10/** The lines indented under the first line matching `key:`, with that line's indent. */
11function blockUnder(all: string[], key: RegExp): { indent: number; body: string[] }[] {
12 const blocks: { indent: number; body: string[] }[] = []
13 all.forEach((line, i) => {
14 if (!key.test(line)) return
15 const indent = indentOf(line)
16 const body: string[] = []
17 for (const next of all.slice(i + 1)) {
18 if (next.trim() === '' || next.trim().startsWith('#')) continue
19 if (indentOf(next) <= indent) break
20 body.push(next)
21 }
22 blocks.push({ indent, body })
23 })
24 return blocks
25}
26
27/** How many values each matrix key lists, inline (`[a, b]`) or as a block list. */
28export function matrixSize(text: string): number {
29 let largest = 1
30 for (const { body } of blockUnder(lines(text), /^\s*matrix:\s*$/)) {
31 let size = 1
32 const keyIndent = body.length > 0 ? Math.min(...body.map(indentOf)) : 0
33 body.forEach((line, i) => {
34 if (indentOf(line) !== keyIndent) return
35 const clean = stripComment(line).trim()
36 if (/^(include|exclude):/.test(clean)) return
37 const inline = clean.match(/^[\w-]+:\s*\[(.*)\]\s*$/)
38 if (inline) {
39 size *= Math.max(1, inline[1]!.split(',').filter(value => value.trim() !== '').length)
40 return
41 }
42 if (/^[\w-]+:\s*$/.test(clean)) {
43 const items = body.slice(i + 1).filter((next, j, rest) => {
44 const before = rest.slice(0, j)
45 return indentOf(next) > keyIndent && next.trim().startsWith('- ') && before.every(b => indentOf(b) > keyIndent)
46 })
47 if (items.length > 0) size *= items.length
48 }
49 })
50 largest = Math.max(largest, size)
51 }
52 return largest
53}
54
55/** How often a cron expression fires per day, from its minute and hour fields. */
56export function cronRunsPerDay(expression: string): number {
57 const [minute = '*', hour = '*'] = expression.trim().split(/\s+/)
58 const count = (field: string, range: number) =>
59 field
60 .split(',')
61 .map(part => {
62 const step = part.match(/^(\*|\d+-\d+)\/(\d+)$/)
63 if (step) {
64 const [from, to] = step[1] === '*' ? [0, range - 1] : step[1]!.split('-').map(Number)
65 return Math.floor((to! - from!) / Number(step[2])) + 1
66 }
67 if (part === '*') return range
68 const span = part.match(/^(\d+)-(\d+)$/)
69 return span ? Number(span[2]) - Number(span[1]) + 1 : 1
70 })
71 .reduce((sum, n) => sum + n, 0)
72
73 return count(minute, 60) * count(hour, 24)
74}
75
76/** The spend risks in a workflow, one sentence each, worst first. */
77export function lintWorkflow(text: string, options: { isPrivate?: boolean } = {}): string[] {
78 const all = lines(text).map(stripComment)
79 const warnings: string[] = []
80 const has = (pattern: RegExp) => all.some(line => pattern.test(line))
81
82 const jobs = all.filter(line => /^\s+runs-on:/.test(line)).length
83 const timeouts = all.filter(line => /^\s+timeout-minutes:/.test(line)).length
84 if (jobs > 0 && timeouts < jobs) {
85 warnings.push(
86 `${jobs - timeouts} of ${jobs} jobs have no timeout-minutes: a stuck job runs for up to 6 hours (360 minutes) before GitHub stops it. Set timeout-minutes on each job.`,
87 )
88 }
89
90 const size = matrixSize(text)
91 if (size >= 6) warnings.push(`The matrix expands to ${size} jobs per run: each one is billed, rounded up to a whole minute.`)
92
93 const isPrivate = options.isPrivate !== false
94 const runners = all.filter(line => /runs-on:|^\s*-?\s*(os|runner|platform):|^\s*-\s+[\w.-]*(macos|windows)/i.test(line)).join(' ').toLowerCase()
95 if (isPrivate && runners.includes('macos')) {
96 warnings.push('macOS runners cost 10 included minutes per minute in a private repo: keep them to what must run on macOS.')
97 }
98 if (isPrivate && runners.includes('windows')) {
99 warnings.push('Windows runners cost 2 included minutes per minute in a private repo.')
100 }
101
102 const onPullRequest = has(/^\s*pull_request(_target)?:/) || has(/^on:.*\bpull_request\b/)
103 if (onPullRequest && !has(/^\s*concurrency:/)) {
104 warnings.push(
105 'Pull request runs are not cancelled by a newer push: add concurrency: { group: ${{ github.workflow }}-${{ github.ref }}, cancel-in-progress: true }.',
106 )
107 }
108
109 const onPush = has(/^on:\s*\[.*\bpush\b.*\]/) || has(/^on:\s*push\s*$/) || has(/^\s*push:\s*$/)
110 const isPushFiltered = blockUnder(all, /^\s*push:\s*$/)[0]?.body.some(line => /branches|tags|paths/.test(line)) ?? false
111 if (onPullRequest && onPush && !isPushFiltered) {
112 warnings.push('It runs on push to every branch and on pull_request: each push to a PR branch runs it twice. Limit push to the default branch (push: { branches: [main] }).')
113 }
114
115 for (const line of all) {
116 const cron = line.match(/cron:\s*["']([^"']+)["']/)
117 if (!cron) continue
118 const perDay = cronRunsPerDay(cron[1]!)
119 if (perDay >= 24) warnings.push(`The schedule "${cron[1]}" runs ${perDay} times a day, about ${perDay * 30} runs a month.`)
120 }
121
122 const name = all.find(line => /^name:/.test(line))?.replace(/^name:\s*/, '').replace(/^["']|["']$/g, '').trim()
123 const watched = blockUnder(all, /^\s*workflow_run:\s*$/)[0]?.body.join(' ') ?? ''
124 if (name && watched.includes(name)) {
125 warnings.push(`It triggers on workflow_run of "${name}", its own name: every run starts another one.`)
126 }
127
128 return warnings
129}
130hooks/usage.ts 151 lines1// Reading GitHub's answers and turning them into a budget, as pure functions:
2// no `$`, so tests call them directly with recorded API responses.
3
4import type { CiBudgetBudget } from '../types'
5
6export type Remote = { owner: string; name: string }
7
8/** `git@github.com:o/r.git`, `https://github.com/o/r`, `ssh://git@github.com/o/r.git` */
9export function parseRemote(url: string): Remote | undefined {
10 const match = url.trim().match(/github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i)
11 return match ? { owner: match[1]!, name: match[2]! } : undefined
12}
13
14/** `2026-10`, and the first day of that month as GitHub's `created` filter takes it. */
15export function period(now: Date): { label: string; year: number; month: number; since: string } {
16 const year = now.getUTCFullYear()
17 const month = now.getUTCMonth() + 1
18 const label = `${year}-${String(month).padStart(2, '0')}`
19 return { label, year, month, since: `${label}-01` }
20}
21
22/**
23 * Included Actions minutes per month by plan, as GitHub's plans list them;
24 * the `includedMinutes` option overrides it. Larger runners are never included.
25 */
26export const INCLUDED_MINUTES: Record<string, number> = {
27 free: 2_000,
28 pro: 3_000,
29 team: 3_000,
30 business: 50_000,
31 enterprise: 50_000,
32}
33
34/** How many quota minutes one minute on a runner costs. */
35export function multiplier(runner: string): number {
36 const r = runner.toLowerCase()
37 if (r.includes('self-hosted')) return 0
38 if (r.includes('mac')) return 10
39 if (r.includes('windows')) return 2
40 return 1
41}
42
43/** One row of `/settings/billing/usage`. */
44export type UsageItem = {
45 date: string
46 product: string
47 sku: string
48 quantity: number
49 unitType: string
50 grossAmount?: number
51 netAmount?: number
52 repositoryName?: string
53}
54
55/** Standard runners draw on the included minutes; larger runners are billed separately. */
56const STANDARD_SKU = /^actions (linux|windows|macos)$/i
57
58/** This month's Actions minutes from the owner's billing usage. */
59export function fromBilling(items: readonly UsageItem[], periodLabel: string, repoName?: string) {
60 const actions = items.filter(
61 item => item.product === 'actions' && /minute/i.test(item.unitType) && item.date.startsWith(periodLabel),
62 )
63 const minutes: Record<string, number> = {}
64 let quotaMinutes = 0
65 let repoQuotaMinutes = 0
66 let grossUsd = 0
67 let netUsd = 0
68
69 for (const item of actions) {
70 const sku = item.sku.replace(/^Actions /, '')
71 minutes[sku] = (minutes[sku] ?? 0) + item.quantity
72 const quota = STANDARD_SKU.test(item.sku) ? item.quantity * multiplier(item.sku) : 0
73 quotaMinutes += quota
74 if (repoName !== undefined && item.repositoryName === repoName) repoQuotaMinutes += quota
75 grossUsd += item.grossAmount ?? 0
76 netUsd += item.netAmount ?? 0
77 }
78
79 return { minutes, quotaMinutes, repoQuotaMinutes, grossUsd, netUsd }
80}
81
82/** The Actions budget among `/settings/billing/budgets`, or null when there is none. */
83export function actionsBudget(budgets: readonly Record<string, unknown>[]): CiBudgetBudget | null {
84 const budget = budgets.find(b => b.budget_product_sku === 'actions' || b.budget_product_sku === 'actions_minutes')
85 if (!budget) return null
86 return { amount: Number(budget.budget_amount ?? 0), stops: budget.prevent_further_usage === true }
87}
88
89export type Job = { id: number; name: string; labels: readonly string[]; started_at: string | null; completed_at: string | null; status: string }
90
91/**
92 * A finished job's billable minutes: GitHub rounds each job up to the minute.
93 * The runner is read from its labels.
94 */
95export function jobMinutes(job: Job): { runner: string; minutes: number; quotaMinutes: number } {
96 const runner = runnerOf(job.labels)
97 if (!job.started_at || !job.completed_at) return { runner, minutes: 0, quotaMinutes: 0 }
98 const ms = Date.parse(job.completed_at) - Date.parse(job.started_at)
99 const minutes = ms > 0 ? Math.ceil(ms / 60_000) : 0
100 return { runner, minutes, quotaMinutes: minutes * multiplier(runner) }
101}
102
103export function runnerOf(labels: readonly string[]): string {
104 const joined = labels.join(' ').toLowerCase()
105 if (joined.includes('self-hosted')) return 'self-hosted'
106 if (joined.includes('mac')) return 'macOS'
107 if (joined.includes('windows')) return 'Windows'
108 return 'Linux'
109}
110
111/** Sums finished jobs into minutes by runner and quota minutes. */
112export function fromJobs(jobs: readonly Job[]) {
113 const minutes: Record<string, number> = {}
114 let quotaMinutes = 0
115 for (const job of jobs) {
116 const counted = jobMinutes(job)
117 if (counted.minutes === 0) continue
118 minutes[counted.runner] = (minutes[counted.runner] ?? 0) + counted.minutes
119 quotaMinutes += counted.quotaMinutes
120 }
121 return { minutes, quotaMinutes }
122}
123
124/** What to tell the person when a `gh` call was refused, from its stderr. */
125export function setupHint(stderr: string, what: { owner: string; ownerType: 'Organization' | 'User' }): string {
126 const text = stderr.toLowerCase()
127 if (/not logged|gh auth login|authentication required/.test(text)) {
128 return 'GitHub CLI is not logged in: run `gh auth login`.'
129 }
130 if (text.includes('"user" scope')) {
131 return 'Billing of your own account needs the `user` scope: run `gh auth refresh -h github.com -s user`.'
132 }
133 if (text.includes('admin:org') || (what.ownerType === 'Organization' && /404|403|not found|forbidden/.test(text))) {
134 return `Billing of ${what.owner} is visible to its owners and billing managers only; if you are one, run \`gh auth refresh -h github.com -s admin:org\`. Showing an estimate from this repo's jobs instead.`
135 }
136 if (/404|not found/.test(text)) return `No billing data for ${what.owner} (404). Showing an estimate from this repo's jobs instead.`
137 return `gh failed: ${stderr.trim().split('\n')[0]?.slice(0, 160) ?? 'unknown error'}`
138}
139
140/** `1 240`, `12.5k` */
141export function formatMinutes(minutes: number): string {
142 return minutes < 10_000 ? String(Math.round(minutes)) : `${(minutes / 1000).toFixed(1).replace(/\.0$/, '')}k`
143}
144
145/** `1.1 GB`, `340 MB` */
146export function formatBytes(bytes: number): string {
147 if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`
148 if (bytes >= 1e6) return `${Math.round(bytes / 1e6)} MB`
149 return `${Math.round(bytes / 1e3)} KB`
150}
151types/index.d.ts 60 lines1/** Where the numbers came from: GitHub's own billing, or an estimate from the repo's jobs. */
2export type CiBudgetSource = 'billing' | 'estimate' | 'none'
3
4export type CiBudgetBudget = {
5 /** US dollars for the month; 0 means no paid usage at all. */
6 amount: number
7 /** GitHub stops the product once the budget is spent. */
8 stops: boolean
9}
10
11export type CiBudgetSnapshot = {
12 /** The account or organisation that pays for the current repo's runs. */
13 owner: string
14 ownerType: 'Organization' | 'User'
15 /** `owner/name` of the session's repo, when it has a GitHub remote. */
16 repo?: string
17 isPrivate?: boolean
18 /** `2026-10` */
19 period: string
20 source: CiBudgetSource
21 /** Raw minutes this month by runner (`Linux`, `macOS`, a billing SKU). */
22 minutes: Record<string, number>
23 /** Minutes counted against the included quota: Linux 1×, Windows 2×, macOS 10×. */
24 quotaMinutes: number
25 includedMinutes?: number
26 /** quotaMinutes over includedMinutes, 0 to 100+. */
27 percent?: number
28 grossUsd?: number
29 /** What was actually charged, beyond the included quota. */
30 netUsd?: number
31 /** The current repo's quota minutes (billing: from the owner's usage). */
32 repoQuotaMinutes?: number
33 /** The Actions budget: null when the owner has none, absent when it cannot be read. */
34 budget?: CiBudgetBudget | null
35 cacheBytes?: number
36 artifactBytes?: number
37 /** What is missing and how to set it up, one line each. */
38 hints: string[]
39 /** When measured, ms since the epoch. */
40 at: number
41}
42
43export type CiBudgetWatch = {
44 /** What started it: `git push` or `gh workflow run`. */
45 trigger: string
46 runs: { id: number; name: string; status: string; conclusion: string | null; url: string }[]
47 /** Jobs already reported as stuck. */
48 stuck: number[]
49}
50
51declare module 'claude-code' {
52 interface PluginState {
53 'ci-budget': {
54 snapshot: CiBudgetSnapshot | null
55 watch: CiBudgetWatch | null
56 isPaused: boolean
57 }
58 }
59}
60