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…

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.
/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.
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.
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.
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.
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 ! .
Text placed by prompt.fill is plain text; Claude Code does not switch to shell mode for it. So the plugin runs it itself:
! command the plugin placed in the input, and the submit came from the user pressing Enter in the prompt input.$SHELL -lc "<command without the leading ! >" in the session's working directory, with a 10-minute timeout.[MOD clip-command] ran: $ …, exit code, seconds, stdout, stderr), so the model reads the output.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.
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.
claude plugin validate .
claude plugin test .
MIT. See LICENSE.
hooks/register.ts 139 lines1import 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}
139hooks/find.ts 32 lines1const 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}
32hooks/run.ts 35 lines1export 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