SLOPSHOPPER

commit-coach

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

newguardtoastmodelprocess
v0.1.0MITupdated 2026-10-04ShriD5/claude-mods/commit-coach
A shopper browsing a rack in a slop shop
README

commit-coach

Catches the git commit calls Claude makes and fixes two things before they land:

  1. Attribution. 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.
  2. Lazy subjects. A subject like 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.

Install

claude --plugin-dir ./commit-coach

or add this repo as a marketplace and install it with /plugin.

Options

optiondefaultwhat it does
stripAttributiontrueRemove 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.
rewriteSubjectstrueRewrite lazy subjects from the diff.
modelhaikuThe model that writes the new subject: an alias or a full model id.

How it works

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.

Source 4 files
hooks/register.ts 103 lines
1import 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}
103
hooks/commit.ts 206 lines
1// 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}
206
hooks/message.ts 125 lines
1// 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}
125
hooks/shell.ts 316 lines
1// 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