Grill-Assistent: mit /grill stellt Claude bis zu 5 Rueckfragen zu deinem Plan, eine pro Schritt als Band ueber dem Eingabefeld, mit Antwortoptionen…

Mods für Claude Code. Jeder Mod ist ein kleines Plugin aus TypeScript-Hooks. Er ändert Claude Code von innen: ein Band über dem Eingabefeld, ein Seitenfenster, eigene Slash-Befehle oder eine Rückfrage vor Tool-Aufrufen.
| Mod | Was er macht | Befehl |
|---|---|---|
| cache-countdown | Zeigt über dem Prompt, wie lange der Prompt-Cache (1 Stunde) noch warm ist: grün, orange ab 15 Min, rot wenn kalt. Knopf Compact und Schalter Auto: verdichtet 2 Minuten vor kalt von selbst. | /compact-cache |
| blast-radius | Hält gefährliche Shell-Befehle (Bash und PowerShell) an, zum Beispiel rm -rf, Remove-Item -Recurse, git reset --hard, git push --force, DROP TABLE. Fragt vor dem Ausführen nach. Standard aus. | /ward an/aus |
| next-steps | Schlägt nach jeder Antwort drei nächste Schritte vor. Ein Klick schickt den Schritt als Prompt. | /vorschlaege |
| todo-list | Arbeitsliste im Seitenfenster. Aufgaben einreihen, ohne Claude zu unterbrechen. Mit Auto arbeitet Claude sie nacheinander ab. | /todo |
| grill | Stellt bis zu 5 Rückfragen zu deinem Plan, eine nach der anderen, mit Antwortknöpfen und KI-Vorschlag. | /grill [thema] |
Hinweis: next-steps und grill rufen das Modell zusätzlich auf ($.model.fork). Das kostet etwas Kontingent.
Unter Windows in PowerShell:
git clone https://github.com/broening/claude-mods.git "$env:USERPROFILE\.claude\mods"
& "$env:USERPROFILE\.claude\mods\install.ps1"
Danach Claude Code neu starten. Das Skript trägt alle Mod-Ordner in ~/.claude/settings.json unter env.CLAUDE_CODE_PLUGIN_DIRS ein. Andere Einstellungen bleiben. Eine Sicherung liegt danach in settings.json.bak.
Nur einzelne Mods:
& "$env:USERPROFILE\.claude\mods\install.ps1" -Only cache-countdown,blast-radius
macOS und Linux: Repo nach ~/.claude/mods klonen und in ~/.claude/settings.json eintragen (Trennzeichen :):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/home/<du>/.claude/mods/cache-countdown:/home/<du>/.claude/mods/blast-radius"
}
}
Das Repo ist auch ein Claude-Code-Marktplatz (.claude-plugin/marketplace.json). In Claude Code:
/plugin marketplace add broening/claude-mods
/plugin install cache-countdown@claude-mods
Nicht beides gleichzeitig nutzen, sonst lädt ein Mod doppelt.
git -C "$env:USERPROFILE\.claude\mods" pull
Danach Claude Code neu starten. Kommt ein neuer Mod dazu, install.ps1 noch einmal ausführen.
<mod>/
.claude-plugin/plugin.json Name, Version, Beschreibung
hooks/hooks.json { "modules": ["./register.tsx"] }
hooks/register.tsx die Hooks: export const register: Register = on => { ... }
types/index.d.ts Vertrag für den gespeicherten Zustand ($.state)
tsconfig.json
.claude-plugin/types/ erzeugt Claude Code bei jedem Build selbst. Der Ordner ist nicht im Repo.
claude plugin validate .\cache-countdown
version in plugin.json erhöhen und einen Eintrag in CHANGELOG.md schreiben.Neue Mods am einfachsten mit dem Claude-Code-Skill plugin-authoring bauen.
hooks/register.tsx 300 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Frage } from '../types'
5
6// Grill-Assistent als Band UEBER dem Eingabefeld. Mit "/grill" (optional
7// "/grill <thema>") fragt das Plugin Claude selbst (ueber $.model.fork auf dem
8// laufenden Gespraech) nach bis zu 5 Rueckfragen, die den aktuellen Plan oder
9// die Entscheidung auf die Probe stellen. Das Band zeigt eine Frage nach der
10// anderen: Antwortoptionen, den KI-Vorschlag und ein Feld fuer eine eigene
11// Antwort. Nach der letzten Frage gehen alle Antworten gesammelt als Prompt an
12// Claude. Ein Fork erzeugt keinen neuen Zug. Nur pro Sitzung, nichts gespeichert.
13
14const GREEN = '#22c55e'
15const MAX_FRAGEN = 5
16const MAX_OPTIONEN = 4
17const LABEL_LEN = 70 // gekuerzt fuer die Anzeige im schmalen Band
18
19const FORK_PROMPT = [
20 'Stelle den aktuellen Plan bzw. die aktuelle Entscheidung aus diesem Gespraech',
21 'auf die Probe. Formuliere bis zu 5 kurze Rueckfragen, die Schwachstellen,',
22 'offene Annahmen und Risiken aufdecken. Die wichtigste Frage kommt zuerst.',
23 'Jede Frage hat 2 bis 4 konkrete Antwortoptionen, die sich gegenseitig',
24 'ausschliessen, und genau eine empfohlene Option als "vorschlag" (sie muss',
25 'wortgleich eine der Optionen sein).',
26 'Antworte NUR mit einem JSON-Array, ohne Text davor oder danach und ohne',
27 'Code-Zaeune. Jedes Element hat die Form',
28 '{ "frage": string, "optionen": string[], "vorschlag": string }.',
29 'Rufe keine Werkzeuge auf.',
30].join(' ')
31
32// Sitzungszustand. Nur pro Sitzung.
33const fragen = atom({ plugin: 'grill', key: 'fragen' } as const, [] as Frage[])
34const antworten = atom(
35 { plugin: 'grill', key: 'antworten' } as const,
36 [] as (string | null)[],
37)
38const index = atom({ plugin: 'grill', key: 'index' } as const, 0)
39const aktiv = atom({ plugin: 'grill', key: 'aktiv' } as const, false)
40const laden = atom({ plugin: 'grill', key: 'laden' } as const, false)
41const draft = atom({ plugin: 'grill', key: 'draft' } as const, '')
42
43// Zaehler, damit ein altes, langsames /grill ein neueres nicht ueberschreibt.
44let generation = 0
45
46// Zu lange Zeile fuers Band kuerzen.
47function cut(text: string): string {
48 return text.length > LABEL_LEN ? text.slice(0, LABEL_LEN - 3) + '...' : text
49}
50
51// Antwort des Forks in Fragen zerlegen: Code-Zaeune weg, vom ersten '[' bis zum
52// letzten ']' lesen, nur wohlgeformte Eintraege behalten.
53function parseFragen(text: string): Frage[] {
54 const clean = text.replace(/```[a-zA-Z]*/g, '')
55 const start = clean.indexOf('[')
56 const end = clean.lastIndexOf(']')
57 if (start < 0 || end <= start) return []
58
59 let raw: unknown
60 try {
61 raw = JSON.parse(clean.slice(start, end + 1))
62 } catch {
63 return []
64 }
65 if (!Array.isArray(raw)) return []
66
67 const out: Frage[] = []
68 for (const item of raw) {
69 if (!item || typeof item !== 'object') continue
70 const o = item as { frage?: unknown; optionen?: unknown; vorschlag?: unknown }
71 if (typeof o.frage !== 'string' || !o.frage.trim()) continue
72 const optionen = Array.isArray(o.optionen)
73 ? o.optionen
74 .filter((x): x is string => typeof x === 'string' && x.trim() !== '')
75 .map(x => x.trim())
76 .slice(0, MAX_OPTIONEN)
77 : []
78 out.push({
79 frage: o.frage.trim(),
80 optionen,
81 vorschlag: typeof o.vorschlag === 'string' ? o.vorschlag.trim() : '',
82 })
83 if (out.length >= MAX_FRAGEN) break
84 }
85
86 return out
87}
88
89// Sitzung komplett leeren und das Band ausblenden.
90async function clearSession($: EngineInterface) {
91 await update($, aktiv, () => false)
92 await update($, laden, () => false)
93 await update($, fragen, () => [])
94 await update($, antworten, () => [])
95 await update($, index, () => 0)
96 await update($, draft, () => '')
97}
98
99// Fragen erzeugen: Fork starten, Antwort zerlegen, Sitzung starten.
100async function startGrill($: EngineInterface, focus: string) {
101 const mine = ++generation
102 await update($, aktiv, () => true)
103 await update($, laden, () => true)
104 await update($, fragen, () => [])
105 await update($, antworten, () => [])
106 await update($, index, () => 0)
107 await update($, draft, () => '')
108
109 const prompt = focus
110 ? FORK_PROMPT + ' Konzentriere dich auf: ' + focus
111 : FORK_PROMPT
112
113 let list: Frage[] = []
114 let grund = 'Grill: keine Fragen erhalten.'
115 try {
116 const r = await $.model.fork({ prompt })
117 if (r.isAnswered) {
118 list = parseFragen(r.text)
119 } else if (r.reason === 'nothing-to-fork') {
120 if (focus) {
121 // Kein Gespraech zum Hinterfragen, aber ein Thema: direkt dazu fragen
122 // (ohne Verlauf, per $.model.complete).
123 const model = (await $.session.model()) || 'sonnet'
124 const direct = await $.model.complete({
125 model,
126 prompt: 'Thema: ' + focus + '\n\n' + FORK_PROMPT,
127 maxTokens: 1024,
128 })
129 if (direct.isAnswered) list = parseFragen(direct.text)
130 } else {
131 grund =
132 'Grill: erst ein Gespraech fuehren, oder "/grill <thema>" mit einem Thema nutzen.'
133 }
134 }
135 } catch {
136 // grund bleibt
137 }
138
139 if (mine !== generation) return
140
141 if (list.length === 0) {
142 await clearSession($)
143 $.ui.toast(grund)
144
145 return
146 }
147
148 await update($, fragen, () => list)
149 await update($, antworten, () => list.map(() => null))
150 await update($, index, () => 0)
151 await update($, laden, () => false)
152}
153
154// Alle Antworten sammeln, Sitzung leeren, als Prompt an Claude schicken.
155async function finish($: EngineInterface) {
156 const list = await read($, fragen)
157 const answers = await read($, antworten)
158
159 const lines = ['Hier meine Antworten auf deine Rueckfragen:']
160 list.forEach((f, i) => {
161 lines.push('- ' + f.frage + ' => ' + (answers[i] ?? '(offen)'))
162 })
163 const text = lines.join('\n')
164
165 generation++
166 await clearSession($)
167 await $.prompt.submit({ text, asUser: true })
168}
169
170// Antwort zu Frage i festhalten und weiterschalten (nach der letzten: fertig).
171async function antwort($: EngineInterface, i: number, text: string) {
172 const list = await read($, fragen)
173 await update($, antworten, a => a.map((x, k) => (k === i ? text : x)))
174 await update($, draft, () => '')
175
176 if (i >= list.length - 1) {
177 await finish($)
178 } else {
179 await update($, index, () => i + 1)
180 }
181}
182
183// Abbrechen: Sitzung leeren, laufenden Fork verwerfen.
184async function abbrechen($: EngineInterface) {
185 generation++
186 await clearSession($)
187 $.ui.toast('Grill abgebrochen.')
188}
189
190export const register: Register = on => {
191 on('session.start', async ($, e, next) => {
192 await $.command.register({
193 name: 'grill',
194 description:
195 'Grill: Claude stellt dir bis zu 5 Rueckfragen zu deinem Plan, mit "/grill <thema>" mit Schwerpunkt',
196 })
197
198 return next(e)
199 })
200
201 on('command.run', { command: 'grill' }, async ($, e) => {
202 // Nicht blockieren: Fork im Hintergrund, das Band zeigt "denkt nach".
203 void startGrill($, e.args.trim())
204
205 return { text: 'Grill gestartet.' }
206 })
207
208 // Band ueber dem Eingabefeld. Es gibt nur EIN Band ("one instance"), darum
209 // reichen wir das darunterliegende Band (below) durch und haengen unseres an.
210 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
211 const below = await next(e)
212 if (!(await read($, aktiv)) || e.props?.hasSurvey) return below
213
214 const { Box, Text, Button, Input } = $.ui.resolve(e)
215
216 if (await read($, laden)) {
217 return (
218 <Box flexDirection="column">
219 {below}
220 <Box gap={1}>
221 <Text dimColor>{'\u25C6'} Grill: denkt nach ...</Text>
222 </Box>
223 </Box>
224 )
225 }
226
227 const list = await read($, fragen)
228 const answers = await read($, antworten)
229 const at = await read($, index)
230 const draftText = await read($, draft)
231
232 const frage = list[at]
233 if (!frage) return below
234
235 const isLast = at >= list.length - 1
236 const given = answers[at]
237
238 return (
239 <Box flexDirection="column">
240 {below}
241 <Text bold color={GREEN}>
242 Frage {at + 1}/{list.length}
243 </Text>
244 <Text bold>{frage.frage}</Text>
245 {given != null && <Text dimColor>Deine Antwort: {cut(given)}</Text>}
246 {frage.optionen.map((option, i) => (
247 <Box key={'opt-' + at + '-' + i} gap={1} alignItems="flex-start">
248 <Text dimColor>{i + 1}</Text>
249 <Button
250 key={'opt-btn-' + at + '-' + i}
251 label={cut(option)}
252 onPress={() => void antwort($, at, option)}
253 />
254 </Box>
255 ))}
256 {frage.vorschlag !== '' && (
257 <Box gap={1} alignItems="flex-start">
258 <Text bold color={GREEN}>
259 {'\u2605'}
260 </Text>
261 <Button
262 key={'ki-' + at}
263 label={cut('KI-Vorschlag: ' + frage.vorschlag)}
264 onPress={() => void antwort($, at, frage.vorschlag)}
265 />
266 </Box>
267 )}
268 <Input
269 key={'own-' + at}
270 placeholder="eigene Antwort, Enter"
271 submitLabel="antworten"
272 autoFocus
273 value={draftText}
274 onInput={value => void update($, draft, () => value)}
275 onSubmit={value => {
276 const text = value.trim()
277 if (text) void antwort($, at, text)
278 }}
279 />
280 <Box gap={1}>
281 <Button
282 key="zurueck"
283 label="zurueck"
284 onPress={() => void update($, index, i => Math.max(0, i - 1))}
285 />
286 <Button
287 key="weiter"
288 label={isLast ? 'fertig' : 'weiter'}
289 onPress={() => {
290 if (isLast) void finish($)
291 else void update($, index, i => i + 1)
292 }}
293 />
294 <Button key="abbrechen" label="abbrechen" onPress={() => void abbrechen($)} />
295 </Box>
296 </Box>
297 )
298 })
299}
300types/index.d.ts 19 lines1export type Frage = {
2 frage: string
3 optionen: string[]
4 vorschlag: string
5}
6
7declare module 'claude-code' {
8 interface PluginState {
9 'grill': {
10 fragen: Frage[]
11 antworten: (string | null)[]
12 index: number
13 aktiv: boolean
14 laden: boolean
15 draft: string
16 }
17 }
18}
19