SLOPSHOPPER

Bang commands

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

newbandrowstoaststatusprompt
★ 1v0.1.0MITupdated 2026-10-23benjaminb10/encore-mods/plugins/bang-commands
A shopper browsing a rack in a slop shop
README

Bang commands

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.

  • The command's own line in Claude's reply becomes a button: ▶ ! 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.
  • Click it: the mod types ! 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.
  • Found alone on its line (in a code fence or not, with \ continuation lines) or quoted in a sentence as ` ! command `.

Install

/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.

macOS: the Accessibility right

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.

Try it without installing

git clone https://github.com/benjaminb10/encore-mods
claude --plugin-dir encore-mods/plugins/bang-commands
Source 1 files
hooks/register.js 197 lines
1// 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