Rewrites lazy commit subjects into conventional commits from the staged diff, and strips AI attribution trailers

Catches the git commit calls Claude makes and fixes two things before they land:
Co-Authored-By: trailers and 🤖 Generated with ... lines are taken out of the message. Claude is also no longer told to add them, so they mostly stop showing up at all.fix, wip, update, stuff, a single word, anything over 72 characters, or anything without a conventional prefix (feat|fix|chore|docs|refactor|test|perf|build|ci|style) is rewritten from the actual diff by a fast model:commit-coach: 'fix' → 'fix(scheduler): handle empty slot list'
Your own body is kept if you wrote one; the model only adds a short body when there was none. If the model's answer is not a clean conventional subject of 72 characters or less, your message goes through as it was, and a toast says so.
It reads every way a message gets into a commit: -m, several -m, -m"...", --message=, -am, $'...', the -m "$(cat <<'EOF' ... EOF)" heredoc agents like to use, -F file, and -F - fed by a heredoc. The new message goes back in as plain single-quoted -m arguments; everything else in the command (git add -A && ... && git push) is left exactly as it was.
It leaves a commit entirely alone while a merge is in progress (MERGE_HEAD exists), for --amend without a new message, -C/-c/--fixup/--squash, and when the shell builds the message at run time ("$MSG", "$(git log ...)"). Subjects starting with Merge , Revert ", fixup! or squash! are never rewritten.
claude --plugin-dir ./commit-coach
or add this repo as a marketplace and install it with /plugin.
| option | default | what it does |
|---|---|---|
stripAttribution | true | Remove Co-Authored-By: and "Generated with" lines, and empty the commit attribution text Claude is given. Note: this strips every Co-Authored-By: trailer, a human pair's included. |
rewriteSubjects | true | Rewrite lazy subjects from the diff. |
model | haiku | The model that writes the new subject: an alias or a full model id. |
A tool.call hook on Bash lexes the command (keeping each token's position), reads the commit message, and when it needs a new subject runs git diff --cached (or git diff HEAD after -a or a git add in the same command) and asks $.model.complete; it then calls next({ ...e, command }) with the rewritten command, and adds a note to the result so Claude knows the new message is intended and doesn't amend it back. An attribution.text hook for commit answers an empty string.
Only the first git commit in a command is coached.
hooks/register.ts 103 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { findCommit, rewrite } from './commit'
4import type { CommitCall } from './commit'
5import { buildPrompt, laziness, readReply, split, stripAttribution, trimDiff } from './message'
6
7async function git($: EngineInterface, call: CommitCall, args: string[]): Promise<string | undefined> {
8 const argv = call.dir === undefined ? ['git', ...args] : ['git', '-C', call.dir, ...args]
9 const ran = await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => undefined)
10
11 return ran?.exitCode === 0 ? ran.stdout : undefined
12}
13
14/** The change the commit will record, as the model should see it. */
15async function diffOf($: EngineInterface, call: CommitCall): Promise<string> {
16 if (call.isAmend) {
17 const patch = await git($, call, ['diff', '--cached', 'HEAD~1'])
18
19 if (patch !== undefined) {
20 return trimDiff((await git($, call, ['diff', '--cached', '--stat', 'HEAD~1'])) ?? '', patch)
21 }
22 }
23
24 // With -a, or a `git add` earlier in the same command, the index is not final yet: read the work tree.
25 if (call.isAll || call.isAddedFirst) {
26 const untracked = call.isAddedFirst ? ((await git($, call, ['ls-files', '--others', '--exclude-standard'])) ?? '').trim() : ''
27 const stat = `${(await git($, call, ['diff', '--stat', 'HEAD'])) ?? ''}${untracked === '' ? '' : `\nnew files:\n${untracked}`}`
28
29 return trimDiff(stat, (await git($, call, ['diff', 'HEAD'])) ?? '')
30 }
31
32 return trimDiff((await git($, call, ['diff', '--cached', '--stat'])) ?? '', (await git($, call, ['diff', '--cached'])) ?? '')
33}
34
35async function suggest($: EngineInterface, model: string, call: CommitCall, original: string) {
36 const diff = await diffOf($, call)
37
38 if (diff.trim() === '') {
39 return undefined
40 }
41
42 const reply = await $.model.complete({ model, prompt: buildPrompt(original, diff), maxTokens: 300, effort: 'low', timeoutMs: 20_000 })
43
44 return reply.isAnswered ? readReply(reply.text) : undefined
45}
46
47export const register: Register = (on, options) => {
48 const shouldStrip = options.stripAttribution !== false
49 const shouldCoach = options.rewriteSubjects !== false
50 const model = String(options.model ?? 'haiku') || 'haiku'
51
52 if (shouldStrip) {
53 // Prevention as well as cure: the model is not asked to add the trailer in the first place.
54 on('attribution.text', { kind: 'commit' }, () => ({ text: '' }))
55 }
56
57 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
58 const call = findCommit(e.command)
59
60 if (call === undefined || call.skip !== undefined) {
61 return next(e)
62 }
63
64 const path = call.file === undefined || call.dir === undefined || call.file.startsWith('/') ? call.file : `${call.dir}/${call.file}`
65 const original = path === undefined ? call.message : await $.fs.read(path).catch(() => undefined)
66 const isMerging = (await git($, call, ['rev-parse', '-q', '--verify', 'MERGE_HEAD'])) !== undefined
67
68 if (original === undefined || isMerging) {
69 return next(e)
70 }
71
72 const stripped = shouldStrip ? stripAttribution(original) : { message: original.trim(), removed: 0 }
73 const { subject, body } = split(stripped.message)
74 const reason = shouldCoach ? laziness(subject) : undefined
75 const better = reason === undefined ? undefined : await suggest($, model, call, stripped.message)
76 const dropped = stripped.removed === 0 ? '' : ` · dropped ${stripped.removed} attribution line${stripped.removed === 1 ? '' : 's'}`
77
78 if (subject === '' || (better === undefined && stripped.removed === 0)) {
79 if (reason !== undefined) {
80 $.ui.toast(`commit-coach: kept '${subject}' (${reason}; no better subject came back)`)
81 }
82
83 return next(e)
84 }
85
86 const command = rewrite(e.command, call, better?.subject ?? subject, body === '' ? (better?.body ?? '') : body)
87
88 $.ui.toast(better === undefined ? `commit-coach${dropped.replace(' ·', ':')}` : `commit-coach: '${subject}' → '${better.subject}'${dropped}`, { timeoutMs: 6000 })
89
90 const ran = await next({ ...e, command })
91
92 if (ran.deny !== undefined || ran.isError) {
93 return ran
94 }
95
96 // Without this the model sees a commit message it didn't write, blames a git hook, and offers to amend it back.
97 const what = better === undefined ? `dropped ${stripped.removed} attribution line${stripped.removed === 1 ? '' : 's'}` : `rewrote the subject '${subject}' to '${better.subject}'`
98 const note = `commit-coach (a plugin the user installed) ${what} in this commit's message before it ran. This is intended: don't amend or redo the commit to change it back.`
99
100 return { ...ran, context: [...(ran.context ?? []), note] }
101 })
102}
103hooks/commit.ts 206 lines1// Finding the first `git commit` in a Bash command, reading its message, and
2// writing the command back with a new one.
3import { lex, quote, simpleCommands } from './shell'
4import type { Heredoc, Word } from './shell'
5
6export type Edit = { start: number; end: number; text: string }
7
8export type CommitCall = {
9 /** The `-C <dir>` git was given, if any. */
10 dir: string | undefined
11 /** The message as git will read it, when the command says it outright. */
12 message: string | undefined
13 /** `-F <path>`: the message is that file's text (read by the caller). */
14 file: string | undefined
15 /** Why this commit is left alone, when it is. */
16 skip: string | undefined
17 isAmend: boolean
18 isAll: boolean
19 /** A `git add` runs earlier in the same command, so the index is not final yet. */
20 isAddedFirst: boolean
21 /** The edits that take the old message out; the new one goes in at `insertAt`. */
22 edits: Edit[]
23 insertAt: number
24}
25
26const WRAPPERS = new Set(['sudo', 'env', 'command', 'time', 'nohup', 'exec'])
27const GIT_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env'])
28const REUSE = new Set(['-C', '-c', '--reuse-message', '--reedit-message', '--fixup', '--squash'])
29const VALUE_OPTIONS = new Set(['--author', '--date', '--template', '-t', '--trailer', '--cleanup', '--pathspec-from-file'])
30
31/** Where git's subcommand sits in a simple command's words, and the -C dir. */
32function gitSub(words: readonly Word[]): { at: number; dir: string | undefined } | undefined {
33 let at = 0
34
35 while (at < words.length && (WRAPPERS.has(words[at]?.value ?? '') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[at]?.value ?? ''))) {
36 at += 1
37 }
38
39 if ((words[at]?.value ?? '').split('/').pop() !== 'git') {
40 return undefined
41 }
42
43 let dir: string | undefined
44 at += 1
45
46 while (at < words.length && (words[at]?.value ?? '').startsWith('-')) {
47 const option = words[at]?.value ?? ''
48 dir = option === '-C' ? words[at + 1]?.value : dir
49 at += GIT_VALUE_OPTIONS.has(option) ? 2 : 1
50 }
51
52 return { at, dir }
53}
54
55/** Removes a run of tokens and the blanks before it. */
56function cut(src: string, first: { start: number }, last: { end: number }, text = ''): Edit {
57 let start = first.start
58
59 while (start > 0 && (src[start - 1] === ' ' || src[start - 1] === '\t')) {
60 start -= 1
61 }
62
63 return { start, end: last.end, text: text === '' ? '' : ` ${text}` }
64}
65
66export function findCommit(src: string): CommitCall | undefined {
67 const commands = simpleCommands(lex(src))
68 let isAddedFirst = false
69
70 for (const { words, heredocs } of commands) {
71 const sub = gitSub(words)
72 const name = sub === undefined ? undefined : words[sub.at]?.value
73
74 if (name === 'add') {
75 isAddedFirst = true
76 }
77
78 if (sub === undefined || name !== 'commit') {
79 continue
80 }
81
82 const commitWord = words[sub.at] as Word
83
84 return { ...readArgs(src, words.slice(sub.at + 1), heredocs), dir: sub.dir, isAddedFirst, insertAt: commitWord.end }
85 }
86
87 return undefined
88}
89
90function readArgs(src: string, args: readonly Word[], heredocs: readonly Heredoc[]) {
91 const messages: string[] = []
92 const edits: Edit[] = []
93 let file: string | undefined
94 let skip: string | undefined
95 let isAmend = false
96 let isAll = false
97
98 /** One -m / -F occurrence: its value, and the tokens it spans (a cluster's other flags kept). */
99 const take = (option: string, value: Word | undefined, tokens: Word[], residue: string) => {
100 const first = tokens[0] as Word
101 const last = tokens.at(-1) as Word
102
103 if (value === undefined) {
104 skip = `${option} has no value`
105
106 return
107 }
108
109 edits.push(residue === '-' || residue === '' ? cut(src, first, last) : cut(src, first, last, residue))
110
111 if (value.isDynamic) {
112 skip = 'the message is built by the shell'
113 } else if (option === 'F') {
114 if (value.value === '-') {
115 const stdin = heredocs[0]
116
117 if (stdin === undefined || stdin.isDynamic) {
118 skip = 'the message comes from stdin'
119 } else {
120 messages.push(stdin.body)
121 edits.push(cut(src, stdin, stdin), { start: stdin.bodyStart, end: stdin.bodyEnd, text: '' })
122 }
123 } else {
124 file = value.value
125 }
126 } else {
127 messages.push(value.value)
128 }
129 }
130
131 for (let i = 0; i < args.length; i += 1) {
132 const word = args[i] as Word
133 const arg = word.value
134
135 if (arg === '--') {
136 break
137 } else if (arg === '-m' || arg === '--message' || arg === '-F' || arg === '--file') {
138 take(arg === '-F' || arg === '--file' ? 'F' : 'm', args[i + 1], [word, ...(args[i + 1] === undefined ? [] : [args[i + 1] as Word])], '')
139 i += 1
140 } else if (arg.startsWith('--message=') || arg.startsWith('--file=')) {
141 const isFile = arg.startsWith('--file=')
142 take(isFile ? 'F' : 'm', { ...word, value: arg.slice(arg.indexOf('=') + 1) }, [word], '')
143 } else if (REUSE.has(arg) || [...REUSE].some(option => option.startsWith('--') && arg.startsWith(`${option}=`))) {
144 skip = `${arg.split('=')[0]} reuses another commit's message`
145 i += arg.includes('=') ? 0 : 1
146 } else if (arg === '--amend') {
147 isAmend = true
148 } else if (arg === '--all') {
149 isAll = true
150 } else if (VALUE_OPTIONS.has(arg)) {
151 i += 1
152 } else if (arg.startsWith('-') && !arg.startsWith('--') && arg.length > 1) {
153 // A cluster of short flags: -am "msg", -m"msg", -aF msg.txt
154 for (let at = 1; at < arg.length; at += 1) {
155 const flag = arg[at] ?? ''
156
157 if (flag === 'a') {
158 isAll = true
159 } else if (flag === 'm' || flag === 'F') {
160 const rest = arg.slice(at + 1)
161 const residue = arg.slice(0, at)
162
163 if (rest === '') {
164 take(flag, args[i + 1], [word, ...(args[i + 1] === undefined ? [] : [args[i + 1] as Word])], residue)
165 i += 1
166 } else {
167 take(flag, { ...word, value: rest }, [word], residue)
168 }
169
170 break
171 } else if (flag === 'C' || flag === 'c') {
172 skip = `-${flag} reuses another commit's message`
173 i += at === arg.length - 1 ? 1 : 0
174
175 break
176 } else if (flag === 't') {
177 i += at === arg.length - 1 ? 1 : 0
178
179 break
180 } else if (flag === 'S' || flag === 'u') {
181 break
182 }
183 }
184 }
185 }
186
187 if (skip === undefined && messages.length === 0 && file === undefined) {
188 skip = isAmend ? 'an amend that keeps its message' : 'no message given'
189 }
190
191 return { message: messages.length === 0 ? undefined : messages.join('\n\n'), file, skip, isAmend, isAll, edits }
192}
193
194/** The command with the old message taken out and `-m subject [-m body]` put right after `commit`. */
195export function rewrite(src: string, call: CommitCall, subject: string, body: string): string {
196 const message = ` -m ${quote(subject)}${body.trim() === '' ? '' : ` -m ${quote(body.trim())}`}`
197 const edits = [...call.edits].sort((a, b) => b.start - a.start)
198 let out = src
199
200 for (const edit of edits) {
201 out = out.slice(0, edit.start) + edit.text + out.slice(edit.end)
202 }
203
204 return out.slice(0, call.insertAt) + message + out.slice(call.insertAt)
205}
206hooks/message.ts 125 lines1// What makes a commit message lazy, stripping attribution, and the model's part.
2
3export const CONVENTIONAL = /^(feat|fix|chore|docs|refactor|test|perf|build|ci|style|revert)(\([^()\n]+\))?!?: \S/
4
5const LAZY = new Set([
6 'fix', 'fixes', 'fixed', 'wip', 'update', 'updates', 'updated', 'change', 'changes', 'stuff', 'asdf',
7 'misc', 'tmp', 'temp', 'test', 'tests', 'cleanup', 'minor', 'edit', 'edits', 'commit', 'save', 'done',
8 'progress', 'more', 'things', 'work', 'tweak', 'tweaks', 'oops', 'typo', '.',
9])
10
11/** Subjects git or a person wrote on purpose: merges, reverts and autosquash markers. */
12const LEAVE_ALONE = /^(Merge |Revert "|fixup! |squash! |amend! )/
13
14const ATTRIBUTION = [/^\s*co-authored-by:/i, /^\W*generated (with|by) /i]
15
16/** Drops `Co-Authored-By:` trailers and "Generated with ..." lines. */
17export function stripAttribution(message: string): { message: string; removed: number } {
18 const lines = message.split('\n')
19 const kept = lines.filter(line => !ATTRIBUTION.some(pattern => pattern.test(line)))
20 const cleaned = kept
21 .join('\n')
22 .replace(/[ \t]+$/gm, '')
23 .replace(/\n{3,}/g, '\n\n')
24 .trim()
25
26 return { message: cleaned, removed: lines.length - kept.length }
27}
28
29export function split(message: string): { subject: string; body: string } {
30 const [subject = '', ...rest] = message.trim().split('\n')
31
32 return { subject: subject.trim(), body: rest.join('\n').trim() }
33}
34
35/** Why a subject needs rewriting, or undefined when it is fine (or not ours to touch). */
36export function laziness(subject: string): string | undefined {
37 const bare = subject.trim()
38
39 if (bare === '' || LEAVE_ALONE.test(bare)) {
40 return undefined
41 }
42
43 if (LAZY.has(bare.toLowerCase().replace(/[.!]+$/, ''))) {
44 return 'lazy'
45 }
46
47 if (!/\s/.test(bare)) {
48 return 'one word'
49 }
50
51 if (bare.length > 72) {
52 return 'over 72 characters'
53 }
54
55 if (!CONVENTIONAL.test(bare)) {
56 return 'no conventional prefix'
57 }
58
59 return undefined
60}
61
62export function buildPrompt(original: string, diff: string): string {
63 return [
64 'Write a git commit message in the Conventional Commits format for the change below.',
65 '',
66 'Line 1: type(optional-scope): subject',
67 '- type is one of feat, fix, chore, docs, refactor, test, perf, build, ci, style',
68 '- imperative mood, lowercase after the colon, no trailing period, 72 characters at most, specific to this diff',
69 'Then, only if the why is not obvious from the subject: a blank line and a body of one to three short lines.',
70 'No Co-Authored-By lines, no "Generated with" lines, no quotes, no code fences. Reply with the message only.',
71 '',
72 `The author's own message, as a hint at intent: ${JSON.stringify(original)}`,
73 '',
74 '<diff>',
75 diff,
76 '</diff>',
77 ].join('\n')
78}
79
80/** The model's reply as a subject and body, or undefined when the subject does not pass. */
81export function readReply(reply: string): { subject: string; body: string } | undefined {
82 const bare = reply
83 .trim()
84 .replace(/^```[a-z]*\n?|```$/g, '')
85 .trim()
86 const { message } = stripAttribution(bare)
87 const { subject, body } = split(message)
88 const clean = subject.replace(/^["'`]+|["'`]+$/g, '').replace(/\.$/, '').trim()
89
90 if (!CONVENTIONAL.test(clean) || clean.length > 72) {
91 return undefined
92 }
93
94 return { subject: clean, body: wrap(body, 72) }
95}
96
97/** Wraps each paragraph of a body at `width` columns, as git log reads best. */
98export function wrap(text: string, width: number): string {
99 return text
100 .split('\n')
101 .map(line => {
102 const out: string[] = []
103 let current = ''
104
105 for (const word of line.split(' ')) {
106 if (current !== '' && current.length + 1 + word.length > width) {
107 out.push(current)
108 current = word
109 } else {
110 current = current === '' ? word : `${current} ${word}`
111 }
112 }
113
114 return [...out, current].join('\n')
115 })
116 .join('\n')
117}
118
119/** Cuts a diff to what a fast model needs: the stat whole, the patch up to `max` characters. */
120export function trimDiff(stat: string, patch: string, max = 8000): string {
121 const cut = patch.length > max ? `${patch.slice(0, max)}\n... (diff cut at ${max} characters)` : patch
122
123 return [stat.trim(), cut.trim()].filter(Boolean).join('\n\n')
124}
125hooks/shell.ts 316 lines1// A small shell lexer that keeps where every token sits in the source, so a
2// `git commit` message can be cut out of a command and a new one put in.
3
4export type Word = { kind: 'word'; value: string; start: number; end: number; isDynamic: boolean }
5export type Op = { kind: 'op'; value: string; start: number; end: number }
6export type Heredoc = {
7 kind: 'heredoc'
8 /** The `<<'EOF'` text. */
9 start: number
10 end: number
11 delimiter: string
12 body: string
13 /** The body's lines and the delimiter line, which come after the command's newline. */
14 bodyStart: number
15 bodyEnd: number
16 isDynamic: boolean
17 strip: boolean
18}
19export type Token = Word | Op | Heredoc
20
21const OPS2 = ['&&', '||', '>>', '>&', '&>', '|&']
22const OPS1 = [';', '|', '&', '(', ')', '<', '>']
23const NAME = /[A-Za-z_0-9@*#?$!{-]/
24
25/** `$(cat <<'EOF' ... EOF\n)`: the way agents pass a multi-line commit message. */
26const CAT_HEREDOC = /^\$\(\s*cat\s*<<-?\s*(['"]?)([A-Za-z_][\w-]*)\1[ \t]*\n/
27
28/** Where a `$(` substitution closes, counting nested parens and skipping quotes. */
29function closeParen(src: string, from: number): number {
30 let depth = 0
31
32 for (let i = from; i < src.length; i += 1) {
33 const char = src[i]
34
35 if (char === "'" || char === '"') {
36 const end = src.indexOf(char, i + 1)
37 i = end === -1 ? src.length : end
38 } else if (char === '(') {
39 depth += 1
40 } else if (char === ')') {
41 depth -= 1
42
43 if (depth === 0) {
44 return i
45 }
46 }
47 }
48
49 return src.length - 1
50}
51
52/** Reads `$(cat <<'EOF' ...)` at `at`: the body and where the substitution ends, or undefined. */
53function catHeredoc(src: string, at: number): { body: string; next: number; isDynamic: boolean } | undefined {
54 const head = CAT_HEREDOC.exec(src.slice(at))
55
56 if (head === null) {
57 return undefined
58 }
59
60 const delimiter = head[2] ?? ''
61 const lines: string[] = []
62 let i = at + head[0].length
63
64 while (i < src.length) {
65 const lineEnd = src.indexOf('\n', i) === -1 ? src.length : src.indexOf('\n', i)
66 const line = src.slice(i, lineEnd)
67
68 if (line.trim() === delimiter) {
69 const close = /^\s*\)/.exec(src.slice(lineEnd))
70
71 return close === null ? undefined : { body: lines.join('\n'), next: lineEnd + close[0].length, isDynamic: head[1] === '' && /[$`]/.test(lines.join('\n')) }
72 }
73
74 lines.push(line)
75 i = lineEnd + 1
76 }
77
78 return undefined
79}
80
81export function lex(src: string): Token[] {
82 const tokens: Token[] = []
83 const pending: Heredoc[] = []
84 let word: Word | null = null
85 let i = 0
86
87 const begin = (): Word => {
88 word ??= { kind: 'word', value: '', start: i, end: i, isDynamic: false }
89
90 return word
91 }
92 const flush = () => {
93 if (word !== null) {
94 word.end = i
95 tokens.push(word)
96 word = null
97 }
98 }
99
100 while (i < src.length) {
101 const char = src[i] ?? ''
102 const pair = src.slice(i, i + 2)
103
104 if (char === '\\') {
105 if (src[i + 1] !== '\n') {
106 begin().value += src[i + 1] ?? ''
107 }
108
109 i += 2
110 } else if (char === "'") {
111 const current = begin()
112 const end = src.indexOf("'", i + 1) === -1 ? src.length : src.indexOf("'", i + 1)
113 current.value += src.slice(i + 1, end)
114 i = end + 1
115 } else if (char === '"') {
116 const current = begin()
117 i += 1
118
119 while (i < src.length && src[i] !== '"') {
120 const inner = src[i] ?? ''
121
122 if (inner === '\\' && '"\\$`\n'.includes(src[i + 1] ?? '')) {
123 current.value += src[i + 1] === '\n' ? '' : (src[i + 1] ?? '')
124 i += 2
125 } else if (src.startsWith('$(', i)) {
126 const heredoc = catHeredoc(src, i)
127
128 if (heredoc === undefined) {
129 const close = closeParen(src, i + 1)
130 current.value += src.slice(i, close + 1)
131 current.isDynamic = true
132 i = close + 1
133 } else {
134 current.value += heredoc.body
135 current.isDynamic ||= heredoc.isDynamic
136 i = heredoc.next
137 }
138 } else if ((inner === '$' && NAME.test(src[i + 1] ?? '')) || inner === '`') {
139 current.value += inner
140 current.isDynamic = true
141 i += 1
142 } else {
143 current.value += inner
144 i += 1
145 }
146 }
147
148 i += 1
149 } else if (pair === "$'") {
150 // ANSI-C quoting: $'line one\nline two'
151 const current = begin()
152 i += 2
153
154 while (i < src.length && src[i] !== "'") {
155 if (src[i] === '\\') {
156 const escaped = src[i + 1] ?? ''
157 current.value += escaped === 'n' ? '\n' : escaped === 't' ? '\t' : escaped
158 i += 2
159 } else {
160 current.value += src[i]
161 i += 1
162 }
163 }
164
165 i += 1
166 } else if (char === '$' || char === '`') {
167 const current = begin()
168 current.isDynamic = true
169
170 if (pair === '$(') {
171 const close = closeParen(src, i + 1)
172 current.value += src.slice(i, close + 1)
173 i = close + 1
174 } else if (char === '`') {
175 const end = src.indexOf('`', i + 1) === -1 ? src.length : src.indexOf('`', i + 1)
176 current.value += src.slice(i, end + 1)
177 i = end + 1
178 } else {
179 current.value += char
180 i += 1
181 }
182 } else if (char === '#' && word === null) {
183 while (i < src.length && src[i] !== '\n') {
184 i += 1
185 }
186 } else if (char === ' ' || char === '\t') {
187 flush()
188 i += 1
189 } else if (char === '\n') {
190 flush()
191 tokens.push({ kind: 'op', value: '\n', start: i, end: i + 1 })
192 i += 1
193
194 for (const heredoc of pending.splice(0)) {
195 const lines: string[] = []
196 heredoc.bodyStart = i
197
198 while (i < src.length) {
199 const lineEnd = src.indexOf('\n', i) === -1 ? src.length : src.indexOf('\n', i)
200 const line = src.slice(i, lineEnd)
201 const bare = heredoc.strip ? line.replace(/^\t+/, '') : line
202 i = Math.min(lineEnd + 1, src.length)
203
204 if (bare === heredoc.delimiter) {
205 break
206 }
207
208 lines.push(bare)
209 }
210
211 heredoc.bodyEnd = i
212 heredoc.body = lines.join('\n')
213 heredoc.isDynamic &&= /[$`]/.test(heredoc.body)
214 }
215 } else if (src.startsWith('<<<', i)) {
216 flush()
217 tokens.push({ kind: 'op', value: '<<<', start: i, end: i + 3 })
218 i += 3
219 } else if (pair === '<<') {
220 flush()
221 const start = i
222 i += 2
223 const strip = src[i] === '-'
224 i += strip ? 1 : 0
225
226 while (src[i] === ' ' || src[i] === '\t') {
227 i += 1
228 }
229
230 const quote = src[i] === "'" || src[i] === '"' ? src[i] : ''
231 i += quote === '' ? 0 : 1
232 let delimiter = ''
233
234 while (i < src.length && (quote === '' ? !/[\s;&|<>()]/.test(src[i] ?? '') : src[i] !== quote)) {
235 delimiter += src[i]
236 i += 1
237 }
238
239 i += quote === '' ? 0 : 1
240 const heredoc: Heredoc = { kind: 'heredoc', start, end: i, delimiter, body: '', bodyStart: i, bodyEnd: i, isDynamic: quote === '', strip }
241 tokens.push(heredoc)
242 pending.push(heredoc)
243 } else if (OPS2.includes(pair)) {
244 flush()
245 tokens.push({ kind: 'op', value: pair, start: i, end: i + 2 })
246 i += 2
247 } else if (OPS1.includes(char)) {
248 flush()
249 tokens.push({ kind: 'op', value: char, start: i, end: i + 1 })
250 i += 1
251 } else {
252 begin().value += char
253 i += 1
254 }
255 }
256
257 flush()
258
259 return tokens
260}
261
262const SEPARATORS = new Set(['&&', '||', ';', '|', '&', '\n', '(', ')', '|&'])
263const REDIRECTS = new Set(['<', '>', '>>', '>&', '&>', '<<<'])
264
265/** The simple commands of a token list: words and heredocs, redirections and their targets left out. */
266export function simpleCommands(tokens: readonly Token[]): { words: Word[]; heredocs: Heredoc[] }[] {
267 const commands: { words: Word[]; heredocs: Heredoc[] }[] = []
268 let words: Word[] = []
269 let heredocs: Heredoc[] = []
270
271 const end = () => {
272 if (words.length > 0) {
273 commands.push({ words, heredocs })
274 }
275
276 words = []
277 heredocs = []
278 }
279
280 for (let i = 0; i < tokens.length; i += 1) {
281 const token = tokens[i]
282
283 if (token === undefined) {
284 continue
285 }
286
287 if (token.kind === 'heredoc') {
288 heredocs.push(token)
289 } else if (token.kind === 'op' && SEPARATORS.has(token.value)) {
290 end()
291 } else if (token.kind === 'op' && REDIRECTS.has(token.value)) {
292 // `2>` leaves a bare fd number as the word before it; the target is the word after.
293 const before = words.at(-1)
294
295 if (before !== undefined && before.end === token.start && /^\d+$/.test(before.value)) {
296 words.pop()
297 }
298
299 if (tokens[i + 1]?.kind === 'word') {
300 i += 1
301 }
302 } else if (token.kind === 'word') {
303 words.push(token)
304 }
305 }
306
307 end()
308
309 return commands
310}
311
312/** Single-quotes a value for the shell: it then means exactly itself. */
313export function quote(value: string): string {
314 return `'${value.replace(/'/g, `'\\''`)}'`
315}
316