SLOPSHOPPER

clip-command

When a reply contains a command for you to run (a code block starting with "! "), copies it to the clipboard and places it in the empty prompt input. Pressing…

newtoastpromptprocess
v0.3.1MITupdated 2026-10-08i-noma-ru/claude-clip-command
A shopper browsing a rack in a slop shop
README

claude-clip-command

A small plugin ("mod") for Claude Code's terminal UI that copies the command Claude asks you to run and places it in the prompt input. When a reply ends with a command for you to run (a code block starting with ! ), you normally select it, copy it and paste it by hand. This plugin copies that block to the clipboard and, if the input is empty, places it there. Press Enter on it and the plugin runs it and feeds the output back to the model.

日本語の説明は README.ja.md にあります。

In-app messages are in Japanese.

When to use

  • When Claude keeps ending replies with a command for you to run and you copy and paste it by hand each time.
  • When you want the output of that command to go back to the model without pasting it yourself.
  • When Claude suggests a one-line slash command such as /rename my-session and you want it ready in the input instead of retyping it.

Not for you if you are on Windows (untested), run Claude non-interactively (claude -p, where there is no prompt input to fill), or do not want a command written by the model to run when you press Enter.

What it looks like

Before: a reply ends with a code block starting with ! . You select the text, copy it, paste it into the input and press Enter.

After: a toast reads ⌨️ 実行するコマンドを入力欄に置きました(Enter で実行): … and the same text is already in the input. Press Enter and the plugin runs it; the submitted text is replaced with [MOD clip-command] ran: $ … followed by the exit code, seconds, stdout and stderr, so the model reads the output. If you were typing something, the toast reads 📋 実行するコマンドをクリップボードへ入れました(Cmd+V → Enter): … instead and your draft is left alone.

Requirements

  • Built on Claude Code's plugin hooks ("mods") API, which is in early access and may change between versions.
  • Developed and tested with Claude Code 2.1.287 to 2.1.289 on macOS.
  • Windows is untested. The plugin falls back to sending the text as a normal message when it cannot start a shell.

Install

From the marketplace in this repository:

claude plugin marketplace add i-noma-ru/claude-clip-command
claude plugin install clip-command@claude-clip-command

Or for one session only, from a clone:

claude --plugin-dir /path/to/claude-clip-command

To load a clone in every session, add it to the CLAUDE_CODE_PLUGIN_DIRS environment variable: a :-separated list where each entry is either a plugin folder itself or a folder that contains plugin folders (both confirmed in 2.1.292). The variable does not appear in claude --help, so treat it as subject to change.

How it works

At the end of each turn the plugin looks for the first fenced code block whose body starts with ! (the same prefix as Claude Code's shell mode). If there is none, it takes the first block that is a single-line slash command such as /rename my-session.

  • It copies the text to the clipboard.
  • If the prompt input is empty, it also places the text there as a draft. Text you are typing is never overwritten.
  • A toast tells you which of the two happened.

Blocks with a non-shell language tag (for example diff), multi-line slash blocks, and paths such as /tmp/x are ignored.

To make this useful, tell the model (for example in CLAUDE.md) to write commands that the user must run in a code block starting with ! .

Running the placed command

Text placed by prompt.fill is plain text; Claude Code does not switch to shell mode for it. So the plugin runs it itself:

  • Only when the submitted text is exactly the ! command the plugin placed in the input, and the submit came from the user pressing Enter in the prompt input.
  • It runs $SHELL -lc "<command without the leading ! >" in the session's working directory, with a 10-minute timeout.
  • The submitted text is replaced with the result ([MOD clip-command] ran: $ …, exit code, seconds, stdout, stderr), so the model reads the output.
  • The same text is never run twice. Anything you paste or type yourself, slash commands, and submits from other sources go through unchanged.

Security note: the command text is written by the model. The plugin never runs anything on its own; it runs the command only after you see it in the input and press Enter. Read the command before pressing Enter, the same as you would before pasting a command into a terminal. If you do not want this behaviour, do not install this plugin, or clear the input instead of pressing Enter.

What the plugin reads, writes, runs, and submits

This section lists everything the plugin touches, for review. Nothing leaves the machine, and the plugin never calls the model.

What it reads: the text of the reply that just finished (turn.complete), to find a code block starting with ! ; the current text of the prompt input, to check that it is empty; the text of a submitted prompt and where the submit came from; and the environment variable SHELL.

What it writes: the found command to the system clipboard (through Claude Code's $.ui.copy), and the same command into the prompt input, only when the input is empty ($.prompt.fill). Both are visible to you before anything else happens.

What it runs: $SHELL -lc "<the command without the leading ! >" in the session's working directory, with a 10-minute timeout. This happens only when the submitted text is exactly the command the plugin placed in the input and the submit came from you pressing Enter in the prompt input. The same text is never run twice. The command text comes from the model's reply, so read it before pressing Enter.

What it submits: when it runs a command, it replaces the text you submitted with the result, so the model reads the output instead of the command. The replacement is: a heading [MOD clip-command] ran: $ <command>, the exit code, seconds, and working directory, then stdout and stderr in fenced blocks (each truncated at 4 MiB). Nothing else is ever submitted or added to a prompt.

Tests

claude plugin validate .
claude plugin test .

Notes

  • Written with AI assistance (Claude Code).
  • This plugin comes from the author's own setup. The source comments and test names are in Japanese.
  • Companion plugin: claude-ask-choice. The two are independent.

License

MIT. See LICENSE.

Source 3 files
hooks/register.ts 139 lines
1import type { Register } from 'claude-code'
2
3import { findUserCommand } from './find'
4import { formatRun } from './run'
5
6export const register: Register = on => {
7  // クリップボードへ最後に入れたコマンド。同じ文では上書きしない(利用者が別の物をコピーしたあとに壊さないため)。
8  // 入力欄はこの決まりの対象外で、空なら毎回置く(書きかけを壊さない守りは「空のときだけ」が担う)。
9  // MOD が実行し終えたら消す(実行済みの文がまた来たら、それは新しい依頼)
10  let last = ''
11  // 入力欄に置いた「! 」コマンド。利用者がそのまま Enter したときだけ MOD が実行する
12  let placed = ''
13  // 同じターンの途中の返答で最後に見たコマンドと、そのターンの id。
14  // Stop フック(番犬)の差し戻しでターンが続くと、ブロックを書いた返答は「最後の返答」でなくなるので、ここで覚えておく
15  let stepCommand = ''
16  let stepTurn = ''
17
18  on('turn.step', async function* ($, e, next) {
19    const result = yield* next(e)
20
21    // サブエージェントの返答は利用者への依頼ではない
22    if (e.agentId === undefined) {
23      if (e.turnId !== stepTurn) {
24        stepTurn = e.turnId
25        stepCommand = ''
26      }
27
28      const found = findUserCommand(result.answer)
29
30      if (found !== '') {
31        stepCommand = found
32      }
33    }
34
35    return result
36  })
37
38  on('turn.complete', async ($, e, next) => {
39    const done = await next(e)
40
41    // サブエージェントの返答は利用者への依頼ではない
42    if (e.agentId !== undefined) {
43      return done
44    }
45
46    try {
47      // 最後の返答を優先し、無ければ同じターンの途中の返答で見たものを使う(別のターンのものは使わない)
48      let command = findUserCommand(e.answer)
49
50      if (command === '' && e.turnId === stepTurn) {
51        command = stepCommand
52      }
53
54      stepCommand = ''
55      stepTurn = ''
56
57      if (command !== '') {
58        const isCopied = command !== last && (await $.ui.copy({ text: command })).isCopied
59
60        // 入力欄が空のときだけ下書きとして置く(書きかけの文は上書きしない)
61        let filled = false
62
63        try {
64          const box = await $.prompt.read()
65
66          if (box.text.trim() === '') {
67            filled = (await $.prompt.fill({ text: command, mode: 'replace' })).isFilled
68          }
69        } catch {
70          // 入力欄の無い環境(-p・ダイアログ中)ではクリップボードだけ
71        }
72
73        if (isCopied) {
74          last = command
75        }
76
77        if (isCopied || filled) {
78          // 本体は fill のたびに入力欄を通常モードへ戻すので「! 」は文字のまま。Enter で来たら下の prompt.submit が実行する。
79          // 置けなかったときは前に置いた文を消す。ただし同じ文が入力欄に残っているだけなら、そのまま Enter で実行できるように保つ
80          if (filled) {
81            placed = command.startsWith('! ') ? command : ''
82          } else if (command !== placed) {
83            placed = ''
84          }
85
86          const first = command.split('\n')[0] ?? ''
87          const shown = first.length > 60 ? `${first.slice(0, 60)}…` : first
88
89          // 利用者にだけ知らせる(モデルは読まない)
90          const notice = filled
91            ? `⌨️ 実行するコマンドを入力欄に置きました(Enter で実行): ${shown}`
92            : `📋 実行するコマンドをクリップボードへ入れました(Cmd+V → Enter): ${shown}`
93
94          $.ui.toast(notice, { timeoutMs: 8000 })
95        }
96      }
97    } catch {
98      // コピーの故障で返答を止めない
99    }
100
101    return done
102  })
103
104  on('prompt.submit', async ($, e, next) => {
105    const text = e.text.trim()
106    // 置いた文と完全一致し、利用者自身の Enter(origin 無し=利用者・composer)のときだけ
107    const isUser = e.origin === undefined || e.origin.kind === 'composer'
108
109    if (placed === '' || text !== placed || !isUser) {
110      return next(e)
111    }
112
113    // 同じ文を二度は実行しない。実行済みなので、次に同じ文が来たらクリップボードにも入れ直す
114    placed = ''
115    last = ''
116
117    const command = text.slice(2)
118
119    try {
120      const shell = (await $.env.get('SHELL')) ?? '/bin/zsh'
121      const cwd = await $.session.cwd()
122      const started = await $.clock.now()
123      const ran = await $.process.run([shell, '-lc', command], { cwd, timeoutMs: 600_000 })
124      const seconds = Math.round((await $.clock.now()) - started) / 1000
125
126      $.ui.toast(`⌨️ 実行しました(終了コード ${ran.exitCode}・${seconds} 秒)`, { timeoutMs: 8000 })
127
128      return next({ ...e, text: formatRun(command, ran, seconds, cwd) })
129    } catch (error) {
130      // シェルが無い(Windows など)・起動に失敗したときは、そのまま文として通す
131      $.ui.toast(`⚠️ 実行できなかったので文として送ります: ${error instanceof Error ? error.message : String(error)}`, {
132        timeoutMs: 8000,
133      })
134
135      return next(e)
136    }
137  })
138}
139
hooks/find.ts 32 lines
1const SHELL_LANGS = ['bash', 'sh', 'zsh', 'shell', 'console', 'text']
2
3/**
4 * 中身が「! 」で始まる最初のコードブロックの中身を返す。
5 * それが無ければ、1 行のスラッシュコマンド(/rename … など)の最初のブロックを返す。どちらも無ければ ''。
6 */
7export function findUserCommand(text: string): string {
8  let slash = ''
9
10  for (const block of text.matchAll(/```([^\n]*)\n([\s\S]*?)```/g)) {
11    // 待機表示は ```diff の中に「! 【〜待ち…】」と書く決まりなので、シェル以外の言語指定は対象外にする
12    const lang = (block[1] ?? '').trim().toLowerCase()
13
14    if (lang && !SHELL_LANGS.includes(lang)) {
15      continue
16    }
17
18    const body = (block[2] ?? '').replace(/\s+$/, '')
19
20    if (/^! \S/.test(body) && !body.startsWith('! 【')) {
21      return body
22    }
23
24    // /Users/… のようなパスは「/」のあとに 2 つ目の「/」が続くので、コマンド名の形だけを通す
25    if (slash === '' && !body.includes('\n') && /^\/[a-z][a-z0-9-]*(\s|$)/.test(body)) {
26      slash = body
27    }
28  }
29
30  return slash
31}
32
hooks/run.ts 35 lines
1export type RunResult = {
2  exitCode: number
3  stdout: string
4  stderr: string
5  isStdoutTruncated: boolean
6  isStderrTruncated: boolean
7}
8
9/**
10 * 実行結果を、利用者が「! 」モードで実行したときと同じ読み方ができる 1 つの文にする。
11 * モデルはこれを利用者の送信として読む。見出しは、利用者の発言と見分けがつくように印つきの英語(2026-10-04)。
12 */
13export function formatRun(command: string, ran: RunResult, seconds: number, cwd: string): string {
14  const lines = [`[MOD clip-command] ran: $ ${command}`, `exit ${ran.exitCode} · ${seconds}s · cwd ${cwd}`]
15
16  const out = ran.stdout.replace(/\s+$/, '')
17  const err = ran.stderr.replace(/\s+$/, '')
18
19  lines.push('```stdout', out === '' ? '(no output)' : out, '```')
20
21  if (ran.isStdoutTruncated) {
22    lines.push('⚠️ stdout truncated at 4 MiB')
23  }
24
25  if (err !== '') {
26    lines.push('```stderr', err, '```')
27  }
28
29  if (ran.isStderrTruncated) {
30    lines.push('⚠️ stderr truncated at 4 MiB')
31  }
32
33  return lines.join('\n')
34}
35