Pone botones [Ejecutar] y [Copiar] al lado de cada comando de shell que Claude escribe en un bloque de código, para correrlo o copiarlo sin seleccionar texto a…

A Claude Code plugin that adds instant Run and Copy buttons to every shell command in Claude's responses, plus a quick-access hotkey band above the prompt.
Claude often suggests shell commands, but copying and running them involves:
command-buttons eliminates all that friction. With one click—or one keystroke—you run the exact command Claude proposed, see the output right there in the transcript, and copy it just as easily.
[ ▶ Run ] [ ⧉ Copy ]), framed to stand out from prose1–9 to run, a–i to copy the latest reply's commands, no mouse requiredkubectl delete, terraform destroy, --force, etc.)CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in ~/.claude/settings.json# Clone or download this repo
git clone https://github.com/faridmurzone/command-buttons-claude-code.git ~/.claude/skills/command-buttons
# Verify the plugin loads
claude plugin validate ~/.claude/skills/command-buttons
The plugin auto-loads as command-buttons@skills-dir on your next Claude Code session.
~/.claude/skills/command-buttons~/.claude/settings.json: ``json { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } } ``Every shell fence in Claude's reply gets a bracketed button row:
gcloud projects add-iam-policy-binding example-project \
--role="roles/editor"
[ ▶ Run ] [ ⧉ Copy ] gcloud projects add-iam-policy-binding…
Updated IAM policy for project [example-project].
tool.call({ tool: 'Bash' }) — same as the model's own callsAbove the prompt, the latest finished reply's commands appear as hotkeys:
╭─ Quick run — last reply: ─────────────────────────────────────╮
│ 1: Run a: Copy gcloud projects add-iam-policy-binding exam…│
╰─────────────────────────────────────────────────────────────────╯
> _
1–9 run the command (works with an empty prompt)a–i copy the command (need focus on the band first: click it or ctrl+x tab)Commands matching the confirm pattern (destructive by default) need two presses:
First press: [ ▶ Run ] → [ ⚠ Confirm ]
"Sensitive command: press again to confirm"
Second press: [ ⚠ Confirm ] → runs it
Default destructive patterns: rm -rf, kubectl delete, terraform destroy, --force, remove-iam-policy-binding, set-iam-policy, firestore import|databases, and any command with uma-v2 (customize in /config → confirmPattern).
In Claude Code's /config menu, or in ~/.claude/settings.json under pluginConfigs["command-buttons@skills-dir"].options:
| Option | Default | What it does |
|---|---|---|
runInBackground | false | Run with run_in_background, keeping the session alive while the command runs. |
confirmPattern | Regex covering destructive ops | Commands matching this pattern need a second press to run. Leave empty to disable. |
maxButtons | 8 | Maximum commands per message to get buttons (others appear in the message box only). |
confirmPattern as your guardrail.runInBackground: true): The inline output shows the initial response (which comes back fast with a task ID), not the final result — watch the transcript for long-running commands.isWorking): The band only shows finished replies, never mid-turn, so it doesn't offer stale commands while Claude is typing.maxRows and won't force a scroll.claude plugin test ~/.claude/skills/command-buttons # 31 tests
claude plugin validate ~/.claude/skills/command-buttons
After updating Claude Code:
mkdir /tmp/modwork && cd /tmp/modwork
claude -p "/plugin-types"
cp .claude/types/claude-code.d.ts ~/.claude/skills/command-buttons/.claude/types/
claude --plugin-dir ~/.claude/skills/command-buttons
The plugin reloads on file save.
hooks/commands-of.ts: Parser for shell blocks in Markdown — finds closed fences, deduplicates, handles continuations, heredocs, loopshooks/register.tsx: Two render hooks:AssistantMessage: Draws the inline button row and outputAbovePrompt: Mirrors the latest reply as a hotkey bandtests/: 31 tests covering parsing, button state, shared state between hooks, streaming re-renders, and moreThe core trick: state shared by command digest (digestOf), not by location. Run from the message box → it updates in the band. Run from the band → it updates in the message box.
Issues, PRs, and forks welcome. The codebase is TypeScript + React (JSX for render trees), tested with claude plugin test, and linted with tsc.
command-buttons is a Claude Code function hook plugin — a TypeScript module that decorates Claude's render pipeline. It:
ui.render event for assistant messages$.tool.call (so they go through the permission system and land in the transcript)No monkey-patching, no secrets, no bypass — just one more layer of UI on top of Claude's own execution stack.
MIT. See LICENSE.
Found a bug? Want a feature? Open an issue. Want to build on top of this? MIT license makes it easy.
Made with ❤️ for people who type faster than they click.
This is an early-access plugin for Claude Code function hooks. The API may change between releases — regenerate types after updating Claude Code.
hooks/register.tsx 509 lines1import type { EngineInterface, On, RenderElement } from 'claude-code'
2
3import { blocksOf } from './commands-of'
4
5/**
6 * A short stable digest of `text` (djb2): the identity a command's state
7 * (armed, running, its last output) is kept under, shared by every place the
8 * same command is drawn — the message's own box and the quick band above the
9 * prompt both key off it, so running it from either shows in both.
10 */
11function digestOf(text: string): string {
12 let hash = 5381
13
14 for (let index = 0; index < text.length; index += 1) {
15 hash = ((hash << 5) + hash + text.charCodeAt(index)) | 0
16 }
17
18 return (hash >>> 0).toString(36)
19}
20
21/** How much of a command a message-box row shows beside its buttons. */
22const PREVIEW_MIN = 24
23
24/** Room the two bracket buttons and their gaps take on a message-box row. */
25const BUTTONS_WIDTH = 30
26
27/** What the message-box frame itself takes: its border, padding and margin. */
28const FRAME_WIDTH = 6
29
30/** Room the two plain, hotkeyed buttons take on a quick-band row. */
31const ABOVE_BUTTONS_WIDTH = 22
32
33/** How many of the latest reply's commands the band offers: one digit each. */
34const MAX_ABOVE_PROMPT_COMMANDS = 9
35
36/** How many lines of a command's output are drawn before "+N more". */
37const OUTPUT_MAX_LINES = 6
38
39/** Clipboard commands to try, in order, until one is on the machine. */
40const CLIPBOARDS = [
41 ['pbcopy'],
42 ['wl-copy'],
43 ['xclip', '-selection', 'clipboard'],
44 ['xsel', '--clipboard', '--input'],
45]
46
47type Engine = EngineInterface
48
49type Options = {
50 runInBackground?: boolean
51 confirmPattern?: string
52 maxButtons?: string
53}
54
55/** What a run left behind, kept until the next press replaces it. */
56type Output = {
57 text: string
58 isError: boolean
59}
60
61/** The state a press reads and updates, shared by every row of one command. */
62type Ctx = {
63 options: Options
64 confirms: RegExp | null
65 armed: Set<string>
66 running: Set<string>
67 outputs: Map<string, Output>
68}
69
70type Handlers = {
71 run: () => void
72 take: () => void
73}
74
75/**
76 * Puts `text` on the system clipboard, answering which tool took it.
77 *
78 * @param $ the engine interface
79 * @param text the command to copy
80 * @returns the tool that took it, or null when the machine has none
81 */
82async function copy($: Engine, text: string): Promise<string | null> {
83 for (const argv of CLIPBOARDS) {
84 try {
85 const { exitCode } = await $.process.run(argv, {
86 stdin: text,
87 timeoutMs: 5000,
88 })
89
90 if (exitCode === 0) {
91 return argv[0] ?? null
92 }
93 } catch {
94 // Not on this machine, or it refused the text: try the next one.
95 }
96 }
97
98 return null
99}
100
101/**
102 * Runs `command` the way the model's own Bash calls do, and answers what it
103 * left behind. Toasts on a denial or a thrown error; a plain result is left
104 * for the caller to draw.
105 *
106 * @param $ the engine interface
107 * @param command the shell command to run
108 * @param options the manifest's `userConfig`
109 */
110async function runCommand(
111 $: Engine,
112 command: string,
113 options: Options,
114): Promise<Output> {
115 try {
116 const response = await $.tool.call({
117 tool: 'Bash',
118 command,
119 description: 'Run the command the assistant proposed',
120 ...(options.runInBackground === true ? { run_in_background: true } : {}),
121 })
122
123 if (response.deny !== undefined) {
124 $.ui.toast(`Denied: ${response.deny}`, { timeoutMs: 8000 })
125
126 return { text: response.deny, isError: true }
127 }
128
129 return { text: response.text ?? '', isError: response.isError === true }
130 } catch (error) {
131 const reason = error instanceof Error ? error.message : String(error)
132
133 $.ui.toast(`Did not run: ${reason}`, { timeoutMs: 8000 })
134
135 return { text: reason, isError: true }
136 }
137}
138
139/**
140 * The `onPress` closures one row needs, built once per draw: pressing Run
141 * arms a confirm-gated command on its first press and runs it on its second
142 * (or its only press, when nothing gates it); pressing Copy never runs
143 * anything. Both read and write `ctx`'s Sets and Map by `id`, so a press from
144 * the quick band and a press from the message box agree on one command's
145 * state.
146 *
147 * @param $ the engine interface
148 * @param id the command's identity (`digestOf`), not the row's own key
149 * @param command the shell command this row offers
150 * @param ctx the state shared by every row of this plugin
151 */
152function handlersFor(
153 $: Engine,
154 id: string,
155 command: string,
156 ctx: Ctx,
157): Handlers {
158 const needsConfirm = ctx.confirms !== null && ctx.confirms.test(command)
159
160 const run = () => {
161 if (ctx.running.has(id)) {
162 return
163 }
164
165 if (needsConfirm && !ctx.armed.has(id)) {
166 ctx.armed.add(id)
167 $.ui.invalidate('ui.render')
168 $.ui.toast('Sensitive command: press again to confirm', {
169 timeoutMs: 6000,
170 })
171
172 return
173 }
174
175 ctx.armed.delete(id)
176 ctx.running.add(id)
177 ctx.outputs.delete(id)
178 $.ui.invalidate('ui.render')
179
180 void runCommand($, command, ctx.options)
181 .then(output => {
182 ctx.outputs.set(id, output)
183 })
184 .finally(() => {
185 ctx.running.delete(id)
186 $.ui.invalidate('ui.render')
187 })
188 }
189
190 const take = () => {
191 void copy($, command).then(tool => {
192 $.ui.toast(
193 tool === null
194 ? 'No clipboard tool found (pbcopy or equivalent)'
195 : 'Command copied',
196 { timeoutMs: 2500 },
197 )
198 })
199 }
200
201 return { run, take }
202}
203
204/**
205 * `output.text`, cut to `width` columns and `OUTPUT_MAX_LINES` lines, with
206 * trailing blank lines dropped so a command that prints one line doesn't
207 * leave empty rows under it.
208 *
209 * @param output what the run resolved with
210 * @param width how wide one line of it may draw
211 * @returns the lines to draw, and how many more there were past the cap
212 */
213function previewOf(
214 output: Output,
215 width: number,
216): { lines: string[]; more: number } {
217 const all = output.text.split('\n')
218
219 while (all.length > 0 && all[all.length - 1]!.trim() === '') {
220 all.pop()
221 }
222
223 if (all.length === 0) {
224 return { lines: ['(no output)'], more: 0 }
225 }
226
227 const shown = all.slice(0, OUTPUT_MAX_LINES).map(line =>
228 line.length > width ? `${line.slice(0, width - 1)}…` : line,
229 )
230
231 return { lines: shown, more: Math.max(0, all.length - OUTPUT_MAX_LINES) }
232}
233
234/** A command on one line, its `\` continuations folded away. */
235function singleLineOf(command: string): string {
236 return command.replace(/\\\s*\n\s*/g, ' ').replace(/\s+/g, ' ')
237}
238
239/** `text`, cut to `width` columns with an ellipsis. */
240function clip(text: string, width: number): string {
241 return text.length > width ? `${text.slice(0, width - 1)}…` : text
242}
243
244/**
245 * Draws `[ Run ]` and `[ Copy ]` under every shell block of an assistant
246 * message, framed apart from its prose, with the run's output under its row;
247 * and mirrors the latest finished reply's commands as a hotkeyed band above
248 * the prompt (`1`, `2`, … to run; `a`, `b`, … to copy), so the last one
249 * never needs the mouse.
250 *
251 * Running goes through `$.tool.call`, so the command takes the session's
252 * permission path and lands in the transcript as its own `Bash` row too.
253 * Nothing runs without a press, and a command the confirm pattern names
254 * takes two.
255 *
256 * @param on the engine's registrar
257 * @param options the manifest's `userConfig`, as the person set it
258 */
259export function register(on: On, options: Options = {}) {
260 const ctx: Ctx = {
261 options,
262 confirms: (() => {
263 const source = options.confirmPattern ?? ''
264
265 if (source.trim() === '') {
266 return null
267 }
268
269 try {
270 return new RegExp(source, 'i')
271 } catch {
272 return null
273 }
274 })(),
275 armed: new Set<string>(),
276 running: new Set<string>(),
277 outputs: new Map<string, Output>(),
278 }
279
280 const maxButtons = (() => {
281 const parsed = Number.parseInt(options.maxButtons ?? '8', 10)
282
283 return Number.isFinite(parsed) && parsed > 0 ? parsed : 8
284 })()
285
286 /**
287 * The latest reply's text blocks, by `requestId`: a re-render (a resize,
288 * a streamed chunk) replaces its own entry rather than appending to it, so
289 * a block still growing never leaves a stale prefix behind. `isFirstOfReply`
290 * starts a new reply and drops every entry from the one before it.
291 */
292 const replyBlocks = new Map<string, string>()
293
294 /** `replyBlocks`, in the order its entries were last touched. */
295 const latestReplyText = () => [...replyBlocks.values()].join('\n')
296
297 on(
298 'ui.render',
299 { component: 'AssistantMessage' },
300 async ($: any, e: any, next: any): Promise<RenderElement> => {
301 const base = await next(e)
302 const text: string = e.props?.text ?? ''
303
304 if (e.props?.isFirstOfReply === true) {
305 replyBlocks.clear()
306 }
307
308 if (text !== '') {
309 replyBlocks.set(e.requestId, text)
310 }
311
312 if (text === '' || (!text.includes('```') && !text.includes('~~~'))) {
313 return base
314 }
315
316 const commands = blocksOf(text)
317 .flatMap(block => block.commands)
318 .slice(0, maxButtons)
319
320 if (commands.length === 0) {
321 return base
322 }
323
324 const { Box, Text, Button } = await $.ui.resolve(e)
325 const columns: number = e.viewport?.columns ?? 80
326 const preview = Math.max(
327 PREVIEW_MIN,
328 columns - BUTTONS_WIDTH - FRAME_WIDTH - 4,
329 )
330 const outputWidth = Math.max(PREVIEW_MIN, columns - FRAME_WIDTH - 2)
331
332 const rows = commands.map((command, index) => {
333 const id = digestOf(command)
334 const elementKey = `${e.requestId}:${index}:${id}`
335 const { run, take } = handlersFor($ as Engine, id, command, ctx)
336
337 const isArmed = ctx.armed.has(id)
338 const isRunning = ctx.running.has(id)
339 const output = ctx.outputs.get(id)
340 const outputLines =
341 output === undefined ? null : previewOf(output, outputWidth)
342
343 const label = isRunning
344 ? '⋯ Running'
345 : isArmed
346 ? '⚠ Confirm'
347 : '▶ Run'
348
349 return (
350 <Box key={elementKey} flexDirection="column">
351 <Box flexDirection="row" gap={1}>
352 <Button
353 key={`run:${elementKey}`}
354 label={label}
355 dimColor={isRunning}
356 onPress={run}
357 />
358 <Button
359 key={`copy:${elementKey}`}
360 label="⧉ Copy"
361 onPress={take}
362 />
363 <Text dimColor wrap="truncate-end">
364 {clip(singleLineOf(command), preview)}
365 </Text>
366 </Box>
367 {output === undefined ? null : (
368 <Box flexDirection="column" marginLeft={2}>
369 {outputLines!.lines.map((line, lineIndex) => (
370 <Text
371 key={`out:${elementKey}:${lineIndex}`}
372 dimColor={!output.isError}
373 color={output.isError ? 'red' : undefined}
374 wrap="truncate-end"
375 >
376 {line}
377 </Text>
378 ))}
379 {outputLines!.more > 0 ? (
380 <Text dimColor>… (+{outputLines!.more} more lines)</Text>
381 ) : null}
382 </Box>
383 )}
384 </Box>
385 )
386 })
387
388 return (
389 <Box flexDirection="column">
390 {base}
391 <Box
392 flexDirection="column"
393 borderStyle="round"
394 borderDimColor
395 paddingX={1}
396 marginLeft={2}
397 marginTop={1}
398 alignSelf="flex-start"
399 >
400 {rows}
401 </Box>
402 </Box>
403 )
404 },
405 )
406
407 on(
408 'ui.render',
409 { component: 'AbovePrompt' },
410 async ($: any, e: any, next: any): Promise<RenderElement> => {
411 // A survey (a permission ask, a question) owns the band while it runs.
412 if (e.props?.hasSurvey === true) {
413 return next(e)
414 }
415
416 const below = await next(e)
417
418 // Mid-turn the latest reply's fences aren't closed yet, so this would
419 // otherwise show the reply before it — stale commands for a live turn.
420 if (e.props?.isWorking === true) {
421 return below
422 }
423
424 const text = latestReplyText()
425
426 if (text === '') {
427 return below
428 }
429
430 // A digest per command: two identical commands in one reply would
431 // otherwise draw two Buttons under the same key, which the engine
432 // refuses to draw (a real key, not just an address, must be unique).
433 const commands = [
434 ...new Map(
435 blocksOf(text)
436 .flatMap(block => block.commands)
437 .map(command => [digestOf(command), command] as const),
438 ).values(),
439 ]
440
441 if (commands.length === 0) {
442 return below
443 }
444
445 const maxRows: number = e.props?.maxRows ?? 0
446 const withHeader = maxRows >= 2
447 const capacity = Math.max(0, withHeader ? maxRows - 1 : maxRows)
448 const shown = commands.slice(
449 0,
450 Math.min(MAX_ABOVE_PROMPT_COMMANDS, capacity),
451 )
452
453 // Below the fold (no room even for one row): leave the band as is
454 // rather than force a scroll, which would drop the digit hotkeys.
455 if (shown.length === 0) {
456 return below
457 }
458
459 const { Box, Text, Button } = await $.ui.resolve(e)
460 const bodyColumns: number = e.props?.bodyColumns ?? 80
461 const preview = Math.max(PREVIEW_MIN, bodyColumns - ABOVE_BUTTONS_WIDTH)
462
463 const rows = shown.map((command, index) => {
464 const id = digestOf(command)
465 const { run, take } = handlersFor($ as Engine, id, command, ctx)
466 const digit = String(index + 1)
467 const letter = String.fromCharCode(97 + index)
468
469 const isArmed = ctx.armed.has(id)
470 const isRunning = ctx.running.has(id)
471 const runLabel = isRunning ? 'Running' : isArmed ? 'Confirm' : 'Run'
472
473 return (
474 <Box key={`above:${id}`} flexDirection="row" gap={1}>
475 <Button
476 key={`above:run:${id}`}
477 hotkey={digit}
478 plain
479 label={runLabel}
480 dimColor={isRunning}
481 onPress={run}
482 />
483 <Button
484 key={`above:copy:${id}`}
485 hotkey={letter}
486 plain
487 label="Copy"
488 onPress={take}
489 />
490 <Text dimColor wrap="truncate-end">
491 {clip(singleLineOf(command), preview)}
492 </Text>
493 </Box>
494 )
495 })
496
497 return (
498 <Box flexDirection="column">
499 {below}
500 {withHeader ? (
501 <Text dimColor>Quick run — last reply:</Text>
502 ) : null}
503 {rows}
504 </Box>
505 )
506 },
507 )
508}
509hooks/commands-of.ts 186 lines1/**
2 * Finds the shell commands a markdown message offers to run.
3 *
4 * Only closed fences count: a fence still streaming has no terminator yet, so
5 * a half-written command never grows a button it would run truncated.
6 */
7
8/** Info strings whose fence holds shell, plus the unlabelled fence. */
9const SHELL_LANGUAGES = new Set([
10 '',
11 'bash',
12 'sh',
13 'shell',
14 'shell-session',
15 'zsh',
16 'console',
17 'terminal',
18 'cmd',
19 'gcloud',
20])
21
22/**
23 * Openers that make the lines beneath them one statement. A block holding any
24 * of them is offered whole: splitting `for`/`if`/`case` at its newlines would
25 * hand `done` to the shell on its own.
26 */
27const BLOCK_OPENERS =
28 /^(for|while|until|if|elif|else|then|fi|do|done|case|esac|function|\}|\{)\b/
29
30/** A line that opens a heredoc, capturing the word that closes it. */
31const HEREDOC = /<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/
32
33/** A line whose statement continues on the next one. */
34const CONTINUES = /(\\|&&|\|\||\||;)\s*$/
35
36/** What a fence holds when it is output pasted back, not a command. */
37const NOT_A_COMMAND = /^\s*[[{<]|^\s*(\d{4}-\d{2}-\d{2}|[A-Z]+:|\||[+-]{3}\s)/
38
39export type Block = {
40 /** The fence's whole body, as the message drew it. */
41 body: string
42 /** The commands it offers, in order; one entry for a fence kept whole. */
43 commands: string[]
44}
45
46/** True when `text` reads as a command rather than as pasted output. */
47function isCommand(text: string): boolean {
48 const first = text.split('\n').find(line => line.trim() !== '')
49
50 if (first === undefined) {
51 return false
52 }
53
54 return !NOT_A_COMMAND.test(first)
55}
56
57/**
58 * Splits a fence body into the commands it holds, or keeps it whole when its
59 * lines depend on each other.
60 *
61 * A comment rides with the command beneath it, so copying one carries what it
62 * is for.
63 */
64export function splitCommands(body: string): string[] {
65 const lines = body.split('\n')
66 const commands: string[] = []
67
68 let current: string[] = []
69 let heredoc: string | null = null
70
71 for (const line of lines) {
72 const trimmed = line.trim()
73
74 if (heredoc !== null) {
75 current.push(line)
76
77 if (trimmed === heredoc) {
78 heredoc = null
79 commands.push(current.join('\n').trim())
80 current = []
81 }
82
83 continue
84 }
85
86 if (BLOCK_OPENERS.test(trimmed)) {
87 return [body.trim()]
88 }
89
90 if (trimmed === '') {
91 if (current.length > 0) {
92 commands.push(current.join('\n').trim())
93 current = []
94 }
95
96 continue
97 }
98
99 current.push(line)
100
101 const opener = HEREDOC.exec(line)
102
103 if (opener) {
104 heredoc = opener[1] ?? null
105 continue
106 }
107
108 if (CONTINUES.test(trimmed) || trimmed.startsWith('#')) {
109 continue
110 }
111
112 commands.push(current.join('\n').trim())
113 current = []
114 }
115
116 if (current.length > 0) {
117 commands.push(current.join('\n').trim())
118 }
119
120 const kept = commands.filter(command => {
121 const bare = command
122 .split('\n')
123 .filter(line => !line.trim().startsWith('#'))
124 .join('\n')
125 .trim()
126
127 return bare !== ''
128 })
129
130 return kept.length === 0 ? [] : kept
131}
132
133/**
134 * The shell blocks of a markdown message, each with the commands it offers.
135 *
136 * @param text the message's markdown, as the transcript draws it
137 */
138export function blocksOf(text: string): Block[] {
139 const lines = text.split('\n')
140 const blocks: Block[] = []
141
142 let fence: { marker: string; language: string; body: string[] } | null = null
143
144 for (const line of lines) {
145 const opener = /^\s*(```+|~~~+)\s*([A-Za-z0-9_+-]*)\s*$/.exec(line)
146
147 if (fence === null) {
148 if (opener) {
149 fence = {
150 marker: opener[1]!.slice(0, 3),
151 language: (opener[2] ?? '').toLowerCase(),
152 body: [],
153 }
154 }
155
156 continue
157 }
158
159 const closes = /^\s*(```+|~~~+)\s*$/.exec(line)
160
161 if (closes && closes[1]!.startsWith(fence.marker)) {
162 const body = fence.body.join('\n')
163
164 if (SHELL_LANGUAGES.has(fence.language) && isCommand(body)) {
165 const commands = splitCommands(body)
166
167 if (commands.length > 0) {
168 blocks.push({ body, commands })
169 }
170 }
171
172 fence = null
173 continue
174 }
175
176 fence.body.push(line)
177 }
178
179 return blocks
180}
181
182/** Every command a message offers, in the order it drew them. */
183export function commandsOf(text: string): string[] {
184 return blocksOf(text).flatMap(block => block.commands)
185}
186