SLOPSHOPPER

ci-budget

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…

newguardcommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ci-budget
› 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 › /ci-budget ⎿ ci-budget: ? (account) · 2025-10 · no usage data ⎿ ci-budget: gh workflow run is blocked from 100% (billing data only). ⎿ ci-budget: → gh failed: gh answered something that is not JSON ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ci-budget

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:

  • Budget of whoever pays for the current repo's runs (its organisation or personal account): this month's minutes by runner, the share of the plan's included minutes, the cost, the repo's own share, and whether a GitHub budget stops usage at a limit
  • Workflow check: when a .github/workflows/*.yml file is written or edited, the spend risks in it go back to the model and show as a toast
  • Run watch: after git push or gh workflow run, the runs it started are followed; a job running too long is flagged with the command to cancel it
  • Storage: the repo's Actions cache (10 GB limit) and artifacts
  • Block: from blockAt (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.

What you need

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 accessWhat you get
Owner or billing manager of the organisationExact usage from GitHub billing: minutes by runner, cost, % of included minutes, every repo's share, the Actions budget
Your own account with the user scopeThe 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 repoIts runs on GitHub-hosted runners are free; the report says so
No access to the repo's ActionsWorkflow 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.

Setup

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:

  • organisation: Settings → Billing and licensing → Budgets and alerts → New budget
  • personal account: Settings → Billing and licensing → Budgets and alerts → New budget

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.

Desktop notifications

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).

The workflow check

Flags, with how to fix each:

  • jobs without timeout-minutes (a stuck job runs up to 6 hours)
  • a matrix of 6 or more jobs per run
  • macOS (×10) and Windows (×2) runners in a private repo
  • pull request workflows without concurrency + cancel-in-progress
  • push to every branch together with pull_request (each PR push runs twice)
  • schedules that fire 24 or more times a day
  • 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.

Options

OptionDefault
warnAt80Toast when usage crosses each of these percentages of the included minutes
blockAt100Deny gh workflow run from this percentage (billing data only); 0 never blocks
includedMinutes0Included minutes per month; 0 takes them from the plan (Free 2 000, Pro and Team 3 000, Enterprise 50 000)
stuckMinutes30A watched job running longer than this is flagged
refreshMinutes15How often usage is read again
linttrueCheck workflows as they are written
watchtrueFollow 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.

Limits

  • GitHub's billing data is not real time; the run watch reads the runs themselves.
  • Larger runners are always billed and never count toward the included minutes; they show in the minutes and the cost.
  • Only github.com; GitHub Enterprise Server is not supported.
  • A fine-grained token in GH_TOKEN works if it grants the same access; the mod reports whatever it is refused.
  • The estimate counts finished runs of the current month in the current repo, up to 300 runs; a busy repo is filled in over a few refreshes.
Source 4 files
hooks/register.ts 464 lines
1import { 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}
464
hooks/lint.ts 130 lines
1// 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}
130
hooks/usage.ts 151 lines
1// 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}
151
types/index.d.ts 60 lines
1/** 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