Setzt Clean-Code-Regeln als Hooks um: Lint-Hinweis nach Code-Änderungen, Referenzordner mit Quellcode der Bibliotheken und Schutz für CLAUDE.md, AGENTS.md und…

Eine Mod für Claude Code, die Claude bei sauberem Code an den Regeln aus dem Video Sauberer Code mit Claude Code von Moritz Brandes ausrichtet. Sie erzwingt, was sich erzwingen lässt, und sagt Claude den Rest in jeder Session.
Version 0.1.0 · Autor Wilhelm Peters · Lizenz MIT
package.json mit einem lint-Skript, .oxlintrc.json, oxlint.config.ts (so schreibt es Ultracite), biome.json, pyproject.toml oder Cargo.toml..claude/skills/, .claude/agents/ und .claude/rules/ davor schützen, dass Claude sie ohne Nachfrage ändert.Nur R1, R4 und R8 haben zusätzlich einen Hook. Die übrigen Regeln wirken über den Systemprompt und sind damit Bitten an Claude, keine Prüfung.
| Regel aus dem Video | Was die Mod tut |
|---|---|
| R1 Nach Code-Änderung Linter laufen lassen und Befunde beheben | Hinweis nach Code-Änderungen mit dem erkannten Lint-Befehl, auch wenn Claude die Datei per Bash schreibt (siehe Grenzen). Ohne Linter folgt der Hinweis auf Typprüfung oder Tests und den Vorschlag, einen strengen Linter einzurichten. |
R2 Streng typisieren (TypeScript statt JavaScript, strict an) | Regel im Systemprompt. Dazu hängt die Mod an den Lint-Hinweis einen strict-Satz, wenn die tsconfig.json des Projekts "strict": true nicht setzt. Erbt die Datei per extends, kommt kein Hinweis, weil strict dort herkommen kann. |
| R3 Wiederkehrende Fehler als Lint-Regel, nicht als Prosa | Regel im Systemprompt |
| R4 Referenzordner mit flachen Checkouts zuerst lesen | Befehle /ref-add und /refs, Liste der Bibliotheken im Systemprompt |
| R5 Idiomatischer Code im Stil des Frameworks | Regel im Systemprompt |
| R6 Agentenfreundliche, explizite Technik wählen und das Ökosystem prüfen | Regel im Systemprompt |
| R7 Die eleganteste, kompakteste und lesbarste Lösung wählen | Regel im Systemprompt |
| R8 CLAUDE.md, AGENTS.md und Skills nicht automatisch von Claude pflegen lassen | Nachfrage vor jeder Änderung, auch bei Bash-Befehlen, die die Datei schreiben. Im Rechtemodus auto oder bypassPermissions sieht niemand die Nachfrage, dort sperrt die Mod stattdessen. Einstellbar als Sperre oder ganz aus. |
| R9 Kontext sauber halten, kurze Anweisungen | Der Regelabschnitt im Systemprompt bleibt unter 1.200 Zeichen. |
| Befehl | Wirkung |
|---|---|
/ref-add <git-url> | Holt das Repository als flachen Checkout (git clone --depth 1) in den Referenzordner. Gibt es das Ziel schon, meldet die Mod das und klont nicht erneut. Erlaubt sind https://- und git@-Adressen. |
/refs | Listet die Ordner im Referenzordner auf. |
| Einstellung | Standard | Bedeutung |
|---|---|---|
referencesDir | ~/references | Ordner für die flachen Checkouts |
lintHint | true | Prüfhinweis nach Code-Änderungen |
guardInstructionFiles | fragen | Schutz für CLAUDE.md, AGENTS.md und Skills. fragen bedeutet, Claude fragt vor jeder Änderung. sperren verbietet die Änderung. aus schaltet den Schutz ab. |
Die Einstellungen fragt Claude bei der Installation ab. Enter übernimmt jeweils den Standard.
Die Mod steckt im Marketplace willis-mods.
In einer Claude-Code-Session im Terminal:
/plugin install clean-code --marketplace ptrzptrz888-create/claude-mods
Die Frage „Add marketplace?" mit y bestätigen und als Scope „user" wählen. Danach zeigt Claude die Einstellungen der Mod, Enter übernimmt jeweils den Standard bis „Save configuration". Anschließend läuft die Mod in jeder neuen Session, auch in der Desktop-App.
Im Code-Tab der Desktop-App und in claude -p gibt es den Befehl /plugin nicht. Dort, oder ganz ohne laufende Session, installierst du mit diesen zwei Befehlen im Terminal:
claude plugin marketplace add ptrzptrz888-create/claude-mods
claude plugin install clean-code@willis-mods --scope user
Die Mod übernimmt nicht alles. Vier Punkte musst du selbst erledigen.
/ref-add <git-url> in den Ordner aus referencesDir.strict in der Konfiguration. Die Mod richtet das nicht ein.CLAUDE_CODE_DISABLE_1M_CONTEXT=1 (Quelle: code.claude.com/docs/en/env-vars) und begrenzt den Kontext auf 200K. Die Mod setzt sie nicht.>, >>), sed -i, tee, mv oder rm. Das ist eine Textanalyse des Befehls, keine Ausführung.python -c, node -e, Skripte, die selbst Dateien schreiben, und Befehlssubstitution wie $(…). Wer die Sperre umgehen will, kann das also weiterhin.ask) geht an den Entscheider des Rechtemodus, im Modus auto an den Klassifizierer statt an die Person. Der hat in einem Test am 09.10.2026 eine Änderung an CLAUDE.md still durchgelassen. Deshalb sperrt die Mod in auto und bypassPermissions. Den Modus liest sie aus den klassischen Hooks UserPromptSubmit und PostToolUse, weil PreToolUse ihn nicht mitliefert. Wechselt der Modus mitten im Turn, gilt der neue erst ab dem nächsten Werkzeugaufruf.claude plugin test .
bun test allein reicht hier nicht, weil die Tests das Modul claude-code/testing brauchen, das nur claude plugin test bereitstellt.
hooks/register.ts 230 lines1import type { EngineInterface, PromptComposeSection, Register } from 'claude-code'
2
3import {
4 detectLintCommand,
5 directoryNames,
6 expandHome,
7 instructionGuardText,
8 isCodeFile,
9 isInstructionFile,
10 isUnattendedMode,
11 lintHintText,
12 refsListText,
13 repoNameOf,
14 rulesSection,
15 strictHintText,
16 unattendedGuardText,
17 writtenPathsOf,
18} from './rules'
19import type { DirectoryEntry } from './rules'
20
21const DEFAULT_REFERENCES_DIR = '~/references'
22const CLONE_TIMEOUT_MS = 120_000
23const STDERR_LIMIT = 500
24const RULES_SECTION_ID = 'clean-code:rules'
25
26type GuardMode = 'fragen' | 'sperren' | 'aus'
27
28type Settings = {
29 readonly referencesDir: string
30 readonly isLintHintOn: boolean
31 readonly guardMode: GuardMode
32}
33
34type ProjectProbe = {
35 readonly command: string | undefined
36 readonly tsconfig: string | undefined
37}
38
39const asGuardMode = (value: unknown): GuardMode => (value === 'sperren' || value === 'aus' ? value : 'fragen')
40
41const settingsOf = (options: Record<string, unknown>): Settings => {
42 const referencesDir = String(options.referencesDir ?? '').trim()
43
44 return {
45 referencesDir: referencesDir === '' ? DEFAULT_REFERENCES_DIR : referencesDir,
46 isLintHintOn: options.lintHint !== false,
47 guardMode: asGuardMode(options.guardInstructionFiles),
48 }
49}
50
51// Module state starts over on a reload, which suits all three below.
52/** Ob der Lint-Hinweis in diesem Turn schon gegeben wurde. */
53let isHintGivenThisTurn = false
54/** Lint-Befehl und tsconfig der Session, einmal ermittelt. Beide Felder können `undefined` sein. */
55let lintCache: ProjectProbe | undefined
56/** Der zuletzt gemeldete Rechtemodus. classic.PreToolUse trägt ihn nicht, die übrigen klassischen Hooks schon. */
57let permissionMode: string | undefined
58
59/** Rückfrage mit `askText`, oder Sperre mit `denyText`, wenn im Modus niemand rückfragt. */
60const guardAnswer = (askText: string, denyText: string): { ask: string } | { deny: string } =>
61 isUnattendedMode(permissionMode) ? { deny: denyText } : { ask: askText }
62
63const referencesPath = async ($: EngineInterface, referencesDir: string): Promise<string> => {
64 const home = await $.env.get('HOME')
65
66 return home === undefined ? referencesDir : expandHome(referencesDir, home)
67}
68
69/** Die Einträge des Referenzordners, `undefined` wenn der Ordner fehlt. */
70const listEntries = async ($: EngineInterface, dir: string): Promise<DirectoryEntry[] | undefined> => {
71 if (!(await $.fs.exists(dir))) return undefined
72
73 return $.fs.list(dir)
74}
75
76/** Die Bibliotheksnamen für den Systemprompt. Fehler ergeben eine leere Liste und werden ins Debug-Log geschrieben. */
77const referenceNamesOf = async ($: EngineInterface, referencesDir: string): Promise<string[]> => {
78 try {
79 const entries = await listEntries($, await referencesPath($, referencesDir))
80
81 return entries === undefined ? [] : directoryNames(entries)
82 } catch (error) {
83 $.ui.log(`clean-code: Referenzordner nicht gelesen (${String(error)}).`, { to: 'debug' })
84
85 return []
86 }
87}
88
89const addReference = async ($: EngineInterface, referencesDir: string, args: string): Promise<string> => {
90 const url = args.trim()
91 if (url === '') return 'Verwendung: /ref-add <git-url>, zum Beispiel https://github.com/owner/repo.'
92
93 const name = repoNameOf(url)
94 if (name === undefined) return 'Ungültige Git-URL. Erlaubt sind https://… und git@…:… .'
95
96 const dir = await referencesPath($, referencesDir)
97 const target = `${dir}/${name}`
98 if (await $.fs.exists(target)) return `Schon vorhanden: ${target}. Zum Aktualisieren dort von Hand git pull ausführen.`
99
100 const result = await $.process.run(['git', 'clone', '--depth', '1', url, target], { timeoutMs: CLONE_TIMEOUT_MS })
101 if (result.exitCode !== 0) {
102 return `git clone endete mit Code ${result.exitCode}: ${result.stderr.trim().slice(0, STDERR_LIMIT)}`
103 }
104
105 return `Geholt: ${target}`
106}
107
108const listReferences = async ($: EngineInterface, referencesDir: string): Promise<string> => {
109 const dir = await referencesPath($, referencesDir)
110 const entries = await listEntries($, dir)
111
112 return refsListText(entries === undefined ? undefined : directoryNames(entries), dir)
113}
114
115/** Lint-Befehl und tsconfig, einmal pro Session ermittelt. Fehler ergeben leere Werte und den allgemeinen Hinweis. */
116const projectProbeOf = async ($: EngineInterface): Promise<ProjectProbe> => {
117 if (lintCache === undefined) {
118 lintCache = await detectProject($)
119 }
120
121 return lintCache
122}
123
124/** Der Text einer Datei im Projekt, `undefined` wenn sie nicht in der Liste steht. */
125const readIfListed = async ($: EngineInterface, files: readonly string[], name: string): Promise<string | undefined> =>
126 files.includes(name) ? $.fs.read(name) : undefined
127
128const detectProject = async ($: EngineInterface): Promise<ProjectProbe> => {
129 try {
130 const files = (await $.fs.list()).map(entry => entry.name)
131 const packageJson = await readIfListed($, files, 'package.json')
132 const tsconfig = await readIfListed($, files, 'tsconfig.json')
133
134 return { command: detectLintCommand({ packageJson, files }), tsconfig }
135 } catch (error) {
136 $.ui.log(`clean-code: Lint-Befehl nicht ermittelt (${String(error)}).`, { to: 'debug' })
137
138 return { command: undefined, tsconfig: undefined }
139 }
140}
141
142/** Lint-Hinweis, bei fehlendem strict um den Typhinweis ergänzt. */
143const lintContextOf = async ($: EngineInterface): Promise<string> => {
144 const { command, tsconfig } = await projectProbeOf($)
145 const strictHint = strictHintText(tsconfig)
146
147 return strictHint === undefined ? lintHintText(command) : `${lintHintText(command)} ${strictHint}`
148}
149
150export const register: Register = (on, options) => {
151 const settings = settingsOf(options)
152
153 on('session.start', async ($, e, next) => {
154 lintCache = undefined
155 await $.command.register({
156 name: 'ref-add',
157 description: 'Bibliothek als flachen Checkout in den Referenzordner holen',
158 argumentHint: '<git-url>',
159 })
160 await $.command.register({ name: 'refs', description: 'Referenzordner auflisten' })
161
162 return next(e)
163 })
164
165 on('turn.start', ($, e, next) => {
166 isHintGivenThisTurn = false
167
168 return next(e)
169 })
170
171 on('command.run', { command: 'ref-add' }, async ($, e) => ({
172 text: await addReference($, settings.referencesDir, e.args),
173 }))
174
175 on('command.run', { command: 'refs' }, async $ => ({
176 text: await listReferences($, settings.referencesDir),
177 }))
178
179 on('prompt.compose', async ($, e, next) => {
180 const composed = await next(e)
181 const section: PromptComposeSection = {
182 id: RULES_SECTION_ID,
183 text: rulesSection(await referenceNamesOf($, settings.referencesDir), settings.referencesDir),
184 scope: 'session',
185 }
186
187 return { sections: [...composed.sections, section] }
188 }).catch(($, e, next) => next(e))
189
190 on('tool.call', async ($, e, next) => {
191 const paths = writtenPathsOf(e)
192 if (paths.length === 0) return next(e)
193 const guarded = paths.find(isInstructionFile)
194 if (settings.guardMode === 'sperren' && guarded !== undefined) return { deny: instructionGuardText(guarded) }
195
196 const ran = await next(e)
197 const isHintDue = settings.isLintHintOn && paths.some(isCodeFile) && !isHintGivenThisTurn
198 if (!isHintDue || ran.deny !== undefined || ran.isError === true) return ran
199
200 isHintGivenThisTurn = true
201
202 return { ...ran, context: [...(ran.context ?? []), await lintContextOf($)] }
203 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'clean-code: Prüfung fehlgeschlagen.' }))
204
205 // Ein ask geht an den Entscheider des Modus, im Modus auto an den Klassifizierer. Deshalb den Modus mitlesen.
206 on('classic.UserPromptSubmit', ($, e, next) => {
207 permissionMode = e.permission_mode ?? permissionMode
208
209 return next(e)
210 }).catch(($, e, next) => next(e))
211
212 on('classic.PostToolUse', ($, e, next) => {
213 permissionMode = e.permission_mode ?? permissionMode
214
215 return next(e)
216 }).catch(($, e, next) => next(e))
217
218 // classic.PreToolUse trägt denselben ToolCallEnvelope wie tool.call, also auch tool und command bei Bash.
219 on('classic.PreToolUse', async ($, e, next) => {
220 const guarded = writtenPathsOf(e).find(isInstructionFile)
221 if (settings.guardMode !== 'fragen' || guarded === undefined) return next(e)
222
223 return guardAnswer(instructionGuardText(guarded), unattendedGuardText(guarded))
224 }).catch(($, e, next) =>
225 next.called
226 ? next(e)
227 : guardAnswer('clean-code: Prüfung fehlgeschlagen. Bitte die Änderung selbst bestätigen.', 'clean-code: Prüfung fehlgeschlagen.'),
228 )
229}
230hooks/rules.ts 224 lines1import { bashModifiedPaths } from './bash-paths'
2
3/** Dateiendungen, die als Code gelten und den Lint-Hinweis auslösen. */
4export const CODE_EXTENSIONS = [
5 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts',
6 'py', 'go', 'rs', 'swift', 'kt', 'java', 'cs', 'php', 'rb', 'vue', 'svelte',
7] as const
8
9/** Obergrenze für den Systemprompt-Abschnitt (R9). */
10export const SECTION_LIMIT = 1200
11
12/** Dateinamen, die nur von Hand gepflegt werden sollen (R8). Kleingeschrieben. */
13const INSTRUCTION_FILE_NAMES: ReadonlySet<string> = new Set([
14 'claude.md', 'claude.local.md', 'agents.md', 'skill.md',
15])
16
17/** Ordner, deren Inhalt als Anweisung an Claude gilt (R8). */
18const INSTRUCTION_DIRS = ['/.claude/skills/', '/.claude/agents/', '/.claude/rules/'] as const
19
20const CODE_EXTENSION_SET: ReadonlySet<string> = new Set(CODE_EXTENSIONS)
21
22const WRITE_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write', 'MultiEdit'])
23
24const SCP_URL = /^git@[^:/\s]+:(.+)$/
25const REPO_NAME = /^[A-Za-z0-9._-]+$/
26const GIT_SUFFIX = /\.git$/
27
28const RULE_HEADING = '# Clean Code (Mod clean-code)'
29
30const RULE_LINES = [
31 '- R1 Nach jeder Code-Änderung den Linter laufen lassen und alle Befunde beheben.',
32 '- R2 Streng typisieren, TypeScript mit strict statt JavaScript.',
33 '- R3 Wiederkehrende Fehler als Lint-Regel festhalten, nicht als Prosa im Prompt.',
34 '- R4 Bei Fragen zu einer Bibliothek zuerst den Quellcode im Referenzordner oder in node_modules lesen.',
35 '- R5 Idiomatisch im Stil des Frameworks schreiben.',
36 '- R6 Agentenfreundliche, explizite Techniken wählen und das Ökosystem prüfen.',
37 '- R7 Die eleganteste, kompakteste und lesbarste Lösung wählen, nicht die aufgeblähte.',
38 '- R8 CLAUDE.md, AGENTS.md und Skills nicht ohne Freigabe der Person ändern.',
39] as const
40
41type LintRule = {
42 readonly when: (probe: LintProbe) => boolean
43 readonly command: string
44}
45
46export type LintProbe = {
47 readonly packageJson?: string
48 readonly files: readonly string[]
49}
50
51export type DirectoryEntry = {
52 readonly name: string
53 readonly kind: 'file' | 'dir' | 'other'
54}
55
56const isRecord = (value: unknown): value is Record<string, unknown> =>
57 typeof value === 'object' && value !== null && !Array.isArray(value)
58
59const baseNameOf = (path: string): string => path.split('/').pop()?.toLowerCase() ?? ''
60
61/** Ob die Datei Code ist, nach der Endung, Groß- und Kleinschreibung egal. */
62export const isCodeFile = (path: string): boolean => {
63 const extension = /\.([a-z]+)$/i.exec(path)?.[1]?.toLowerCase()
64
65 return extension !== undefined && CODE_EXTENSION_SET.has(extension)
66}
67
68/** Ob die Datei eine Anweisungsdatei ist, die nur von Hand geändert werden soll (R8). */
69export const isInstructionFile = (path: string): boolean => {
70 if (INSTRUCTION_FILE_NAMES.has(baseNameOf(path))) return true
71 const anchored = `/${path}`
72
73 return INSTRUCTION_DIRS.some(dir => anchored.includes(dir))
74}
75
76/** Ersetzt ein führendes `~` durch das Home-Verzeichnis. */
77export const expandHome = (path: string, home: string): string => {
78 if (path === '~') return home
79 if (path.startsWith('~/')) return `${home}${path.slice(1)}`
80
81 return path
82}
83
84/** Der Pfad einer https- oder git@-URL, sonst `undefined`. */
85const pathOf = (url: string): string | undefined => {
86 const scp = SCP_URL.exec(url)
87 if (scp?.[1] !== undefined) return scp[1]
88
89 try {
90 const parsed = new URL(url)
91
92 return parsed.protocol === 'https:' ? parsed.pathname : undefined
93 } catch {
94 return undefined
95 }
96}
97
98/** Der Name des Repos aus einer Git-URL, ohne `.git`. Unbekannte Formen ergeben `undefined`. */
99export const repoNameOf = (url: string): string | undefined => {
100 const path = pathOf(url)
101 if (path === undefined) return undefined
102
103 const segment = path.split('/').filter(part => part !== '').pop()
104 const name = segment?.replace(GIT_SUFFIX, '')
105 const isUsable = name !== undefined && name !== '.' && name !== '..' && REPO_NAME.test(name)
106
107 return isUsable ? name : undefined
108}
109
110const hasFile = (probe: LintProbe, name: string): boolean => probe.files.includes(name)
111
112/** Ob die package.json ein `scripts.lint` hat. Kaputtes JSON gilt als „nein“. */
113const hasLintScript = (packageJson: string | undefined): boolean => {
114 if (packageJson === undefined) return false
115
116 try {
117 const parsed: unknown = JSON.parse(packageJson)
118
119 return isRecord(parsed) && isRecord(parsed.scripts) && typeof parsed.scripts.lint === 'string'
120 } catch {
121 return false
122 }
123}
124
125const OXLINT_CONFIG_FILES: readonly string[] = [
126 '.oxlintrc.json', 'oxlint.config.ts', 'oxlint.config.mts', 'oxlint.config.js', 'oxlint.config.mjs',
127]
128
129const LINT_RULES: readonly LintRule[] = [
130 { when: probe => hasLintScript(probe.packageJson), command: 'npm run lint' },
131 { when: probe => OXLINT_CONFIG_FILES.some(file => hasFile(probe, file)), command: 'npx oxlint' },
132 { when: probe => hasFile(probe, 'biome.json') || hasFile(probe, 'biome.jsonc'), command: 'npx biome check' },
133 { when: probe => hasFile(probe, 'ruff.toml') || hasFile(probe, 'pyproject.toml'), command: 'ruff check' },
134 { when: probe => hasFile(probe, 'Cargo.toml'), command: 'cargo clippy' },
135 { when: probe => hasFile(probe, 'go.mod'), command: 'go vet ./...' },
136]
137
138/** Der Lint-Befehl des Projekts nach fester Reihenfolge, sonst `undefined`. */
139export const detectLintCommand = (probe: LintProbe): string | undefined =>
140 LINT_RULES.find(rule => rule.when(probe))?.command
141
142/** Der Hinweis nach einer Code-Änderung (R1). */
143export const lintHintText = (command?: string): string =>
144 command === undefined
145 ? 'Kein Linter erkannt. Vor dem Abschluss Typprüfung oder Tests laufen lassen und einen strengen Linter vorschlagen (etwa oxlint mit dem Preset Ultracite und strict).'
146 : `Code geändert. Vor dem Abschluss \`${command}\` ausführen und alle Befunde beheben. Wiederkehrende Fehler als Lint-Regel vorschlagen statt als Prosa.`
147
148const referenceText = (refs: readonly string[], refsDir: string, shownCount: number): string => {
149 if (refs.length === 0) return 'Referenzordner leer, mit /ref-add <git-url> füllen.'
150
151 const shown = refs.slice(0, shownCount).join(', ')
152 const rest = refs.length - shownCount
153 const tail = rest > 0 ? ` … und ${rest} weitere` : ''
154
155 return `Referenzordner ${refsDir}: ${shown}${tail}. Bei Fragen zu diesen Bibliotheken zuerst dort den Quellcode lesen.`
156}
157
158const sectionWith = (refs: readonly string[], refsDir: string, shownCount: number): string =>
159 [RULE_HEADING, ...RULE_LINES, referenceText(refs, refsDir, shownCount)].join('\n')
160
161/**
162 * Der Systemprompt-Abschnitt. Passt die volle Referenzliste nicht in
163 * SECTION_LIMIT, kürzt er die Liste mit „… und N weitere“.
164 */
165export const rulesSection = (refs: readonly string[], refsDir: string): string => {
166 for (let shownCount = refs.length; shownCount > 0; shownCount -= 1) {
167 const section = sectionWith(refs, refsDir, shownCount)
168 if (section.length <= SECTION_LIMIT) return section
169 }
170
171 return sectionWith(refs, refsDir, 0).slice(0, SECTION_LIMIT)
172}
173
174/** Die Begründung für Nachfrage bzw. Sperre einer Anweisungsdatei (R8). */
175export const instructionGuardText = (path: string): string =>
176 `Clean-Code-Regel R8 greift. ${path} wird von Hand gepflegt, nicht von Claude. Änderung nur nach ausdrücklicher Freigabe durch die Person.`
177
178/** Modi, in denen keine Person ein `ask` beantwortet: auto gibt es an den Klassifizierer, bypassPermissions fragt gar nicht. */
179const UNATTENDED_MODES: ReadonlySet<string> = new Set(['auto', 'bypassPermissions'])
180
181/** Ob im Rechtemodus `mode` niemand eine Rückfrage sieht. Ein unbekannter Modus zählt nicht dazu. */
182export const isUnattendedMode = (mode: string | undefined): boolean => mode !== undefined && UNATTENDED_MODES.has(mode)
183
184/** Die Begründung der Sperre, wenn im Modus niemand rückfragen kann (R8). */
185export const unattendedGuardText = (path: string): string =>
186 `${instructionGuardText(path)} In diesem Rechtemodus beantwortet niemand die Rückfrage, deshalb gesperrt. Die Person kann den Modus kurz auf Standard stellen oder die Datei selbst ändern.`
187
188/** Die Pfade, die ein Werkzeugaufruf schreibt. Bash wird über die Befehlsanalyse geprüft, Schreibwerkzeuge über `file_path`. Nimmt jeden Wert, weil die Tool-Union des Engines zu weit für einen engen Parameter ist. */
189export const writtenPathsOf = (call: unknown): string[] => {
190 if (!isRecord(call) || typeof call.tool !== 'string') return []
191 if (call.tool === 'Bash') return typeof call.command === 'string' ? bashModifiedPaths(call.command) : []
192 if (!WRITE_TOOLS.has(call.tool) || typeof call.file_path !== 'string') return []
193
194 return [call.file_path]
195}
196
197const STRICT_HINT = 'tsconfig.json hat kein "strict": true. Für TypeScript strict aktivieren (R2).'
198
199/** Per Regex statt JSON.parse, weil tsconfig Kommentare erlaubt. Bei `extends` kann strict geerbt sein, dann kein Hinweis. */
200const isStrictEnabled = (tsconfig: string): boolean =>
201 /"extends"\s*:/.test(tsconfig) || /"strict"\s*:\s*true/.test(tsconfig)
202
203/** Der Hinweis, wenn die tsconfig strict nicht auf true setzt. `undefined` heißt: kein Hinweis nötig oder keine tsconfig vorhanden. */
204export const strictHintText = (tsconfig: string | undefined): string | undefined => {
205 if (tsconfig === undefined) return undefined
206
207 return isStrictEnabled(tsconfig) ? undefined : STRICT_HINT
208}
209
210/** Die Namen der Unterordner, alphabetisch sortiert. Dateien fallen weg. */
211export const directoryNames = (entries: readonly DirectoryEntry[]): string[] =>
212 entries
213 .filter(entry => entry.kind === 'dir')
214 .map(entry => entry.name)
215 .sort((a, b) => a.localeCompare(b))
216
217/** Die Antwort des Befehls `/refs`. `undefined` heißt: der Ordner fehlt. */
218export const refsListText = (names: readonly string[] | undefined, refsDir: string): string => {
219 if (names === undefined) return `Referenzordner ${refsDir} fehlt. Mit /ref-add <git-url> anlegen.`
220 if (names.length === 0) return `Referenzordner ${refsDir} ist leer. Mit /ref-add <git-url> füllen.`
221
222 return `Referenzordner ${refsDir}:\n${names.map(name => `- ${name}`).join('\n')}`
223}
224hooks/bash-paths.ts 278 lines1/**
2 * Erkennt, welche Pfade ein Bash-Befehl schreibt, anlegt, verschiebt oder löscht.
3 *
4 * Kein vollständiger Shell-Parser. Bekannte Grenzen:
5 * - Erkannt werden tee, sed -i, perl -i, cp, install, mv, rm, touch sowie die Umleitungen
6 * `>`, `>>`, `&>` und `>|`. Alles andere (python -c, node, dd of=, git apply, Editoren) bleibt unerkannt.
7 * - Befehlsersetzungen `$(...)` und Backticks gelten als Trennung. Verschachtelte Ersetzungen
8 * innerhalb doppelter Anführungszeichen werden nicht ausgewertet.
9 * - Keine Auflösung von Variablen, Glob-Mustern oder `~`: `rm *.ts` liefert `*.ts`.
10 * - cp -t und mv -t (Zielordner vorab) liefern das falsche Ziel. install -d meldet nur das letzte Argument.
11 * - mv meldet Quelle und Ziel, weil auch die Quelle verschwindet.
12 * - Die Heredoc-Erkennung kennt keinen Anführungskontext: `echo "<<EOF"` startet einen Heredoc.
13 * - Umleitungen auf /dev/... und Eingabeumleitungen zählen nicht.
14 */
15
16/** Ein Wort mit seiner Position im Befehl, damit Ziele in Textreihenfolge erscheinen. */
17type Word = { readonly value: string; readonly pos: number }
18
19/** Ein einfacher Befehl: seine Argumente und die Ziele seiner Umleitungen. */
20type Command = { readonly words: readonly Word[]; readonly writes: readonly Word[] }
21
22type Token =
23 | { readonly kind: 'word'; readonly value: string }
24 | { readonly kind: 'redirect'; readonly mode: 'write' | 'skip' }
25 | { readonly kind: 'sep' }
26
27const HEREDOC = /(?<!<)<<(?!<)(-?)\s*(['"]?)([A-Za-z_]\w*)\2/g
28const ASSIGNMENT = /^[A-Za-z_]\w*=/
29const DIGITS = /^\d+$/
30const SED_IN_PLACE = /^-[nErsuz]*i/
31const PERL_IN_PLACE = /^-[pnlaFs0-9]*i/
32const SCRIPT_FLAGS: ReadonlySet<string> = new Set(['-e', '-f'])
33const SEPARATOR_CHARS: ReadonlySet<string> = new Set([';', '\n', '(', ')', '`'])
34const DEVICE_PREFIX = '/dev/'
35
36const isOption = (value: string): boolean => value.startsWith('-') && value !== '-'
37
38const basenameOf = (value: string): string => value.split('/').pop() ?? value
39
40/** Entfernt Heredoc-Inhalte, behält aber die Kopfzeile samt Umleitungen. */
41const stripHeredocBodies = (command: string): string => {
42 const pending: { readonly delimiter: string; readonly stripTabs: boolean }[] = []
43 const kept: string[] = []
44
45 for (const line of command.split('\n')) {
46 const open = pending[0]
47 if (open !== undefined) {
48 const candidate = open.stripTabs ? line.replace(/^\t+/, '') : line
49 if (candidate === open.delimiter) pending.shift()
50 continue
51 }
52 for (const match of line.matchAll(HEREDOC)) {
53 pending.push({ delimiter: match[3] ?? '', stripTabs: match[1] === '-' })
54 }
55 kept.push(line.replace(HEREDOC, ' '))
56 }
57
58 return kept.join('\n')
59}
60
61/** Liest einen Anführungsblock ab `start`. `end` zeigt auf das schließende Zeichen. */
62const scanQuoted = (text: string, start: number): { readonly content: string; readonly end: number } => {
63 const isSingle = text.charAt(start) === "'"
64 const closing = isSingle ? "'" : '"'
65 let content = ''
66 let index = start + 1
67
68 while (index < text.length && text.charAt(index) !== closing) {
69 const char = text.charAt(index)
70 if (!isSingle && char === '\\' && index + 1 < text.length) {
71 const escaped = text.charAt(index + 1)
72 content += '"\\$`\n'.includes(escaped) ? escaped : `\\${escaped}`
73 index += 2
74 } else {
75 content += char
76 index += 1
77 }
78 }
79
80 return { content, end: index }
81}
82
83/** Zerlegt den Text in Wörter, Umleitungen und Trenner. Anführungszeichen und Kommentare werden beachtet. */
84const tokenize = (text: string): Token[] => {
85 const tokens: Token[] = []
86 let buffer = ''
87 let hasBuffer = false
88
89 const flush = (): void => {
90 if (hasBuffer) tokens.push({ kind: 'word', value: buffer })
91 buffer = ''
92 hasBuffer = false
93 }
94 const add = (token: Token): void => {
95 flush()
96 tokens.push(token)
97 }
98
99 for (let index = 0; index < text.length; index += 1) {
100 const char = text.charAt(index)
101 const next = text.charAt(index + 1)
102
103 if (char === ' ' || char === '\t' || char === '\r') {
104 flush()
105 } else if (char === '#' && !hasBuffer) {
106 const lineEnd = text.indexOf('\n', index)
107 if (lineEnd === -1) break
108 index = lineEnd - 1
109 } else if (char === '\\') {
110 if (next !== '\n' && next !== '') {
111 buffer += next
112 hasBuffer = true
113 }
114 index += 1
115 } else if (char === "'" || char === '"') {
116 const quoted = scanQuoted(text, index)
117 buffer += quoted.content
118 hasBuffer = true
119 index = quoted.end
120 } else if (char === '>') {
121 if (hasBuffer && DIGITS.test(buffer)) {
122 buffer = ''
123 hasBuffer = false
124 } else {
125 flush()
126 }
127 const writeEnd = next === '>' ? index + 1 : index
128 const marker = text.charAt(writeEnd + 1)
129 tokens.push({ kind: 'redirect', mode: marker === '&' ? 'skip' : 'write' })
130 index = writeEnd + (marker === '&' || marker === '|' ? 1 : 0)
131 } else if (char === '&' && next === '>') {
132 add({ kind: 'redirect', mode: 'write' })
133 index += text.charAt(index + 2) === '>' ? 2 : 1
134 } else if (char === '&' || char === '|') {
135 add({ kind: 'sep' })
136 if (next === char) index += 1
137 } else if (char === '<') {
138 add({ kind: 'redirect', mode: 'skip' })
139 while (text.charAt(index + 1) === '<') index += 1
140 if (text.charAt(index + 1) === '&') index += 1
141 } else if (SEPARATOR_CHARS.has(char)) {
142 add({ kind: 'sep' })
143 } else {
144 buffer += char
145 hasBuffer = true
146 }
147 }
148
149 flush()
150
151 return tokens
152}
153
154/** Gruppiert die Tokens in einfache Befehle. Die Position eines Wortes ist seine Stelle im Token-Strom. */
155const toCommands = (tokens: readonly Token[]): Command[] => {
156 const commands: Command[] = []
157 let words: Word[] = []
158 let writes: Word[] = []
159 let pending = undefined as 'write' | 'skip' | undefined
160
161 const endCommand = (): void => {
162 if (words.length > 0 || writes.length > 0) commands.push({ words, writes })
163 words = []
164 writes = []
165 pending = undefined
166 }
167
168 for (const [pos, token] of tokens.entries()) {
169 if (token.kind === 'sep') {
170 endCommand()
171 } else if (token.kind === 'redirect') {
172 pending = token.mode
173 } else {
174 if (pending === undefined) words.push({ value: token.value, pos })
175 else if (pending === 'write') writes.push({ value: token.value, pos })
176 pending = undefined
177 }
178 }
179 endCommand()
180
181 return commands
182}
183
184/** Überspringt Variablenzuweisungen und ein führendes sudo samt Optionen. */
185const skipPrefix = (words: readonly Word[]): readonly Word[] => {
186 let index = 0
187 let afterSudo = false
188
189 while (index < words.length) {
190 const value = words[index]?.value ?? ''
191 const isPrefix = ASSIGNMENT.test(value) || value === 'sudo' || (afterSudo && isOption(value))
192 if (!isPrefix) break
193 afterSudo = afterSudo || value === 'sudo'
194 index += 1
195 }
196
197 return words.slice(index)
198}
199
200const positionalOf = (args: readonly Word[]): Word[] => args.filter(word => !isOption(word.value))
201
202/** Das letzte von mindestens zwei Argumenten, sonst nichts. */
203const lastOf = (words: readonly Word[]): Word[] => {
204 const last = words[words.length - 1]
205
206 return words.length >= 2 && last !== undefined ? [last] : []
207}
208
209/**
210 * Zielt sed und perl mit In-place-Schalter. Ohne -e/-f ist das erste Argument das Skript,
211 * mit -e/-f sind alle Argumente Dateien. Das leere Suffix von macOS (`-i ''`) wird übersprungen.
212 */
213const inPlaceTargets = (args: readonly Word[], inPlace: RegExp): Word[] => {
214 const positional: Word[] = []
215 let enabled = false
216 let hasScript = false
217 let index = 0
218
219 while (index < args.length) {
220 const word = args[index]
221 const value = word?.value ?? ''
222 index += 1
223
224 if (SCRIPT_FLAGS.has(value)) {
225 hasScript = true
226 index += 1
227 } else if (inPlace.test(value) || value.startsWith('--in-place')) {
228 enabled = true
229 if (value === '-i' && args[index]?.value === '') index += 1
230 } else if (word !== undefined && !isOption(value)) {
231 positional.push(word)
232 }
233 }
234
235 if (!enabled) return []
236
237 return hasScript ? positional : positional.slice(1)
238}
239
240/** Die Ziele, die ein einzelner Befehl über seinen Namen und seine Argumente verändert. */
241const toolWrites = (words: readonly Word[]): Word[] => {
242 const [head, ...args] = skipPrefix(words)
243 if (head === undefined) return []
244
245 switch (basenameOf(head.value)) {
246 case 'tee':
247 case 'rm':
248 case 'touch':
249 case 'mv':
250 return positionalOf(args)
251 case 'cp':
252 case 'install':
253 return lastOf(positionalOf(args))
254 case 'sed':
255 return inPlaceTargets(args, SED_IN_PLACE)
256 case 'perl':
257 return inPlaceTargets(args, PERL_IN_PLACE)
258 default:
259 return []
260 }
261}
262
263const isNonFileTarget = (value: string): boolean => value === '' || value.startsWith(DEVICE_PREFIX)
264
265/** Alle Ziele eines Befehls in Textreihenfolge, ohne Gerätedateien. */
266const writesOf = (command: Command): Word[] =>
267 [...toolWrites(command.words), ...command.writes]
268 .filter(word => !isNonFileTarget(word.value))
269 .sort((a, b) => a.pos - b.pos)
270
271/** Die Pfade, die der Bash-Befehl schreibt, anlegt, verschiebt oder löscht. Ohne Duplikate, in Reihenfolge des Auftretens. */
272export const bashModifiedPaths = (command: string): string[] => {
273 const commands = toCommands(tokenize(stripHeredocBodies(command)))
274 const paths = commands.flatMap(writesOf).map(word => word.value)
275
276 return [...new Set(paths)]
277}
278