Refuses gh pr create and gh pr edit while the PR's title, its body or the branch's added Markdown lines hold a file:line citation, a placeholder or a count…

Small TypeScript mods that live inside Claude Code: a pane that knows your next step, one-click replies, a rate-limit countdown that resumes for you, your repo's pull requests and issues beside the conversation, a shelf of paths you use every day, presets that switch the model and the effort with one press, a chime when a long turn ends, a guard for the Office file you left open, a question before a commit or push on the default branch, a question before Claude reads a
.envor key file, a list of the files this session made, a list of the ones it read, a check that keeps banned claims out of a pull request, a gate that runs your checks and hands back only the failures, a nudge to fetch fresh tab IDs when a browser tab is gone, a name for the session from its first prompt, and one dialog for every mod's settings.
claude-mods is one developer's personal toolbox of Claude Code mods, shared as a plugin marketplace so anyone can install them. The mods are built for the author's own workflow first, and you are a welcome guest: install one, install all twenty, or read the source and write your own.
A mod is a plugin of function hooks: TypeScript that runs inside Claude Code and changes what it shows (a pane in the side panel, a band of buttons above the prompt, the status line) or what it does between turns. The mod's own code decides when to act, even when what it does is send the model a prompt.
| Runs as | Who decides when it acts | |
|---|---|---|
| Mod | TypeScript function hooks inside Claude Code | The mod's own code |
| Skill | Instructions the model reads | The model |
| Plain plugin | Commands, agents or shell hooks | You, or a shell script |
| Mod | Where it shows | What it does |
|---|---|---|
| 🧭 whats-next | Pane in the side panel | Lists the next steps of your workflow, each with a prompt ready to paste |
| ⚡ quick-reply | Band above the prompt | One-click replies, including the options Claude just offered, Pass, Fail and Skip for a verdict, and the next wayfinder ticket |
| ⏳ auto-resume | Band and status line | Counts down to a rate limit's reset, then sends "continue" |
| 🐙 github-panel | Pane in the side panel | The repo's open pull requests and issues, one click from the browser or from /implement and /wayfinder in the prompt; the issue a /wayfinder run works on, pinned with its next ticket; a toast when your branch's checks turn green or red |
| 📚 shelf | Band above the prompt | Named folders and files; one click drops a path into what you are typing |
| 🔔 turn-chime | Sound and toast | Tells you when a long turn ends or Claude stops to ask you something |
| 🔒 open-file-guard | Question dialog | Asks you to close a Word, Excel or PowerPoint file before Claude uses it |
| 🌿 branch-guard | Question dialog | Asks you before Claude commits or pushes on the default branch |
| 🔑 env-guard | Question dialog | Asks you before Claude reads a .env or key file, and refuses when no one is there to answer |
| 🩹 bash-quoting-rescue | Refused tool call | Stops a shell command that does not parse, such as an unclosed quote, before any of it runs |
| 📂 outputs | Pane in the side panel | The files this session made or changed, newest first; click one to open it |
| 🔎 sources | Pane in the side panel | The files Claude read, grouped by where they came from, with a lock to the project folder |
| 📊 hud | Two lines under the prompt | Model, effort, context window, rate limits, turn timer, tool calls, agents, git state, worktree, cost, session length and folder, each named and in colour |
| 🧹 post-merge-cleanup | Question dialog and toast | After a PR merges, /cleanup switches to the default branch, pulls and deletes the branch |
| 🧾 pre-pr-claims-check | Refusal Claude reads | Refuses gh pr create and gh pr edit while the pull request cites a file and line, holds a placeholder, or spells out a count |
| 🚦 lint-test-gate | Band above the prompt | Runs your checks on a press and before Claude's git commit, and hands back only the failures |
| 🔄 chrome-tab-self-heal | Note after a browser tool's error | When a Claude in Chrome tab is gone, tells Claude to fetch the current tab IDs before it tries again |
| 🏷 session-auto-namer | Band above the prompt | Suggests a name for the session from its first prompt; one press renames it |
| 🧠 model-effort-presets | Band above the prompt | Plan and execute presets: one press switches the model and the effort together |
| ⚙ mod-settings | Gear on each pane; a dialog | Change and save any mod's settings without leaving the session |
When more than one mod has a pane open, Claude Code shows them as tabs in the side panel.
A pane listing the next steps of your workflow for the project folder, kept between sessions. A skill of your choosing answers "what's next" in a headless run beside your session, and the mod turns the answer into steps.
What's next refresh
updated 3 min ago
Fix the failing parse test
working on it done
The check is red, so nothing else can merge.
Open a pull request for the fix
Review comes before the next feature starts.
Triage the two new issues
They arrived while you were heads-down.
flowchart LR
A[Skill answers<br/>what's next] --> B[Steps in the pane]
B --> C[You read a step's prompt<br/>and send it yourself]
C --> D[Step glows:<br/>working on it]
D --> E{Laya, Jev or Haiku:<br/>is the step finished?}
E -- not yet --> D
E -- yes --> F[Step leaves the list]
d to drop it yourself.git push, git config, git -c, gh api and --output are always denied.| Command or key | What it does |
|---|---|
/whats-next | Bring the pane to the front |
/whats-next refresh | Ask the skill again |
r | Refresh |
1 to 9 | Show that step's prompt in the pane |
p, n, c | Paste the shown prompt, paste it after /clear (and the prime command, once its turn ends), or copy it |
b | Back to the list |
d | Mark the active step done |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Skill | skill | /ask-sean | The skill that answers "what's next", written as you would run it: / followed by letters, digits, _, :, . or - |
| Most steps | maxSteps | 5 | How many steps to ask for (1-9) |
| Refresh on start | refreshOnStart | on | Ask for a fresh list when a session starts in a git repository |
| Tools the headless run may use | allowedTools | empty: the read-only set | Comma-separated permission rules for the headless run |
| Model | model | empty: your default | Model for the headless run, as an alias (haiku) or a full id |
| Prime command | primeCommand | empty: none | A slash command, with any arguments, run after /clear and before the paste (/lril:prime); the prompt is filled once the turn it starts ends, finished or not. A value that is not one slash command on one line is never run, and the step view says so |
| System One models | modelChoice | local only | Which System One model judges whether a turn finished the active step: local only (Laya on this machine, or Haiku as before), local first (Laya, and TypeSafe's hosted Jev while Laya is unavailable) or hosted first (Jev, and Laya while Jev is unavailable) |
| Jev API key | jevApiKey | empty | Your key for Jev, from console.typesafe.ai, kept in secure storage. Sent only to https://api.typesafe.ai, and only while System One models allows Jev |
| Laya port | layaPort | 8000 | The port your laya-serve listens on at 127.0.0.1 (1-65535) |
/ask-sean is the author's own skill, so point the Skill setting at a skill of yours that answers "what should I do next?" (how to set it):
echo '{"skill": "/next-steps", "maxSteps": "3", "model": "haiku"}' | claude plugin configure whats-next@claude-mods --values-stdin
The read-only set, used while Tools is empty, is Bash(git status:*), Bash(git log:*), Bash(git diff:*), Bash(git show:*), Bash(git rev-parse:*), Bash(git branch --show-current), Bash(git branch -vv), Bash(git remote -v), Bash(gh issue list:*), Bash(gh issue view:*), Bash(gh pr list:*), Bash(gh pr view:*), Bash(gh pr checks:*), Bash(gh run list:*), Read, Glob and Grep.
(...). One rule that is not, such as one starting with -, discards your whole list and the read-only set is used.Skill, so a skill that calls another still works. MCP servers load only when a rule names an mcp__ tool.What the judge sends to a System One model, once after each answered turn while a step is active:
Needs: the claude CLI on the PATH, and the skill named in the Skill setting. Laya and a Jev key are optional: with neither, the judge is Haiku, as before.
One row of buttons above the prompt after each answer. When Claude ends on a question, the band offers the choices Claude asked you to pick from, then your replies to a question. After any other answer it offers your other replies. You set both lists in the settings below.
Reply: [a: Keep the copies] [b: Add a sync script] [Yes] [Go with your recommendation] [No]
Verdicts. When Claude asks for a pass/fail verdict on a test or a check, as a UAT or /gsd:verify-work step does ("Pass or fail?", "Did it pass?", "Type pass or describe what's wrong"), the band offers a verdict in place of your replies:
Reply: [Pass] [Fail…] [Skip]
Pass sends "pass" and Skip sends "skip", as your own message. Fail… sends nothing: it puts "Fail: " in the prompt, where you say what went wrong and can paste a screenshot. A test's numbered steps are not offered as choices, and a question that only mentions passing ("Shall I make the tests pass?") gets your usual replies.
Next ticket. When you work a map with the wayfinder skill, the band leads with the next ticket after each turn that closes one or charts the map:
Reply: [Next ticket: /wayfinder 135] [Continue] [Commit and push]
One press runs /clear and then /wayfinder 135, the loop you would otherwise type. It is offered only in a session that ran the wayfinder skill, after a turn whose gh issue close worked; after charting it names the map the turn created. A close made some other way (gh api, the web) is not seen, so no button shows, and once a turn closes the map itself the loop ends.
| Setting | Key | Default | Meaning | |||
|---|---|---|---|---|---|---|
| Replies to a question | questionReplies | `Yes\ | Go with your recommendation\ | No` | Shown after Claude asks something, separated by `\ | `; empty shows only the choices Claude offered |
| Replies otherwise | idleReplies | `Continue\ | Commit and push` | Shown after any other answer; empty hides the band then, except for Next ticket | ||
| Offer the next wayfinder ticket | wayfinderNext | true | After a wayfinder turn closes a ticket or charts a map, offer Next ticket | |||
| System One models | modelChoice | local only | Which System One model reads how each answer ends: local only (Laya on this machine, or the band's own reading as before), local first (Laya, and TypeSafe's hosted Jev while Laya is unavailable) or hosted first (Jev, and Laya while Jev is unavailable) | |||
| Jev API key | jevApiKey | empty | Your key for Jev, from console.typesafe.ai, kept in secure storage. Sent only to https://api.typesafe.ai, and only while System One models allows Jev | |||
| Laya port | layaPort | 8000 | The port your laya-serve listens on at 127.0.0.1 (1-65535) |
Each list holds up to six replies. A reply longer than 120 characters is cut short, and a repeat is dropped. For example, to answer questions with your own three replies and hide the band after other answers (how to set it):
echo '{"questionReplies": "Yes|No|Explain that first", "idleReplies": ""}' | claude plugin configure quick-reply@claude-mods --values-stdin
What the band sends to a System One model, once after each answered turn:
Laya and a Jev key are optional: with neither, the band reads each answer as before.
When a turn dies on a rate limit or an overloaded API, auto-resume counts down to the reset and sends "continue" for you. Go to lunch, and come back to finished work.
⏳ rate limited: sending "continue" in 1 h 12 min [Resume now] [Cancel]
| Command | What it does |
|---|---|
/auto-resume | Say what is waiting, if anything |
/auto-resume now | Send the resume now |
/auto-resume cancel | Cancel the wait |
/auto-resume in <minutes> | Schedule a resume yourself (1 to 1440) |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Resume prompt | text | continue | What is sent when the wait is over; empty sends continue |
| Grace after reset (s) | graceSeconds | 60 | Extra seconds to wait past the limit's reset time (0-900) |
| Retry overloaded/server errors | retryOverloaded | on | Also resume after an overloaded or server error, backing off from one minute |
| Most retries in a row | maxRetries | 5 | Give up after this many resumes without a successful answer (1-20) |
For example, to send a longer prompt and wait two minutes past the reset (how to set it):
echo '{"text": "continue where you left off", "graceSeconds": "120"}' | claude plugin configure auto-resume@claude-mods --values-stdin
A GitHub pane beside What's next listing the repo's open pull requests and issues. Click one to open it in the browser. Under each issue, implement and wayfinder put /implement or /wayfinder and the issue's URL in the prompt. Nothing is sent. While a /wayfinder effort is under way, its issue is pinned at the top, with the next ticket to take.
octocat/hello-world refresh
updated just now
Decide how shared code is copied unpin
3 done · 1 takeable · 1 claimed · 2 blocked
next: Pick the drift check decision
work next
Pull requests 2 all
#41 Add a drift check for copied guards
draft · @octocat
#40 Bring the asked pane to the front
✗ checks failing · @hubot fix
Issues 2 all
#39 Share the headless-session check
blocked by #12 · ready… implement wayfinder
#12 Decide how shared code is copied
needs-triage · @hubot implement wayfinder
implement and wayfinder buttons take a line of their own.r.fix button. It fills the prompt box with the failed checks' names, the last 40 lines of the failed run's log, and "Fix it." Nothing is sent: you read it and send it yourself. The log is fetched only when you press fix; when gh cannot fetch it, such as while the run is still going, the prompt holds the names alone./wayfinder on an issue of this repo (/wayfinder 12, /wayfinder #12 or the issue's URL) pins that issue and brings the pane to the front. Anything else, such as prose or another repo's issue, pins nothing. The pin is kept for the repo across sessions, one at a time: a run on another issue replaces it. It goes when you press unpin, or once the issue is closed.next is the first takeable ticket in the order the issue lists its sub-issues, with its wayfinder: type label; click it to open it on GitHub. With nothing takeable it says so; an issue with no sub-issues yet says no tickets yet.work next fills /wayfinder and the pinned issue's URL into the prompt, without sending it, so the skill takes the frontier ticket fresh when it runs.| Command or key | What it does |
|---|---|
/github | Bring the pane to the front and refresh it |
r | Refresh |
all | Open the whole list on GitHub |
implement, wayfinder | Fill the command and the issue's URL into the prompt, without sending it |
fix | Fill a request to fix the current branch's failing checks into the prompt box |
unpin | Drop the pinned issue |
work next | Fill /wayfinder and the pinned issue's URL into the prompt, without sending it |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Most items per list | limit | 30 | How many open pull requests and issues to list each (1-100) |
| Refresh every (minutes) | refreshMinutes | 5 | How often to refresh (0-120); 0 refreshes only on open, after turns and on r |
| Implement button fills | implementCommand | /implement | The slash command the implement button fills before the issue's URL; empty hides the button |
| Wayfinder button fills | wayfinderCommand | /wayfinder | The slash command the wayfinder and work next buttons fill before the issue's URL, and the skill whose runs pin an issue; empty hides the button and turns pinning off |
A command must start with / and hold no spaces. Any other value falls back to the default, and a message says so when the session starts. A button is labelled with its command, without the /.
For example, to list fifty of each and stop the timer (how to set it):
echo '{"limit": "50", "refreshMinutes": "0"}' | claude plugin configure github-panel@claude-mods --values-stdin
Needs: the GitHub CLI, logged in, and a folder with a GitHub remote.
A row of named folders and files above the prompt, for the paths you type again and again. Click a name and its path lands at the cursor in what you are typing. Nothing is sent.
Shelf: [brand] [records] [kb]
/shelf always lists them all.| Command | What it does |
|---|---|
/shelf | List the shelf with each path |
/shelf add <name> [path] | Put a path on the shelf; with no path, the project folder |
/shelf remove <name> | Take one off |
The shelf has no settings: you fill it with /shelf add.
/shelf add brand "N:/Marketing/Brand Kit"
/shelf add records N:/RECORDS
/shelf add kb
/shelf remove brand
hooks/register.ts 91 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { findingsInDiff, findingsInText, refusal } from './claims'
4import type { Finding } from './claims'
5import { prCalls } from './command'
6import type { Body, PrCall } from './command'
7
8/** More context than any Markdown file has lines, so each changed file comes whole and fences can be followed. */
9const WHOLE_FILE = 1_000_000
10
11function logQuietly($: EngineInterface, what: string, error: unknown): void {
12 $.ui.log(`pre-pr-claims-check: ${what}: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
13}
14
15/** Runs a command that must succeed and answers its output. */
16async function output($: EngineInterface, argv: readonly string[]): Promise<string> {
17 const result = await $.process.run(argv)
18 if (result.exitCode !== 0) throw new Error(`${argv.slice(0, 3).join(' ')} failed: ${result.stderr.trim() || `exit ${result.exitCode}`}`)
19 return result.stdout
20}
21
22async function defaultBranch($: EngineInterface): Promise<string> {
23 return (await output($, ['gh', 'repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name'])).trim()
24}
25
26/** The findings on the lines the branch adds to Markdown files since it left `base`. */
27async function findingsOnBranch($: EngineInterface, base: string): Promise<Finding[]> {
28 const diff = await output($, [
29 'git',
30 'diff',
31 '--no-color',
32 '--no-ext-diff',
33 '--no-prefix',
34 '--find-renames',
35 `--unified=${WHOLE_FILE}`,
36 `origin/${base}...HEAD`,
37 '--',
38 ':(icase)*.md',
39 ':(icase)*.markdown',
40 ])
41 return findingsInDiff(diff)
42}
43
44async function findingsInBody($: EngineInterface, body: Body | undefined): Promise<Finding[]> {
45 if (body === undefined) return []
46 if ('text' in body) return findingsInText(body.text, 'PR body')
47 let text: string
48 try {
49 text = await $.fs.read(body.file)
50 } catch (error) {
51 // A file that cannot be read is gh's to report when the command runs.
52 logQuietly($, `reading ${body.file}`, error)
53 return []
54 }
55 return findingsInText(text, 'PR body')
56}
57
58/**
59 * Every finding in the calls' titles and bodies and in the branch's added Markdown lines.
60 * A git or gh call that fails leaves the branch unchecked rather than blocking the command.
61 */
62async function findingsFor($: EngineInterface, calls: readonly PrCall[]): Promise<Finding[]> {
63 const found: Finding[] = []
64 const bases = new Set<string | undefined>()
65 for (const call of calls) {
66 if (call.title !== undefined) found.push(...findingsInText(call.title, 'PR title'))
67 found.push(...(await findingsInBody($, call.body)))
68 bases.add(call.base)
69 }
70 for (const base of bases) {
71 try {
72 found.push(...(await findingsOnBranch($, base ?? (await defaultBranch($)))))
73 } catch (error) {
74 logQuietly($, 'checking the branch', error)
75 }
76 }
77 return found
78}
79
80export const register: Register = on => {
81 // Matched by pattern: which shell tools a session has depends on the machine.
82 // It runs in a headless session too: it asks no one, and starts no prompt and no headless run.
83 on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
84 const command = 'command' in e && typeof e.command === 'string' ? e.command : ''
85 const calls = prCalls(command, e.tool === 'PowerShell' ? 'powershell' : 'bash')
86 if (calls.length === 0) return next(e)
87 const findings = await findingsFor($, calls)
88 return findings.length === 0 ? next(e) : { deny: refusal(findings) }
89 })
90}
91hooks/claims.ts 133 lines1/** A banned claim: where it is (a file and line, "PR body" or "PR title"), which ban, and the text matched. */
2export type Finding = { where: string; ban: string; text: string }
3
4const UNIT = 'one|two|three|four|five|six|seven|eight|nine'
5const TEEN = 'eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen'
6const TENS = `(?:twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety)(?:[- ](?:${UNIT}))?`
7const UNDER_HUNDRED = `${TEEN}|ten|${TENS}|${UNIT}`
8const LARGE = `(?:a|${UNDER_HUNDRED})[- ](?:hundred|thousand|million)(?:[- ](?:and[- ])?(?:${UNDER_HUNDRED}))?`
9// Words ending in s that are not plural nouns, so a count before them is not one.
10const NOT_PLURAL = new Set(['was', 'has', 'does', 'goes', 'its', 'yes', 'always', 'across', 'perhaps', 'whereas', 'besides'])
11const IRREGULAR_PLURAL = new Set(['people', 'children', 'men', 'women'])
12
13const BANS: readonly { ban: string; pattern: RegExp; isFinding?: (match: RegExpMatchArray) => boolean }[] = [
14 {
15 ban: 'file:line citation',
16 // A URL's host and port is not one: nothing before the name may join it to a scheme.
17 pattern: /(?<![\w.:/\\@-])(?:[A-Za-z]:[\\/])?(?:[\w.-]+[\\/])*[\w-]+(?:\.[\w-]+)*\.[A-Za-z][A-Za-z0-9]*:\d+(?:-\d+)?(?![\w/])/g,
18 },
19 {
20 ban: 'placeholder',
21 pattern: /\(PR #NN\)|(?<!\w)#(?:NN|XX)(?!\w)|\bTODO:?\s+fill\s+in\b/gi,
22 },
23 {
24 ban: 'count in words',
25 pattern: new RegExp(`\\b(?:${LARGE}|${TEEN}|${TENS})\\s+([A-Za-z]+)\\b`, 'gi'),
26 isFinding: match => isPlural(match[1]!),
27 },
28]
29
30function isPlural(word: string): boolean {
31 const lower = word.toLowerCase()
32 if (IRREGULAR_PLURAL.has(lower)) return true
33 return lower.length >= 3 && lower.endsWith('s') && !/(?:ss|us|is)$/.test(lower) && !NOT_PLURAL.has(lower)
34}
35
36/** The bans one line breaks. */
37function findingsInLine(line: string, where: string): Finding[] {
38 return BANS.flatMap(({ ban, pattern, isFinding }) =>
39 [...line.matchAll(pattern)].filter(match => isFinding?.(match) ?? true).map(match => ({ where, ban, text: match[0] })),
40 )
41}
42
43/** The run of backticks or tildes a fence line starts with; undefined for any other line. */
44function fenceOf(line: string): string | undefined {
45 return /^\s*(`{3,}|~{3,})/.exec(line)?.[1]
46}
47
48function closes(line: string, open: string): boolean {
49 const fence = fenceOf(line)
50 return fence !== undefined && fence[0] === open[0] && fence.length >= open.length && line.trim() === fence
51}
52
53/**
54 * The findings on the lines `where` names, outside fenced code blocks.
55 * Every line is read for the fences; `where` answers undefined for a line not to check.
56 */
57export function findingsIn(lines: readonly string[], where: (index: number) => string | undefined): Finding[] {
58 const found: Finding[] = []
59 let open: string | undefined
60 lines.forEach((line, index) => {
61 if (open !== undefined) {
62 if (closes(line, open)) open = undefined
63 return
64 }
65 open = fenceOf(line)
66 const place = where(index)
67 if (open === undefined && place !== undefined) found.push(...findingsInLine(line, place))
68 })
69 return found
70}
71
72/** The findings in a PR's title or body. */
73export function findingsInText(text: string, where: string): Finding[] {
74 return findingsIn(text.split(/\r?\n/), () => where)
75}
76
77/** One file as a whole-file diff shows it: its lines after the change, and which of them the change added. */
78type ChangedFile = { path: string; lines: string[]; isAdded: boolean[] }
79
80function pathOf(header: string): string {
81 const path = header.slice(4).replace(/\t$/, '')
82 return path.startsWith('"') && path.endsWith('"') ? path.slice(1, -1) : path
83}
84
85/**
86 * Reads `git diff --no-prefix` output whose context covers each whole file.
87 * A hunk is read by its line counts, so an added line that starts with `++` is not taken for a header.
88 */
89function changedFiles(diff: string): ChangedFile[] {
90 const files: ChangedFile[] = []
91 const lines = diff.split('\n')
92 let file: ChangedFile | undefined
93 for (let at = 0; at < lines.length; at += 1) {
94 const line = lines[at]!
95 if (line.startsWith('+++ ')) {
96 file = line === '+++ /dev/null' ? undefined : { path: pathOf(line), lines: [], isAdded: [] }
97 if (file !== undefined) files.push(file)
98 continue
99 }
100 const hunk = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line)
101 if (hunk === null || file === undefined) continue
102 let old = Number(hunk[1] ?? 1)
103 let added = Number(hunk[2] ?? 1)
104 while ((old > 0 || added > 0) && at + 1 < lines.length) {
105 const body = lines[(at += 1)]!.replace(/\r$/, '')
106 const mark = body[0]
107 if (mark === '\\') continue
108 if (mark !== '+') old -= 1
109 if (mark === '-') continue
110 added -= 1
111 file.lines.push(body.slice(1))
112 file.isAdded.push(mark === '+')
113 }
114 }
115 return files
116}
117
118/** The findings on the lines a whole-file diff of Markdown files adds, each placed by file and line. */
119export function findingsInDiff(diff: string): Finding[] {
120 return changedFiles(diff).flatMap(file =>
121 findingsIn(file.lines, index => (file.isAdded[index] ? `${file.path}, line ${index + 1}` : undefined)),
122 )
123}
124
125/** Why a gh call is refused: each finding, where it is and what it matched. */
126export function refusal(findings: readonly Finding[]): string {
127 return [
128 'pre-pr-claims-check: this pull request breaks the claims bans. Fix each of these, then run the command again:',
129 ...findings.map(finding => `- ${finding.where}: ${finding.ban} "${finding.text}"`),
130 'Name a file without its line number, fill in or drop each placeholder, and leave out counts typed by hand.',
131 ].join('\n')
132}
133hooks/command.ts 180 lines1export type Shell = 'bash' | 'powershell'
2
3/** Where a PR's body comes from: its text as the command gives it, or a file to read. */
4export type Body = { text: string } | { file: string }
5
6/** One `gh pr create` or `gh pr edit` in a shell command, with what it sets. */
7export type PrCall = { title?: string; body?: Body; base?: string }
8
9/**
10 * Stands in for a heredoc or a here-string taken out of the command, so a word
11 * that held one can still be traced to its text.
12 */
13const MARK = '\u0001'
14const MARKED = new RegExp(`${MARK}(\\d+)${MARK}`, 'g')
15const HEREDOC = /<<(?!<)(-?)\s*(?:'([^'\n]+)'|"([^"\n]+)"|\\?([\w.-]+))/g
16const HERE_STRING = /@(['"])\r?\n([\s\S]*?)\r?\n\1@/g
17const BREAKS = new Set([';', '&', '|', '<', '>', '(', ')', '{', '}', '\n'])
18const FLAGS: Readonly<Record<string, keyof Flags>> = {
19 '--title': 'title',
20 '-t': 'title',
21 '--body': 'body',
22 '-b': 'body',
23 '--body-file': 'bodyFile',
24 '-F': 'bodyFile',
25 '--base': 'base',
26 '-B': 'base',
27}
28
29type Flags = { title?: string; body?: string; bodyFile?: string; base?: string }
30type Inline = { command: string; texts: string[] }
31
32function mark(index: number): string {
33 return `${MARK}${index}${MARK}`
34}
35
36/** Takes each heredoc's lines out of a Bash command, leaving a mark where its `<<` was. */
37function takeHeredocs(command: string): Inline {
38 const lines = command.split('\n')
39 const kept: string[] = []
40 const texts: string[] = []
41 for (let at = 0; at < lines.length; at += 1) {
42 const opened: { delimiter: string; isTabbed: boolean }[] = []
43 kept.push(
44 lines[at]!.replace(HEREDOC, (_all, dash: string, single?: string, double?: string, bare?: string) => {
45 opened.push({ delimiter: single ?? double ?? bare ?? '', isTabbed: dash === '-' })
46 return mark(texts.length + opened.length - 1)
47 }),
48 )
49 for (const { delimiter, isTabbed } of opened) {
50 const body: string[] = []
51 for (at += 1; at < lines.length; at += 1) {
52 const line = lines[at]!.replace(/\r$/, '')
53 if ((isTabbed ? line.replace(/^\t+/, '') : line) === delimiter) break
54 body.push(line)
55 }
56 texts.push(body.join('\n'))
57 }
58 }
59 return { command: kept.join('\n'), texts }
60}
61
62/** Takes each here-string out of a PowerShell command, leaving a mark in its place. */
63function takeHereStrings(command: string): Inline {
64 const texts: string[] = []
65 const rest = command.replace(HERE_STRING, (_all, _quote, text: string) => {
66 texts.push(text)
67 return mark(texts.length - 1)
68 })
69 return { command: rest, texts }
70}
71
72/**
73 * The command's words with quotes taken off, cut into simple commands at an
74 * unquoted break (`;`, `&&`, a pipe, a redirect, a bracket or a line end).
75 */
76function simpleCommands(command: string, shell: Shell): string[][] {
77 const escape = shell === 'bash' ? '\\' : '`'
78 const all: string[][] = [[]]
79 let word: string | undefined
80 const endWord = () => {
81 if (word !== undefined) all[all.length - 1]!.push(word)
82 word = undefined
83 }
84 for (let at = 0; at < command.length; at += 1) {
85 const char = command[at]!
86 if (char === escape) {
87 // An escaped line end continues the command.
88 if (command[at + 1] !== '\n') word = (word ?? '') + (command[at + 1] ?? '')
89 at += 1
90 } else if (char === "'") {
91 const end = closing(command, at, "'", shell)
92 const text = command.slice(at + 1, end)
93 word = (word ?? '') + (shell === 'powershell' ? text.replaceAll("''", "'") : text)
94 at = end
95 } else if (char === '"') {
96 const end = closing(command, at, '"', shell)
97 word = (word ?? '') + unescapeDouble(command.slice(at + 1, end), shell)
98 at = end
99 } else if (/\s/.test(char) && char !== '\n') {
100 endWord()
101 } else if (BREAKS.has(char)) {
102 endWord()
103 all.push([])
104 } else {
105 word = (word ?? '') + char
106 }
107 }
108 endWord()
109 return all.filter(words => words.length > 0)
110}
111
112/** Where the quote opened at `start` closes, or the command's end when it never does. */
113function closing(command: string, start: number, quote: string, shell: Shell): number {
114 for (let at = start + 1; at < command.length; at += 1) {
115 const char = command[at]
116 if (quote === '"' && char === (shell === 'bash' ? '\\' : '`')) at += 1
117 else if (char === quote) {
118 // PowerShell doubles a quote to put one inside its own kind.
119 if (shell === 'powershell' && command[at + 1] === quote) at += 1
120 else return at
121 }
122 }
123 return command.length
124}
125
126function unescapeDouble(text: string, shell: Shell): string {
127 if (shell === 'bash') return text.replace(/\\([\\"$`\n])/g, (_all, char: string) => (char === '\n' ? '' : char))
128 return text.replaceAll('""', '"').replace(/`(.)/g, (_all, char: string) => (char === 'n' ? '\n' : char === 't' ? '\t' : char))
129}
130
131/** The flags a gh call sets, from the words after `gh pr create` or `gh pr edit`. */
132function readFlags(words: readonly string[]): Flags {
133 const flags: Flags = {}
134 for (let at = 0; at < words.length; at += 1) {
135 const word = words[at]!
136 const equals = word.indexOf('=')
137 const name = equals > 0 ? word.slice(0, equals) : word
138 const key = FLAGS[name]
139 if (key === undefined) continue
140 const value = equals > 0 ? word.slice(equals + 1) : words[(at += 1)]
141 if (value !== undefined) flags[key] = value
142 }
143 return flags
144}
145
146/** The text a word holds once each heredoc or here-string it names is put back. */
147function inlineIn(word: string, texts: readonly string[]): string | undefined {
148 const named = [...word.matchAll(MARKED)].map(match => texts[Number(match[1])] ?? '')
149 return named.length === 0 ? undefined : named.join('\n')
150}
151
152function bodyOf(flags: Flags, texts: readonly string[]): Body | undefined {
153 if (flags.body !== undefined) return { text: inlineIn(flags.body, texts) ?? flags.body }
154 if (flags.bodyFile === undefined) return undefined
155 // Standard input: the body is the heredoc or here-string the command carries.
156 if (flags.bodyFile === '-') return texts.length === 0 ? undefined : { text: texts.join('\n') }
157 return { file: flags.bodyFile }
158}
159
160/** Each `gh pr create` and `gh pr edit` the command runs, in order; none for any other command. */
161export function prCalls(command: string, shell: Shell): PrCall[] {
162 if (!/\bgh(?:\.exe)?\s+pr\s+(?:create|edit)\b/.test(command)) return []
163 const inline = shell === 'bash' ? takeHeredocs(command) : takeHereStrings(command)
164 const calls: PrCall[] = []
165 for (const words of simpleCommands(inline.command, shell)) {
166 const at = words.findIndex(
167 (word, index) => /^(?:.*[\\/])?gh(?:\.exe)?$/i.test(word) && words[index + 1] === 'pr' && /^(?:create|edit)$/.test(words[index + 2] ?? ''),
168 )
169 if (at < 0) continue
170 const flags = readFlags(words.slice(at + 3))
171 const call: PrCall = {}
172 if (flags.title !== undefined) call.title = inlineIn(flags.title, inline.texts) ?? flags.title
173 const body = bodyOf(flags, inline.texts)
174 if (body !== undefined) call.body = body
175 if (flags.base !== undefined) call.base = flags.base
176 calls.push(call)
177 }
178 return calls
179}
180