When Claude asks you to run `! command` yourself, click the command: it lands in the prompt in real bash mode, press Enter

A Claude Code mod from Encore Mods. Sometimes Claude can't run a command itself (a login, something that needs your own shell) and asks you to type ! command. This mod saves the copy and paste.
▶ ! gcloud auth login ← click: to the prompt. The same button sits above the prompt, and the first command shows as ghost text in the empty prompt box.! into the empty prompt, so Claude Code switches to its own bash mode, then adds the command. Press Enter and it runs exactly as if you had typed it, output live.\ continuation lines) or quoted in a sentence as ` ! command `./plugin marketplace add benjaminb10/encore-mods
/plugin install bang-commands@encore-mods
Then /reload-plugins or restart Claude Code. Needs Claude Code 2.1.287 or later.
Typing ! goes through osascript (System Events). The first click may ask to let your terminal control System Events, or you can turn your terminal on in System Settings → Privacy & Security → Accessibility. The keystroke goes to the frontmost app, so click with the terminal in front.
Without that right (or on Linux and Windows), the click puts !command in the prompt as plain text. On Enter the mod runs it itself, in the session's folder with your login shell and a 2 minute limit, and Claude gets the command with its output. It only does that for a command Claude asked for in one of its last replies: anything else you send starting with ! goes through untouched. A command that waits for keyboard input (a password prompt) needs real bash mode.
git clone https://github.com/benjaminb10/encore-mods
claude --plugin-dir encore-mods/plugins/bang-commandshooks/register.js 197 lines1// bang-commands: when Claude asks you to run `! command` yourself (a login, a command that needs your shell),
2// the command becomes a button in the reply, above the prompt and as ghost text in the empty prompt box.
3// A click types "!" into the prompt (Claude Code's own bash mode, on macOS) and adds the command after it:
4// press Enter and it runs as if you had typed it. Where "!" can't be typed, "!command" goes in as text and,
5// on Enter, the mod runs it itself and hands Claude the command with its output.
6
7const MAX_COMMANDS = 4
8const RUN_TIMEOUT_MS = 120000
9
10let commands = [] // the `! command`s in Claude's last reply
11let filled = null // the last command a click put in the prompt box
12
13const BANG_LINE = /^\s*(?:[-*>]\s+)?`{0,3}!\s*(\S.*?)`{0,3}\s*$/
14
15function fit(s, n) {
16 return s.length > n ? s.slice(0, Math.max(1, n - 1)) + '…' : s
17}
18
19// The commands Claude asks you to run: alone on their line (in a code fence or not, with their `\`
20// continuation lines), or quoted in a sentence between backticks.
21export function parseBangCommands(answer) {
22 if (!answer) return []
23 const out = []
24 const lines = String(answer).replace(/\r/g, '').split('\n')
25 for (let i = 0; i < lines.length; i++) {
26 const m = lines[i].match(BANG_LINE)
27 if (m && !/^=/.test(m[1])) {
28 let cmd = m[1]
29 while (/\\$/.test(cmd) && i + 1 < lines.length) cmd += '\n' + lines[++i].replace(/`{3}\s*$/, '')
30 out.push(cmd.trim())
31 continue
32 }
33 for (const n of lines[i].matchAll(/`!\s*([^`]+)`/g)) out.push(n[1].trim())
34 }
35 return [...new Set(out.filter(Boolean))].slice(0, MAX_COMMANDS)
36}
37
38// The reply cut around the commands alone on their line, so each one is drawn in place as a button.
39// A code fence that only wrapped the command goes with it.
40export function splitAtBangs(answer) {
41 const lines = String(answer || '').replace(/\r/g, '').split('\n')
42 const parts = []
43 let md = []
44 const flush = () => {
45 const t = md.join('\n').replace(/^\s*\n|\n\s*$/g, '')
46 if (t.trim()) parts.push({ md: t })
47 md = []
48 }
49 for (let i = 0; i < lines.length; i++) {
50 const m = lines[i].match(BANG_LINE)
51 if (!m || /^=/.test(m[1])) { md.push(lines[i]); continue }
52 let cmd = m[1]
53 while (/\\$/.test(cmd) && i + 1 < lines.length) cmd += '\n' + lines[++i].replace(/`{3}\s*$/, '')
54 let k = md.length - 1
55 while (k >= 0 && !md[k].trim()) k--
56 const fenced = k >= 0 && /^\s*`{3}[\w-]*\s*$/.test(md[k])
57 if (fenced) md = md.slice(0, k)
58 flush()
59 parts.push({ cmd: cmd.trim() })
60 if (fenced) {
61 let j = i + 1
62 while (j < lines.length && !lines[j].trim()) j++
63 if (j < lines.length && /^\s*`{3}\s*$/.test(lines[j])) i = j
64 }
65 }
66 flush()
67 return parts
68}
69
70function label(cmd, n) {
71 return '▶ ' + fit('! ' + cmd.replace(/\s*\\\n\s*/g, ' '), n)
72}
73
74// What Claude reads when the mod ran the command itself
75export function report(cmd, r) {
76 let out = ((r.stdout || '') + (r.stderr ? (r.stdout ? '\n' : '') + r.stderr : '')).replace(/\s+$/, '')
77 if (out.length > 20000) out = out.slice(0, 20000) + '\n… (output cut)'
78 const status = r.timedOut ? 'stopped after 2 min' : 'exit code ' + r.exitCode
79 return 'I ran the command (' + status + '):\n```\n$ ' + cmd + '\n' + (out || '(no output)') + '\n```'
80}
81
82// A click: "!" typed by macOS into the empty prompt box, as by hand, so Claude Code switches to bash mode,
83// then the command after it. Elsewhere (or without the Accessibility right) "!command" goes in as text.
84async function fill($, cmd) {
85 filled = cmd
86 await $.prompt.fill({ text: '', mode: 'replace' })
87 let typed = false
88 try {
89 const r = await $.process.run(['osascript', '-e', 'tell application "System Events" to keystroke "!"'])
90 typed = r.exitCode === 0
91 } catch {}
92 if (typed) {
93 await new Promise((ok) => $.clock.after(150, ok))
94 const box = await $.prompt.read()
95 // Bash mode took the "!": the box is empty and waits for the command
96 if ((box.text || '') === '') { await $.prompt.fill({ text: cmd, mode: 'insert' }); return }
97 }
98 await $.prompt.fill({ text: '!' + cmd, mode: 'replace' })
99}
100
101// The commands Claude asked for in its last few replies (the module's own memory starts over on a reload)
102async function askedLately($) {
103 try {
104 const replies = (await $.session.messages()).filter((m) => m.role === 'assistant' && m.text).slice(-6)
105 return replies.flatMap((m) => parseBangCommands(m.text))
106 } catch {
107 return []
108 }
109}
110
111async function run($, cmd) {
112 $.ui.status('⏳ ! ' + fit(cmd.split('\n')[0], 60))
113 try {
114 const cwd = String((await $.session.cwd()) || '')
115 let shell = '/bin/sh'
116 try { shell = (await $.env.get('SHELL')) || shell } catch {}
117 const r = await $.process.run([shell, '-lc', cmd], cwd ? { cwd, timeoutMs: RUN_TIMEOUT_MS } : { timeoutMs: RUN_TIMEOUT_MS })
118 return report(cmd, r)
119 } catch (err) {
120 return report(cmd, { exitCode: -1, stdout: '', stderr: String((err && err.message) || err), timedOut: /timed? ?out/i.test(String(err)) })
121 } finally {
122 $.ui.status(undefined)
123 }
124}
125
126// "!command" that reached the prompt as text: run it when it is one Claude asked for, never anything else,
127// so your own bash mode (which never comes through here) is untouched.
128async function onSubmit($, e, next) {
129 const asked = commands
130 commands = []
131 $.ui.invalidate('ui.render')
132 const bang = /^\s*!\s*(\S[\s\S]*)$/.exec(e.text || '')
133 const wanted = bang && bang[1].trim()
134 if (wanted && (!e.origin || e.origin.kind === 'composer') && (wanted === filled || asked.includes(wanted) || (await askedLately($)).includes(wanted))) {
135 filled = null
136 $.ui.toast('bang-commands: running ! ' + fit(wanted.split('\n')[0], 50))
137 return next({ ...e, text: await run($, wanted) })
138 }
139 return next(e)
140}
141
142export function register(on) {
143 on('session.start', async ($, e, next) => {
144 try {
145 const last = (await $.session.messages()).filter((m) => m.role === 'assistant').at(-1)
146 if (last && last.text) commands = parseBangCommands(last.text)
147 } catch {}
148 return next(e)
149 })
150
151 on('prompt.submit', async ($, e, next) => onSubmit($, e, next)).catch(() => undefined)
152
153 on('turn.complete', async ($, e, next) => {
154 if (e.agentId) return next(e)
155 if (!e.isAborted) {
156 commands = parseBangCommands(e.answer)
157 if (commands.length) $.prompt.suggest({ text: '!' + commands[0] }).catch(() => {})
158 }
159 $.ui.invalidate('ui.render')
160 return next(e)
161 })
162
163 // A reply that asks for `! command`: the command's own line becomes the button, in place
164 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
165 const text = (e.props && e.props.text) || ''
166 const cmds = parseBangCommands(text)
167 if (!cmds.length) return next(e)
168 const { Box, Button, Markdown } = $.ui.resolve(e)
169 const cols = Math.max(30, ((e.viewport && e.viewport.columns) || 80) - 6)
170 const parts = splitAtBangs(text)
171 const placed = new Set(parts.filter((p) => p.cmd).map((p) => p.cmd))
172 const button = (c, key) => Button({ key, label: label(c, cols - 22) + ' ← click: to the prompt', plain: true, onPress: () => fill($, c) })
173 const children = []
174 for (let i = 0; i < parts.length; i++) {
175 const p = parts[i]
176 const top = i ? 1 : 0
177 if (p.cmd) children.push(Box({ key: 'c' + i, paddingLeft: 2, marginTop: top, children: [button(p.cmd, 'bang-' + i)] }))
178 // The first text keeps the engine's own drawing (the reply's bullet); the rest is drawn the same way
179 else if (i === 0) children.push(await next({ ...e, props: { ...e.props, text: p.md } }))
180 else children.push(Box({ key: 'm' + i, paddingLeft: 2, marginTop: top, children: [Markdown({ key: 'md-' + i, text: p.md })] }))
181 }
182 // Commands only quoted in a sentence: a button under the block
183 cmds.filter((c) => !placed.has(c)).forEach((c, i) => children.push(Box({ key: 'q' + i, paddingLeft: 2, children: [button(c, 'quoted-' + i)] })))
184 return Box({ flexDirection: 'column', children })
185 })
186
187 // The same buttons above the prompt, while the reply is fresh
188 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
189 if (e.props.isWorking || !commands.length) return next(e)
190 const { Box, Button } = $.ui.resolve(e)
191 const cols = Math.max(30, (e.props.bodyColumns || 80) - 4)
192 const rows = commands.map((c, i) => Button({ key: 'bang-' + i, label: label(c, cols), plain: true, onPress: () => fill($, c) }))
193 const theirs = await next(e)
194 return Box({ flexDirection: 'column', children: [...rows, theirs] })
195 })
196}
197