SLOPSHOPPER

rules-guard

Blocks pushes to protected branches, plain force pushes and production kubectl/AWS calls. Optional rules: AI attribution, Test plan sections and prose in PR…

newguardprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rules-guard
› 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(rm -rf build && git push --force origin main) ⎿ Denied by rules-guard: rules-guard · protected-push · use-instead Problem: pushing to main directly is not ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

rules-guard

Checks Bash, Write and Edit calls before they run and holds the ones that break a rule. Each block names the rule, the problem, where it was found, the fix, and when to ask the user, so Claude can correct itself and retry.

Always on:

  • No direct push to a protected branch, including --all, --mirror and a bare git push from a protected branch. A bare push whose branch cannot be told is held until the branch is named.
  • No plain force push. --force-with-lease is fine.
  • No kubectl or aws calls aimed at production (a context, cluster or profile with "prod" in its name).

Optional (off by default):

  • noAiAttribution: block AI attribution lines (co-author trailers, session links, "generated with" lines) in commits, PRs, comments and files.
  • noTestPlan: block a "Test plan" section in PR descriptions.
  • prBulletsOnly: PR descriptions must be 20 lines or fewer of short bullet points, with no prose paragraphs.
  • noCodeComments: block new code comments in source files inside a git repo. Tool directives such as @ts- or eslint- lines and shebangs are allowed.
  • noTestFiles: block a git commit that would include test files.
  • noSlackSend: block Claude from sending or scheduling Slack messages. Drafts are allowed.
  • liquibaseAuthor: the only author allowed on Liquibase changesets. Empty turns the check off.
  • vpnProcess: process name that runs while your VPN is up, such as openvpn. When set, kubectl calls are held while it is not running.
  • awsSsoCheck: hold kubectl and aws calls when no AWS SSO session in ~/.aws/sso/cache is fresh.

Other options:

  • protectedBranches: comma-separated branch names. Default main,master.

Install

/plugin marketplace add AbdAlrahmanAlQaroot/claude-code-mods
/plugin install rules-guard@claude-code-mods

Turn on the optional rules from /plugin > rules-guard > options.

What it runs and reads

rules-guard makes no network calls and sends nothing anywhere. It only reads the tool call Claude is about to make, and in some cases:

  • runs git (rev-parse, diff --name-only, add --dry-run) in the repo the call targets, to find the current branch and the files a commit would include;
  • runs kubectl config current-context before a kubectl call, to see whether it points at production;
  • runs pgrep -x <vpnProcess> when vpnProcess is set;
  • lists ~/.aws/sso/cache and reads only the expiresAt field of each file when awsSsoCheck is on. Tokens are never kept or sent.

License

MIT

Source 8 files
hooks/register.ts 195 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { heldBlock } from './block'
4import { familyOf } from './comments'
5import type { CommitFiles } from './testfiles'
6import { bashVerdict, fileVerdict, isChangelogFile, isSlackSend, looksRisky, slackBlock } from './rules'
7import type { Io, Rules } from './rules'
8
9const CONTEXT_FRESH_MS = 30_000
10
11const INTERACTIVE_ADD = new Set(['-p', '-i', '-e', '--patch', '--interactive', '--edit'])
12
13const lines = (text: string) => text.split('\n').map(line => line.trim()).filter(Boolean)
14
15let contextCache: { value: string | undefined; at: number } | undefined
16
17const repoCache = new Map<string, boolean>()
18
19type EnvChecks = { vpnProcess: string; awsSso: boolean }
20
21let envChecks: EnvChecks = { vpnProcess: '', awsSso: false }
22
23async function hostIo($: EngineInterface): Promise<Io> {
24  const home = (await $.env.get('HOME')) ?? ''
25  const cwd = await $.session.cwd()
26  return {
27    home,
28    cwd,
29    read: async path => {
30      try {
31        return await $.fs.read(path)
32      } catch {
33        return undefined
34      }
35    },
36    branch: async dir => {
37      try {
38        const { exitCode, stdout } = await $.process.run(['git', '-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 5000 })
39        return exitCode === 0 ? stdout.trim() : undefined
40      } catch {
41        return undefined
42      }
43    },
44    commitFiles: async (dir, adds, scope) => {
45      try {
46        const toplevel = async (at: string) => {
47          const { exitCode, stdout } = await $.process.run(['git', '-C', at, 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
48          return exitCode === 0 ? stdout.trim() : undefined
49        }
50        const root = await toplevel(dir)
51        if (root === undefined) return undefined
52        const paths = new Set<string>()
53        const staged = await $.process.run(['git', '-C', root, 'diff', '--cached', '--name-only', '--diff-filter=ACMR'], { timeoutMs: 5000 })
54        for (const path of lines(staged.stdout)) paths.add(path)
55        for (const add of adds) {
56          if ((await toplevel(add.dir)) !== root) continue
57          const named = add.args.filter(arg => !arg.startsWith('-'))
58          if (add.args.some(arg => INTERACTIVE_ADD.has(arg))) {
59            for (const path of named) paths.add(path)
60            continue
61          }
62          const dry = await $.process.run(['git', '-C', add.dir, 'add', '--dry-run', ...add.args], { timeoutMs: 10_000 })
63          const added = dry.exitCode === 0 ? lines(dry.stdout).map(line => /^add '(.*)'$/.exec(line)?.[1]).filter(path => path !== undefined) : named
64          for (const path of added) paths.add(path)
65        }
66        if (scope.all) {
67          const tracked = await $.process.run(['git', '-C', root, 'diff', '--name-only', '--diff-filter=ACMR'], { timeoutMs: 5000 })
68          for (const path of lines(tracked.stdout)) paths.add(path)
69        }
70        if (scope.paths.length > 0) {
71          const named = await $.process.run(['git', '-C', dir, 'diff', 'HEAD', '--name-only', '--diff-filter=ACMR', '--', ...scope.paths], { timeoutMs: 5000 })
72          for (const path of lines(named.stdout)) paths.add(path)
73        }
74        const files: CommitFiles = { root, paths: [...paths] }
75        return files
76      } catch {
77        return undefined
78      }
79    },
80    vpnUp: async () => {
81      if (envChecks.vpnProcess === '') return true
82      try {
83        const { exitCode } = await $.process.run(['pgrep', '-x', envChecks.vpnProcess], { timeoutMs: 3000 })
84        return exitCode === 0
85      } catch {
86        return true
87      }
88    },
89    kubeContext: async () => {
90      const now = await $.clock.now()
91      if (contextCache !== undefined && now - contextCache.at < CONTEXT_FRESH_MS) return contextCache.value
92      try {
93        const { exitCode, stdout } = await $.process.run(['kubectl', 'config', 'current-context'], { timeoutMs: 3000 })
94        contextCache = { value: exitCode === 0 ? stdout.trim() : undefined, at: now }
95      } catch {
96        contextCache = { value: undefined, at: now }
97      }
98      return contextCache.value
99    },
100    ssoAgeMs: async () => {
101      if (!envChecks.awsSso) return 0
102      const folder = `${home}/.aws/sso/cache`
103      const entries = await $.fs.list(folder).catch(() => [])
104      let newest: number | undefined
105      for (const entry of entries) {
106        if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
107        const token = parseToken(await $.fs.read(`${folder}/${entry.name}`).catch(() => ''))
108        const expiresAt = token === undefined ? Number.NaN : Date.parse(token)
109        if (Number.isFinite(expiresAt) && (newest === undefined || expiresAt > newest)) newest = expiresAt
110      }
111      return newest === undefined ? undefined : (await $.clock.now()) - newest
112    },
113  }
114}
115
116function parseToken(text: string): string | undefined {
117  try {
118    const token = JSON.parse(text) as { accessToken?: unknown; expiresAt?: unknown }
119    return typeof token.accessToken === 'string' && typeof token.expiresAt === 'string' ? token.expiresAt : undefined
120  } catch {
121    return undefined
122  }
123}
124
125async function insideRepo($: EngineInterface, path: string): Promise<boolean> {
126  if (familyOf(path) === undefined) return false
127  let dir = path.slice(0, path.lastIndexOf('/')) || '/'
128  const known = repoCache.get(dir)
129  if (known !== undefined) return known
130  const asked = dir
131  try {
132    while (dir !== '/' && !(await $.fs.exists(dir))) dir = dir.slice(0, dir.lastIndexOf('/')) || '/'
133    const { exitCode, stdout } = await $.process.run(['git', '-C', dir, 'rev-parse', '--is-inside-work-tree'], { timeoutMs: 3000 })
134    const isInside = exitCode === 0 && stdout.trim() === 'true'
135    repoCache.set(asked, isInside)
136    return isInside
137  } catch {
138    return false
139  }
140}
141
142async function existing($: EngineInterface, path: string): Promise<string | undefined> {
143  if (familyOf(path) === undefined) return undefined
144  try {
145    return await $.fs.read(path)
146  } catch {
147    return undefined
148  }
149}
150
151export const register: Register = (on, options) => {
152  const rules: Rules = {
153    protectedBranches: String(options.protectedBranches ?? 'main,master')
154      .split(',')
155      .map(name => name.trim())
156      .filter(name => name.length > 0),
157    author: String(options.liquibaseAuthor ?? '').trim(),
158    noCodeComments: options.noCodeComments === true,
159    noTestFiles: options.noTestFiles === true,
160    prBulletsOnly: options.prBulletsOnly === true,
161    noTestPlan: options.noTestPlan === true,
162    noAiAttribution: options.noAiAttribution === true,
163  }
164  const noSlackSend = options.noSlackSend === true
165  envChecks = {
166    vpnProcess: String(options.vpnProcess ?? '').trim(),
167    awsSso: options.awsSsoCheck === true,
168  }
169
170  if (noSlackSend) {
171    on('tool.call', ($, e, next) => (isSlackSend(String(e.tool)) ? { deny: slackBlock(String(e.tool)) } : next(e))).catch(($, e, next) =>
172      next.called ? next(e) : isSlackSend(String(e.tool)) ? { deny: slackBlock(String(e.tool)) } : next(e),
173    )
174  }
175
176  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
177    const verdict = await bashVerdict(e.command, rules, await hostIo($))
178    return verdict ? { deny: verdict } : next(e)
179  }).catch(($, e, next) => (next.called ? next(e) : looksRisky(e.command) ? { deny: heldBlock(e.command) } : next(e)))
180
181  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
182    const before = await existing($, e.file_path)
183    const verdict = fileVerdict(e.file_path, e.content, rules, { before, isRepoCode: rules.noCodeComments && (await insideRepo($, e.file_path)), startLine: 1 })
184    return verdict ? { deny: verdict } : next(e)
185  }).catch(($, e, next) => (next.called ? next(e) : rules.author !== '' && isChangelogFile(e.file_path) ? { deny: heldBlock(e.file_path) } : next(e)))
186
187  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
188    const current = await existing($, e.file_path)
189    const at = current === undefined ? -1 : current.indexOf(e.old_string)
190    const startLine = current === undefined || at < 0 ? undefined : current.slice(0, at).split('\n').length
191    const verdict = fileVerdict(e.file_path, e.new_string, rules, { before: e.old_string, isRepoCode: rules.noCodeComments && (await insideRepo($, e.file_path)), startLine })
192    return verdict ? { deny: verdict } : next(e)
193  }).catch(($, e, next) => (next.called ? next(e) : rules.author !== '' && isChangelogFile(e.file_path) ? { deny: heldBlock(e.file_path) } : next(e)))
194}
195
hooks/block.ts 42 lines
1export type Category = 'fix-and-retry' | 'use-instead' | 'needs-user' | 'env-setup' | 'held'
2
3export type Block = {
4  rule: string
5  category: Category
6  problem: string
7  where?: string | readonly string[]
8  fix: string | readonly string[]
9  ask?: string
10}
11
12export function block({ rule, category, problem, where, fix, ask }: Block): string {
13  const lines = [`rules-guard · ${rule} · ${category}`, `Problem: ${problem}`]
14  if (typeof where === 'string') lines.push(`Where: ${where}`)
15  else if (where !== undefined && where.length === 1) lines.push(`Where: ${where[0] ?? ''}`)
16  else if (where !== undefined && where.length > 1) lines.push('Where:', ...where.map(item => `- ${item}`))
17  const [first = '', ...rest] = typeof fix === 'string' ? [fix] : fix
18  lines.push(`Fix: ${first}`, ...rest)
19  if (ask !== undefined) lines.push(`Ask the user: ${ask}`)
20  return lines.join('\n')
21}
22
23export function clip(text: string, max = 120): string {
24  const flat = text.replace(/\s+/g, ' ').trim()
25  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
26}
27
28export function tilde(path: string, home: string): string {
29  return home !== '' && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path
30}
31
32export function heldBlock(where: string): string {
33  return block({
34    rule: 'guard-error',
35    category: 'held',
36    problem: 'rules-guard failed while checking this, so it was held.',
37    where: clip(where),
38    fix: 'retry once.',
39    ask: 'when it is held a second time.',
40  })
41}
42
hooks/comments.ts 60 lines
1type Family = 'slash' | 'hash' | 'dash'
2
3const FAMILIES: Record<string, Family> = {
4  kt: 'slash', kts: 'slash', java: 'slash', ts: 'slash', tsx: 'slash', js: 'slash', jsx: 'slash', mjs: 'slash',
5  cjs: 'slash', go: 'slash', rs: 'slash', swift: 'slash', gradle: 'slash',
6  py: 'hash', sh: 'hash',
7  sql: 'dash',
8}
9
10const DIRECTIVE =
11  /^\/\/\/\s*<reference|@ts-|eslint-|prettier-|oxlint-|biome-ignore|noqa|type:\s*ignore|pragma|ktlint-disable|detekt|@formatter:|noinspection|istanbul|c8 ignore|go:(build|generate|embed)|swiftlint:|fmt:|pylint:|mypy:|-\*-\s*coding|^#!|^--\s*(liquibase|changeset|rollback|precondition|validchecksum|comment:|property|include)/i
12
13const STRINGS = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g
14
15export function familyOf(path: string): Family | undefined {
16  const extension = /\.([A-Za-z0-9]+)$/.exec(path)?.[1]?.toLowerCase()
17  return extension === undefined ? undefined : FAMILIES[extension]
18}
19
20function trailingSlash(line: string): boolean {
21  return /\s\/\/(\s|$)/.test(line.replace(STRINGS, '""'))
22}
23
24export type Comment = { line: number; text: string }
25
26export function addedComments(path: string, text: string, before: string | undefined, isRepoCode: boolean): Comment[] {
27  if (!isRepoCode || path.includes('/.claude/')) return []
28  const family = familyOf(path)
29  if (family === undefined) return []
30  const kept = new Set((before ?? '').split('\n').map(line => line.trim()).filter(line => line.length > 0))
31  const found: Comment[] = []
32  let inBlock = false
33  let blockShown = false
34  for (const [index, raw] of text.split('\n').entries()) {
35    const line = raw.trim()
36    if (line.length === 0) continue
37    const added = !DIRECTIVE.test(line) && !kept.has(line)
38    if (inBlock) {
39      if (line.includes('*/')) inBlock = false
40      if (added && !blockShown) found.push({ line: index + 1, text: line })
41      blockShown ||= added
42      continue
43    }
44    let isComment: boolean
45    if (family !== 'hash' && (line.startsWith('/*') || line.startsWith('{/*'))) {
46      isComment = true
47      inBlock = !line.includes('*/')
48      blockShown = added
49    } else if (family === 'slash') {
50      isComment = line.startsWith('//') || trailingSlash(line)
51    } else if (family === 'hash') {
52      isComment = line.startsWith('#') && !line.startsWith('#!')
53    } else {
54      isComment = line.startsWith('--')
55    }
56    if (isComment && added) found.push({ line: index + 1, text: line })
57  }
58  return found
59}
60
hooks/testfiles.ts 67 lines
1import { block } from './block'
2
3export type GitAdd = { dir: string; args: string[] }
4
5export type CommitScope = { all: boolean; paths: string[] }
6
7export type CommitFiles = { root: string; paths: string[] }
8
9const TEST_DIR = /(^|\/)(src\/(test|androidTest|testFixtures|integrationTest)\/|__tests__\/|__mocks__\/|tests?\/|e2e\/|cypress\/|playwright\/)/
10
11const TEST_NAME = /\.(test|spec|e2e)\.[cm]?[jt]sx?$|Tests?\.(kt|kts|java)$|_test\.(go|py)$|(^|\/)test_[^/]*\.py$/
12
13const COMMIT_VALUE = new Set([
14  '--message', '--file', '--reuse-message', '--reedit-message', '--template', '--author', '--date', '--fixup', '--squash', '--trailer', '--cleanup',
15])
16
17const MAX_LISTED = 10
18
19export function isTestPath(path: string): boolean {
20  return TEST_DIR.test(path) || (TEST_NAME.test(path) && !/(^|\/)src\/main\//.test(path))
21}
22
23export function commitScope(args: readonly string[]): CommitScope {
24  let all = false
25  let rest = false
26  const paths: string[] = []
27  for (let i = 0; i < args.length; i++) {
28    const arg = args[i] ?? ''
29    if (rest) {
30      paths.push(arg)
31    } else if (arg === '--') {
32      rest = true
33    } else if (arg === '--all') {
34      all = true
35    } else if (arg.startsWith('--')) {
36      if (COMMIT_VALUE.has(arg)) i++
37    } else if (arg.startsWith('-') && arg.length > 1) {
38      for (let j = 1; j < arg.length; j++) {
39        const flag = arg[j] ?? ''
40        if (flag === 'a') all = true
41        if ('mFCct'.includes(flag)) {
42          if (j === arg.length - 1) i++
43          break
44        }
45      }
46    } else {
47      paths.push(arg)
48    }
49  }
50  return { all, paths }
51}
52
53export function testFilesVerdict(files: CommitFiles | undefined): string | undefined {
54  const tests = (files?.paths ?? []).filter(isTestPath)
55  if (files === undefined || tests.length === 0) return undefined
56  const where = tests.slice(0, MAX_LISTED)
57  if (tests.length > MAX_LISTED) where.push(`and ${tests.length - MAX_LISTED} more`)
58  return block({
59    rule: 'test-files',
60    category: 'fix-and-retry',
61    problem: `this commit in ${files.root} would include test files; tests stay local and never go into any repo (user rule).`,
62    where,
63    fix: 'leave them out: stage only the files you mean (git add <paths>, not -A or .), or unstage them with git restore --staged <file>. Keep the tests untracked or outside the repo, then commit again.',
64    ask: 'when a test file really has to be committed (for example a reviewer requires coverage); that is decided case by case.',
65  })
66}
67
hooks/rules.ts 421 lines
1import { block, clip, tilde } from './block'
2import { addedComments } from './comments'
3import { envVerdict } from './env'
4import type { EnvIo } from './env'
5import { proseVerdict } from './prose'
6import { heredocs, optionValue, resolvePath, tokenize, words } from './shell'
7import { commitScope, testFilesVerdict } from './testfiles'
8import type { CommitFiles, CommitScope, GitAdd } from './testfiles'
9
10export { resolvePath, tokenize } from './shell'
11
12export type Rules = {
13  protectedBranches: readonly string[]
14  author: string
15  noCodeComments: boolean
16  noTestFiles: boolean
17  prBulletsOnly: boolean
18  noTestPlan: boolean
19  noAiAttribution: boolean
20}
21
22export type Io = EnvIo & {
23  home: string
24  cwd: string
25  read: (path: string) => Promise<string | undefined>
26  branch: (dir: string) => Promise<string | undefined>
27  commitFiles: (dir: string, adds: readonly GitAdd[], scope: CommitScope) => Promise<CommitFiles | undefined>
28}
29
30type Segment = { tokens: string[]; dir: string }
31
32const ATTRIBUTION: readonly RegExp[] = [
33  /(^|\\n|["'])[ \t]*co-authored-by:[^\n<]*(claude|anthropic)[^\n<]*<[^>\n]+>/im,
34  /(^|\\n|["'])[ \t]*claude-session:[ \t]*https?:\/\//im,
35  /generated (with|by) (\[claude code\]\(|claude code\b)/i,
36]
37
38const TEST_PLAN = /(^|\\n)[ \t]*(#{1,6}[ \t]*|\*\*)?test(ing)?[ \t]+plan\b/im
39
40const AUTHORS: readonly RegExp[] = [
41  /<changeSet\b[^>]*?\bauthor\s*=\s*["']([^"']*)["']/gi,
42  /^\s*-?\s*author\s*:\s*["']?([^"'\s#]+)/gim,
43  /^\s*--\s*changeset\s+([^:\s]+)\s*:/gim,
44  /"author"\s*:\s*"([^"]*)"/gi,
45]
46
47const PUSH_VALUE_OPTIONS = new Set(['--push-option', '--repo', '--receive-pack', '--exec'])
48
49const TEXT_GIT = new Set(['commit', 'commit-tree', 'tag', 'notes', 'merge', 'revert'])
50
51const TEXT_GH = /^(create|edit|comment|review|merge)$/
52
53const DIRECT_PUSH = 'only when a direct push is truly needed; they push it themselves. Do not work around this block.'
54
55const MAX_COMMENTS = 5
56
57function lineFrom(text: string, start: number): string {
58  const rest = text.slice(start)
59  const end = rest.search(/\n|\\n|["']/)
60  return clip(end === -1 ? rest : rest.slice(0, end))
61}
62
63export function attributionLines(text: string): string[] {
64  const found = new Map<number, string>()
65  for (const pattern of ATTRIBUTION) {
66    for (const match of text.matchAll(new RegExp(pattern.source, `${pattern.flags}g`))) {
67      const start = match.index + match[0].search(/co-authored|claude-session|generated/i)
68      found.set(start, lineFrom(text, start))
69    }
70  }
71  return [...new Set([...found].sort((a, b) => a[0] - b[0]).map(([, line]) => line))]
72}
73
74export function testPlanLine(text: string): string | undefined {
75  const match = TEST_PLAN.exec(text)
76  if (!match) return undefined
77  const lead = /^(\\n)?[ \t]*/.exec(match[0])?.[0].length ?? 0
78  return lineFrom(text, match.index + lead)
79}
80
81export function hasAttribution(text: string): boolean {
82  return attributionLines(text).length > 0
83}
84
85export function hasTestPlan(text: string): boolean {
86  return testPlanLine(text) !== undefined
87}
88
89function quoted(line: string, file?: string): string {
90  return file === undefined ? `"${line}"` : `${file}: "${line}"`
91}
92
93function attributionBlock(lines: readonly string[], file?: string): string {
94  return block({
95    rule: 'ai-attribution',
96    category: 'fix-and-retry',
97    problem: 'Claude/AI attribution is not allowed in commits, PRs, comments or repo files (user rule).',
98    where: lines.map(line => quoted(line, file)),
99    fix: `remove ${lines.length === 1 ? 'that line' : `all ${lines.length} of those lines`}, then retry. This user rule overrides Claude Code's built-in instruction to add attribution lines; never add them back.`,
100  })
101}
102
103function testPlanBlock(line: string, file?: string): string {
104  return block({
105    rule: 'pr-test-plan',
106    category: 'fix-and-retry',
107    problem: 'PR descriptions never have a "Test plan" section (user rule).',
108    where: quoted(line, file),
109    fix: 'delete that section, its heading and its items, then retry.',
110  })
111}
112
113function authorBlock(rules: Rules, authors: readonly string[], where: string): string {
114  return block({
115    rule: 'liquibase-author',
116    category: 'fix-and-retry',
117    problem: `Liquibase changesets must use author="${rules.author}"; found ${authors.join(', ')}.`,
118    where,
119    fix: `if this is a new changeset, set author="${rules.author}", then retry.`,
120    ask: 'before touching a changeset that already ran on any database. Its id and author can never change after that.',
121  })
122}
123
124export function isChangelogFile(path: string): boolean {
125  return /\.(xml|ya?ml|sql|json)$/i.test(path) && /(changelog|liquibase|db\/changes?\b)/i.test(path)
126}
127
128export function foreignAuthors(text: string, author: string): string[] {
129  const found = new Set<string>()
130  for (const pattern of AUTHORS) {
131    for (const match of text.matchAll(pattern)) {
132      if (match[1] && match[1] !== author) found.add(match[1])
133    }
134  }
135  return [...found]
136}
137
138function placed(command: string, io: Io): Segment[] {
139  let dir = io.cwd
140  const placedSegments: Segment[] = []
141  for (const tokens of tokenize(command)) {
142    if ((tokens[0] === 'cd' || tokens[0] === 'pushd') && tokens[1] !== '-') {
143      dir = resolvePath(dir, tokens[1] ?? io.home, io.home)
144    }
145    placedSegments.push({ tokens, dir })
146  }
147  return placedSegments
148}
149
150function git(segment: Segment, io: Io): { sub: string; args: string[]; dir: string } | undefined {
151  const parts = words(segment.tokens)
152  if (parts[0] !== 'git' && !parts[0]?.endsWith('/git')) return undefined
153  let dir = segment.dir
154  let i = 1
155  while (parts[i]?.startsWith('-')) {
156    const word = parts[i]
157    if (word === '-C') {
158      dir = resolvePath(dir, parts[i + 1] ?? '.', io.home)
159      i += 2
160    } else if (word === '-c' || word === '--git-dir' || word === '--work-tree' || word === '--namespace') {
161      i += 2
162    } else {
163      i++
164    }
165  }
166  const sub = parts[i]
167  if (sub === undefined) return undefined
168  return { sub, args: parts.slice(i + 1), dir }
169}
170
171function gh(segment: Segment): { area: string; action: string; args: string[] } | undefined {
172  const parts = words(segment.tokens)
173  if (parts[0] !== 'gh' && !parts[0]?.endsWith('/gh')) return undefined
174  return { area: parts[1] ?? '', action: parts[2] ?? '', args: parts.slice(3) }
175}
176
177function listed(branches: readonly string[], branch: string): boolean {
178  return branches.includes(branch)
179}
180
181async function pushVerdict(args: string[], dir: string, rules: Rules, io: Io): Promise<string | undefined> {
182  let force = false
183  let lease = false
184  let everything = false
185  const positional: string[] = []
186  for (let i = 0; i < args.length; i++) {
187    const arg = args[i] ?? ''
188    if (arg === '--') continue
189    if (arg.startsWith('--')) {
190      if (arg === '--force') force = true
191      else if (arg.startsWith('--force-with-lease') || arg === '--force-if-includes') lease = true
192      else if (arg === '--all' || arg === '--mirror' || arg === '--branches') everything = true
193      else if (PUSH_VALUE_OPTIONS.has(arg)) i++
194      continue
195    }
196    if (arg.startsWith('-') && arg.length > 1) {
197      if (arg === '-o') i++
198      else if (/^-[a-zA-Z]+$/.test(arg) && arg.includes('f')) force = true
199      continue
200    }
201    positional.push(arg)
202  }
203  const refspecs = positional.slice(1)
204  if (refspecs.some(refspec => refspec.startsWith('+'))) force = true
205  const where = `${clip(['git push', ...args].join(' '))} (in ${tilde(dir, io.home)})`
206  if (everything) {
207    return block({
208      rule: 'protected-push',
209      category: 'use-instead',
210      problem: `--all/--mirror would also push ${rules.protectedBranches.join(', ')} directly (user rule: branch + PR only).`,
211      where,
212      fix: 'push only your feature branch (git push -u origin <feature-branch>) and open a PR.',
213      ask: DIRECT_PUSH,
214    })
215  }
216  const destinations = refspecs.map(refspec => {
217    const plain = refspec.replace(/^\+/, '')
218    const destination = plain.includes(':') ? plain.slice(plain.indexOf(':') + 1) : plain
219    return destination.replace(/^refs\/heads\//, '')
220  })
221  const needsCurrent = destinations.length === 0 || destinations.some(d => d === 'HEAD' || d === '@' || d === '')
222  const explicit = destinations.filter(d => d !== 'HEAD' && d !== '@' && d !== '')
223  const hit = explicit.find(d => listed(rules.protectedBranches, d))
224  if (hit) {
225    return block({
226      rule: 'protected-push',
227      category: 'use-instead',
228      problem: `pushing to ${hit} directly is not allowed (user rule: branch + PR only).`,
229      where,
230      fix: `push a feature branch (git push -u origin <feature-branch>) and open a PR into ${hit}.`,
231      ask: DIRECT_PUSH,
232    })
233  }
234  if (needsCurrent) {
235    const current = await io.branch(dir)
236    if (current === undefined || current === 'HEAD') {
237      return block({
238        rule: 'push-branch-unknown',
239        category: 'fix-and-retry',
240        problem: 'could not tell which branch this push comes from.',
241        where,
242        fix: 'name the branch: git push -u origin <branch>, then retry.',
243      })
244    }
245    if (listed(rules.protectedBranches, current)) {
246      return block({
247        rule: 'protected-push',
248        category: 'use-instead',
249        problem: `you are on ${current}, so this push would update ${current} directly (user rule: branch + PR only).`,
250        where,
251        fix: `move the work to a feature branch (git switch -c <feature-branch>), push that, and open a PR into ${current}.`,
252        ask: DIRECT_PUSH,
253      })
254    }
255  }
256  if (force && !lease) {
257    return block({
258      rule: 'force-push',
259      category: 'use-instead',
260      problem: 'a plain force push can overwrite commits someone else pushed.',
261      where,
262      fix: 'use git push --force-with-lease origin <your-feature-branch>.',
263    })
264  }
265  return undefined
266}
267
268function fileOptions(args: string[], names: readonly string[], dir: string, io: Io): { arg: string; path: string }[] {
269  const files: string[] = []
270  for (let i = 0; i < args.length; i++) {
271    const arg = args[i] ?? ''
272    const following = args[i + 1]
273    for (const name of names) {
274      if (arg === name && following !== undefined) files.push(following)
275      else if (name.startsWith('--') && arg.startsWith(`${name}=`)) files.push(arg.slice(name.length + 1))
276    }
277  }
278  return files.filter(file => file !== '-').map(arg => ({ arg, path: resolvePath(dir, arg, io.home) }))
279}
280
281async function fileText(file: { arg: string; path: string }, raw: string, io: Io): Promise<string | undefined> {
282  if (raw.split(file.arg).length > 2) return undefined
283  return io.read(file.path)
284}
285
286async function prBodies(args: string[], dir: string, raw: string, io: Io): Promise<string[]> {
287  const bodies: string[] = []
288  const documents = heredocs(raw)
289  const inline = optionValue(args, ['--body', '-b'])
290  if (inline !== undefined) {
291    const document = inline.includes('<<') ? (documents.find(doc => /--body|-b\b/.test(doc.opener)) ?? documents[0]) : undefined
292    bodies.push(document === undefined ? inline : document.body)
293  }
294  for (const file of fileOptions(args, ['--body-file', '-F'], dir, io)) {
295    const written = documents.find(doc => doc.opener.includes(file.arg))
296    const text = written === undefined ? await fileText(file, raw, io) : written.body
297    if (text !== undefined) bodies.push(text)
298  }
299  return bodies
300}
301
302export async function bashVerdict(raw: string, rules: Rules, io: Io): Promise<string | undefined> {
303  if (!/\bgit\b|\bgh\b|changeset|\bcp\b|\bkubectl\b|\baws\b/i.test(raw)) return undefined
304  const segments = placed(raw, io)
305  const environment = await envVerdict(segments.map(segment => segment.tokens), io)
306  if (environment) return environment
307  let carriesText = false
308  let createsPr = false
309  let prose: string | undefined
310  const checkedCommits = new Set<string>()
311  const adds: GitAdd[] = []
312  for (const segment of segments) {
313    const gitCall = git(segment, io)
314    if (gitCall?.sub === 'push') {
315      const verdict = await pushVerdict(gitCall.args, gitCall.dir, rules, io)
316      if (verdict) return verdict
317    }
318    if (gitCall?.sub === 'add') adds.push({ dir: gitCall.dir, args: gitCall.args })
319    if (rules.noTestFiles && gitCall?.sub === 'commit' && !checkedCommits.has(gitCall.dir)) {
320      checkedCommits.add(gitCall.dir)
321      const tests = testFilesVerdict(await io.commitFiles(gitCall.dir, adds, commitScope(gitCall.args)))
322      if (tests) return tests
323    }
324    const ghCall = gh(segment)
325    const isGitText = gitCall !== undefined && TEXT_GIT.has(gitCall.sub)
326    const isGhText = ghCall !== undefined && (ghCall.area === 'api' || (/^(pr|issue|release)$/.test(ghCall.area) && TEXT_GH.test(ghCall.action)))
327    const isPrBody = ghCall !== undefined && ghCall.area === 'pr' && /^(create|edit)$/.test(ghCall.action)
328    if (isGitText || isGhText) carriesText = true
329    if (isPrBody) createsPr = true
330    if (rules.prBulletsOnly && isPrBody && ghCall && prose === undefined && optionValue(ghCall.args, ['--title', '-t']) !== 'RELEASE') {
331      for (const body of await prBodies(ghCall.args, segment.dir, raw, io)) prose ??= proseVerdict(body)
332    }
333    const files =
334      isGitText && gitCall ? fileOptions(gitCall.args, ['-F', '--file', '-t', '--template'], gitCall.dir, io)
335      : isGhText && ghCall && ghCall.area !== 'api' ? fileOptions(ghCall.args, ['--body-file', '-F'], segment.dir, io)
336      : []
337    for (const file of files) {
338      const text = await fileText(file, raw, io)
339      if (text === undefined) continue
340      const attribution = rules.noAiAttribution ? attributionLines(text) : []
341      if (attribution.length > 0) return attributionBlock(attribution, file.arg)
342      const testPlan = rules.noTestPlan && isPrBody ? testPlanLine(text) : undefined
343      if (testPlan !== undefined) return testPlanBlock(testPlan, file.arg)
344    }
345    const parts = words(segment.tokens)
346    if (parts[0] === 'cp') {
347      const paths = parts.slice(1).filter(word => !word.startsWith('-'))
348      const target = paths.at(-1)
349      const sources = paths.slice(0, -1)
350      if (rules.author !== '' && target && (sources.some(isChangelogFile) || isChangelogFile(target))) {
351        for (const source of sources) {
352          const text = await io.read(resolvePath(segment.dir, source, io.home))
353          const authors = text === undefined ? [] : foreignAuthors(text, rules.author)
354          if (authors.length > 0) {
355            return block({
356              rule: 'liquibase-author',
357              category: 'fix-and-retry',
358              problem: `copying ${source} brings in changeset author ${authors.join(', ')}; changesets must use author="${rules.author}".`,
359              where: clip(parts.join(' ')),
360              fix: `write a new changeset instead, with author="${rules.author}", a new id and a new filename, then retry.`,
361            })
362          }
363        }
364      }
365    }
366  }
367  const attribution = rules.noAiAttribution && carriesText ? attributionLines(raw) : []
368  if (attribution.length > 0) return attributionBlock(attribution)
369  const testPlan = rules.noTestPlan && createsPr ? testPlanLine(raw) : undefined
370  if (testPlan !== undefined) return testPlanBlock(testPlan)
371  if (rules.author !== '' && /changeSet|--\s*changeset/i.test(raw)) {
372    const authors = foreignAuthors(raw, rules.author)
373    if (authors.length > 0) return authorBlock(rules, authors, 'the changeset text in this command')
374  }
375  return prose
376}
377
378export type FileChange = { before?: string; isRepoCode?: boolean; startLine?: number }
379
380export function fileVerdict(path: string, text: string, rules: Rules, { before, isRepoCode = false, startLine }: FileChange = {}): string | undefined {
381  const attribution = !rules.noAiAttribution || path.includes('/.claude/') ? [] : attributionLines(text)
382  if (attribution.length > 0) return attributionBlock(attribution, path)
383  if (rules.author !== '' && isChangelogFile(path)) {
384    const authors = foreignAuthors(text, rules.author)
385    if (authors.length > 0) return authorBlock(rules, authors, path)
386  }
387  const comments = rules.noCodeComments ? addedComments(path, text, before, isRepoCode) : []
388  if (comments.length === 0) return undefined
389  const where = comments
390    .slice(0, MAX_COMMENTS)
391    .map(comment => `${startLine === undefined ? path : `${path}:${startLine + comment.line - 1}`} — ${clip(comment.text, 100)}`)
392  if (comments.length > MAX_COMMENTS) where.push(`and ${comments.length - MAX_COMMENTS} more in the same change`)
393  return block({
394    rule: 'code-comments',
395    category: 'fix-and-retry',
396    problem: "code comments are not allowed in this user's code (user rule).",
397    where,
398    fix: 'remove them and put any reasoning in the commit message or PR description instead, then retry.',
399  })
400}
401
402export function looksRisky(raw: string): boolean {
403  return /\bgit\s[^\n]*\b(push|commit)\b|\bgh\s+(pr|issue|api)\b|changeset/i.test(raw)
404}
405
406const SLACK_SEND = /^mcp__.*slack.*__(slack_)?(send_message|schedule_message|post_message|chat_post_message|reply_to_thread)$/i
407
408export function slackBlock(tool: string): string {
409  return block({
410    rule: 'slack-send',
411    category: 'use-instead',
412    problem: 'Claude never sends Slack messages (user rule).',
413    where: tool,
414    fix: 'give the user the exact text in chat. If they want it in Slack, save it with slack_send_message_draft so it lands as an unsent draft they review and send. Do not send it any other way.',
415  })
416}
417
418export function isSlackSend(tool: string): boolean {
419  return SLACK_SEND.test(tool)
420}
421
hooks/env.ts 120 lines
1import { block, clip } from './block'
2import { optionValue, program, words } from './shell'
3
4export type EnvIo = {
5  vpnUp: () => Promise<boolean>
6  kubeContext: () => Promise<string | undefined>
7  ssoAgeMs: () => Promise<number | undefined>
8}
9
10const PROD = /prod/i
11
12const SSO_STALE_MS = 8 * 3_600_000
13
14const KUBECTL_VALUE_FLAGS = new Set([
15  '--context', '--cluster', '--kubeconfig', '--namespace', '-n', '--user', '-s', '--server', '-o', '--output',
16  '-l', '--selector', '-c', '--container', '-f', '--filename', '--token', '--as', '--request-timeout', '-p',
17  '--sort-by', '--field-selector', '--tail', '--since', '--template',
18])
19
20const KUBECTL_LOCAL = new Set(['config', 'version', 'help', 'completion', 'plugin', 'kustomize'])
21
22const AWS_VALUE_FLAGS = new Set([
23  '--profile', '--region', '--output', '--query', '--endpoint-url', '--cli-read-timeout', '--cli-connect-timeout', '--color',
24])
25
26const AWS_LOCAL = new Set(['sso', 'configure', 'help'])
27
28function prodBlock(command: string, why: string): string {
29  return block({
30    rule: 'prod-access',
31    category: 'use-instead',
32    problem: `this would reach production (${why}); direct kubectl/AWS access to production is blocked.`,
33    where: clip(command),
34    fix: 'for production logs, use your read-only logs tool instead. Do not retry another way.',
35    ask: 'for anything the logs tool cannot show.',
36  })
37}
38
39function vpnBlock(command: string): string {
40  return block({
41    rule: 'vpn-down',
42    category: 'env-setup',
43    problem: 'the VPN is down, so the cluster is unreachable.',
44    where: clip(command),
45    fix: 'do not start the VPN yourself (it needs sudo); retry once the user says it is up.',
46    ask: 'to connect the VPN in their own terminal.',
47  })
48}
49
50function subcommand(args: readonly string[], valueFlags: ReadonlySet<string>): string | undefined {
51  for (let i = 0; i < args.length; i++) {
52    const arg = args[i] ?? ''
53    if (valueFlags.has(arg)) {
54      i++
55      continue
56    }
57    if (!arg.startsWith('-')) return arg
58  }
59  return undefined
60}
61
62function assigned(tokens: readonly string[], name: string): string | undefined {
63  const prefix = `${name}=`
64  const found = tokens.find(token => token.startsWith(prefix))
65  return found === undefined ? undefined : found.slice(prefix.length)
66}
67
68async function ssoVerdict(command: string, io: EnvIo): Promise<string | undefined> {
69  const age = await io.ssoAgeMs()
70  if (age !== undefined && age <= SSO_STALE_MS) return undefined
71  return block({
72    rule: 'aws-sso',
73    category: 'env-setup',
74    problem: age === undefined ? 'no AWS SSO session found.' : `the AWS SSO session looks expired (last refreshed ${Math.round(age / 3_600_000)}h ago).`,
75    where: clip(command),
76    fix: 'retry once the user has logged in.',
77    ask: 'to run `! aws sso login`.',
78  })
79}
80
81export async function envVerdict(segments: readonly string[][], io: EnvIo): Promise<string | undefined> {
82  let exportedProfile: string | undefined
83  for (const tokens of segments) {
84    if (tokens[0] === 'export') {
85      exportedProfile = assigned(tokens.slice(1), 'AWS_PROFILE') ?? exportedProfile
86      continue
87    }
88    const parts = words(tokens)
89    const name = program(parts[0])
90    if (name !== 'kubectl' && name !== 'aws') continue
91    const args = parts.slice(1)
92    const command = tokens.join(' ')
93    const profile = assigned(tokens.slice(0, tokens.length - parts.length), 'AWS_PROFILE') ?? exportedProfile
94    if (profile !== undefined && PROD.test(profile)) return prodBlock(command, `AWS profile ${profile}`)
95    if (name === 'kubectl') {
96      if (KUBECTL_LOCAL.has(subcommand(args, KUBECTL_VALUE_FLAGS) ?? 'help')) continue
97      const context = optionValue(args, ['--context', '--cluster'])
98      if (context !== undefined && PROD.test(context)) return prodBlock(command, `context ${context}`)
99      if (context === undefined) {
100        const current = await io.kubeContext()
101        if (current !== undefined && PROD.test(current)) return prodBlock(command, `current kube context ${current}`)
102      }
103      if (!(await io.vpnUp())) return vpnBlock(command)
104      const sso = await ssoVerdict(command, io)
105      if (sso) return sso
106      continue
107    }
108    const service = subcommand(args, AWS_VALUE_FLAGS) ?? 'help'
109    if (args.includes('--version') || AWS_LOCAL.has(service) || args.includes('get-caller-identity')) continue
110    const named = optionValue(args, ['--profile'])
111    if (named !== undefined && PROD.test(named)) return prodBlock(command, `AWS profile ${named}`)
112    const cluster = service === 'eks' ? optionValue(args, ['--name', '--cluster-name']) : undefined
113    if (cluster !== undefined && PROD.test(cluster)) return prodBlock(command, `cluster ${cluster}`)
114    if (args.some(arg => /arn:aws:eks:[^\s]*:cluster\/[^\s]*prod/i.test(arg))) return prodBlock(command, 'a prod EKS cluster')
115    const sso = await ssoVerdict(command, io)
116    if (sso) return sso
117  }
118  return undefined
119}
120
hooks/prose.ts 41 lines
1import { block, clip } from './block'
2
3const MAX_LINES = 20
4
5const MAX_PROSE_WORDS = 15
6
7const STRUCTURED = /^([-*+]\s|\d+[.)]\s|#{1,6}\s|https?:\/\/\S+$|([\w.-]+\/[\w.-]+)?#\d+\b|<!--|\|)/
8
9export function proseVerdict(body: string): string | undefined {
10  const lines: string[] = []
11  let inFence = false
12  for (const raw of body.replaceAll('\\n', '\n').split('\n')) {
13    const line = raw.trim()
14    if (line.startsWith('```')) {
15      inFence = !inFence
16      continue
17    }
18    if (inFence || line.length === 0) continue
19    lines.push(line)
20  }
21  if (lines.length > MAX_LINES) {
22    return block({
23      rule: 'pr-format',
24      category: 'fix-and-retry',
25      problem: `this PR description has ${lines.length} lines; the limit is ${MAX_LINES} (user rule: short and to the point).`,
26      fix: `cut it to ${MAX_LINES} or fewer short "- " bullets saying what changed and why, then retry.`,
27    })
28  }
29  const prose = lines.filter(line => !STRUCTURED.test(line) && line.split(/\s+/).length > MAX_PROSE_WORDS)
30  if (prose.length > 0) {
31    return block({
32      rule: 'pr-format',
33      category: 'fix-and-retry',
34      problem: 'PR descriptions are short bullet points only, no prose paragraphs (user rule).',
35      where: prose.map(line => `"${clip(line, 100)}"`),
36      fix: `turn ${prose.length === 1 ? 'that line' : `each of those ${prose.length} lines`} into a short "- " bullet or cut it, then retry.`,
37    })
38  }
39  return undefined
40}
41
hooks/shell.ts 118 lines
1const HEREDOC = /<<-?[ \t]*(['"]?)([A-Za-z_][\w.-]*)\1([^\n]*)\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g
2
3const HEREDOC_BODY = /([^\n]*)<<-?[ \t]*(['"]?)([A-Za-z_][\w.-]*)\2([^\n]*)\n([\s\S]*?)\n[ \t]*\3[ \t]*(?=\n|$)/g
4
5const WRAPPERS = new Set(['sudo', 'command', 'time', 'nohup', 'env', 'exec', 'xargs'])
6
7export type Heredoc = { opener: string; body: string; index: number }
8
9export function resolvePath(base: string, path: string, home: string): string {
10  const absolute =
11    path === '~' ? home
12    : path.startsWith('~/') ? home + path.slice(1)
13    : path.startsWith('/') ? path
14    : `${base}/${path}`
15  const parts: string[] = []
16  for (const part of absolute.split('/')) {
17    if (part === '' || part === '.') continue
18    if (part === '..') parts.pop()
19    else parts.push(part)
20  }
21  return `/${parts.join('/')}`
22}
23
24export function tokenize(command: string): string[][] {
25  const text = command.replace(HEREDOC, (_all, _quote, tag: string, rest: string) => `<<${tag}${rest}`)
26  const segments: string[][] = []
27  let current: string[] = []
28  let word = ''
29  let hasWord = false
30  let quote: string | null = null
31  const endWord = () => {
32    if (hasWord) current.push(word)
33    word = ''
34    hasWord = false
35  }
36  const endSegment = () => {
37    endWord()
38    if (current.length > 0) segments.push(dropRedirects(current))
39    current = []
40  }
41  for (let i = 0; i < text.length; i++) {
42    const c = text[i]
43    if (quote !== null) {
44      if (c === quote) quote = null
45      else if (c === '\\' && quote === '"' && i + 1 < text.length) word += text[++i]
46      else word += c
47      continue
48    }
49    if (c === '"' || c === "'") {
50      quote = c
51      hasWord = true
52    } else if (c === '\\' && i + 1 < text.length) {
53      word += text[++i]
54      hasWord = true
55    } else if (c === '&' && (word.endsWith('>') || word.endsWith('<'))) {
56      word += c
57    } else if (c === '&' && text[i + 1] === '>') {
58      endWord()
59      word = '&'
60      hasWord = true
61    } else if (c === '&' || c === '|') {
62      if (text[i + 1] === c) i++
63      endSegment()
64    } else if (c === '\n' || c === ';' || c === '(' || c === ')' || c === '`') {
65      endSegment()
66    } else if (c === ' ' || c === '\t') {
67      endWord()
68    } else {
69      word += c
70      hasWord = true
71    }
72  }
73  endSegment()
74  return segments
75}
76
77function dropRedirects(tokens: string[]): string[] {
78  const kept: string[] = []
79  for (let i = 0; i < tokens.length; i++) {
80    const token = tokens[i] ?? ''
81    if (/^(\d*|&)?(>>?|<<?)/.test(token)) {
82      if (/^(\d*|&)?(>>?|<<?)&?$/.test(token)) i++
83      continue
84    }
85    kept.push(token)
86  }
87  return kept
88}
89
90export function words(tokens: readonly string[]): string[] {
91  let i = 0
92  while (i < tokens.length && (WRAPPERS.has(tokens[i] ?? '') || /^[A-Za-z_]\w*=/.test(tokens[i] ?? ''))) i++
93  return tokens.slice(i)
94}
95
96export function heredocs(raw: string): Heredoc[] {
97  return [...raw.matchAll(HEREDOC_BODY)].map(match => ({
98    opener: `${match[1] ?? ''}${match[4] ?? ''}`,
99    body: match[5] ?? '',
100    index: match.index ?? 0,
101  }))
102}
103
104export function optionValue(args: readonly string[], names: readonly string[]): string | undefined {
105  for (let i = 0; i < args.length; i++) {
106    const arg = args[i] ?? ''
107    for (const name of names) {
108      if (arg === name) return args[i + 1]
109      if (name.startsWith('--') && arg.startsWith(`${name}=`)) return arg.slice(name.length + 1)
110    }
111  }
112  return undefined
113}
114
115export function program(token: string | undefined): string {
116  return (token ?? '').split('/').at(-1) ?? ''
117}
118