Paste an image, media file or copied files from the system clipboard into the Claude Code prompt

A Claude Code mod that attaches what's on your system clipboard to your next prompt:
curl and keeps it only if it is an image, video, audio file or PDF.data: URIs (data:image/png;base64,…)Tested with Claude Code v2.1.300 on Windows 11. The macOS (JXA) and Linux (wl-paste/xclip) readers are covered by unit tests but haven't been run on a real Mac or Linux machine yet.
| Where | How |
|---|---|
| Prompt | Type /paste and press Enter. It runs even while Claude is working. |
| Band above the prompt | Press Ctrl+X Tab to focus the band, then v to paste or c to clear. Use x beside an item to drop it. You can also click the buttons. |
| List / clear | /paste list, /paste clear |
When you paste, the mod puts a [clip#1 clip-20261014-120000-000.png] marker at the cursor. Type your question around it and press Enter. When the prompt is sent:
[clipboard image #1: C:\proj\.claude\clipboard-paste\clip-….png].Delete a marker to leave that item out. If the prompt has no markers at all, for example when the box couldn't take the text, everything waiting goes with the prompt.
Mods can't add image bytes to a prompt; prompt.submit only reports native attachments, it can't create them. So the mod:
$.process.run:powershell -STA -EncodedCommand … (System.Windows.Forms). It prefers the browser's lossless PNG format, then the plain bitmap, then file lists, then text.osascript -l JavaScript with the AppKit bridge (NSPasteboard: file URLs, PNG, TIFF→PNG, text)wl-paste (Wayland) or xclip (X11), images only<project>/.claude/clipboard-paste/, which gets a .gitignore of * so captures never reach git. Copied files are referenced where they are and aren't copied.prompt.submit.Claude Code's own Ctrl+V (Alt+V on Windows) image paste still works. Use this mod when that one doesn't pick up the clipboard, for example browser images in some terminals, copied files, PDFs, media or image URLs.
claude plugin validate reports these calls:
$.process.run: powershell (Windows) or /bin/sh → osascript/wl-paste/xclip (macOS/Linux) to read the clipboard. curl to download a copied image URL (http/https only, max 50 MB, 30 s). cmd /c move/del or mv/rm to rename or discard that download.$.prompt.fill puts the marker in the prompt box.$.state holds the waiting items.$.session.cwd, $.env.get('OS') and $.clock.now detect the platform, find the capture folder and name files.Nothing is uploaded anywhere. The only network access is the curl download, and it runs only when you paste a URL.
# Windows (PowerShell)
claude plugin validate .\plugins\clipboard-paste --strict
Set-Location .\plugins\clipboard-paste; claude plugin test
claude --plugin-dir .\plugins\clipboard-paste
# macOS / Linux
claude plugin validate ./plugins/clipboard-paste --strict
(cd plugins/clipboard-paste && claude plugin test)
claude --plugin-dir ./plugins/clipboard-paste
Files: hooks/register.js (hooks, band, marker logic), hooks/readers.js (the three clipboard readers as strings), types/index.d.ts ($.state keys), tests/ (11 tests, Windows and POSIX).
hooks/register.js 387 lines1// clipboard-paste: get an image, media file or copied files from the system
2// clipboard into the Claude Code prompt.
3//
4// A mod can't put image bytes into a prompt, so the clipboard is saved to
5// <project>/.claude/clipboard-paste/ (git-ignored), a [clip#N name] marker is
6// put in the prompt box, and when the prompt is sent the marker becomes the
7// file's path and Claude is told to open it with the Read tool, which shows
8// images and PDFs to the model.
9//
10// Triggers: /paste (works while Claude is busy), or the band above the prompt
11// (Ctrl+X Tab, then v).
12
13import { atom, read, update } from 'claude-code'
14import { WINDOWS_READER, MAC_READER, POSIX_READER } from './readers.js'
15
16const pending = atom({ plugin: 'clipboard-paste', key: 'pending' }, [])
17const counter = atom({ plugin: 'clipboard-paste', key: 'counter' }, 0)
18const note = atom({ plugin: 'clipboard-paste', key: 'note' }, '')
19
20const MARKER = /\[clip#(\d+)[^\]\n]*\]/g
21const MAX_DOWNLOAD_BYTES = '52428800' // 50 MiB, for "copy image address" URLs
22
23const MEDIA_TYPES = {
24 png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
25 bmp: 'image/bmp', svg: 'image/svg+xml', heic: 'image/heic', tif: 'image/tiff', tiff: 'image/tiff',
26 pdf: 'application/pdf', mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', mkv: 'video/x-matroska',
27 mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg', flac: 'audio/flac',
28 txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', json: 'application/json', html: 'text/html',
29 docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
30 xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
31 pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
32 zip: 'application/zip',
33}
34const EXTENSIONS = {
35 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', 'image/bmp': '.bmp',
36 'image/svg+xml': '.svg', 'application/pdf': '.pdf', 'video/mp4': '.mp4', 'video/webm': '.webm',
37 'audio/mpeg': '.mp3', 'audio/wav': '.wav',
38}
39const ATTACHABLE = /^(image|video|audio)\/|^application\/pdf$/
40
41let platform // 'windows' | 'posix', cached until the next reload
42let busy = false // one clipboard read at a time
43
44export function register(on) {
45 on('session.start', async ($, e, next) => {
46 await detectPlatform($)
47 try {
48 await $.command.register({
49 name: 'paste',
50 description: 'Attach the image, media or files on the system clipboard to your next prompt',
51 argumentHint: '[list | clear]',
52 immediate: true,
53 })
54 } catch (err) {
55 $.ui.log('could not register /paste: ' + err.message, { to: 'debug' })
56 }
57 return next(e)
58 })
59
60 on('command.run', { command: 'paste' }, async ($, e) => {
61 const arg = String(e.args ?? '').trim().toLowerCase()
62 if (arg === 'clear') {
63 await update($, pending, () => [])
64 await update($, note, () => '')
65 return { text: 'Clipboard attachments cleared.' }
66 }
67 if (arg === 'list') {
68 const items = await read($, pending)
69 if (items.length === 0) return { text: 'No clipboard attachments are waiting.' }
70 return { text: 'Waiting for your next prompt:\n' + items.map(describeItem).join('\n') }
71 }
72 const message = await pasteFromClipboard($)
73 $.ui.toast(message)
74 return {}
75 })
76
77 // On send: swap markers for real paths and tell Claude how to open them
78 on('prompt.submit', async ($, e, next) => {
79 if (e.origin?.kind === 'plugin' || /^\s*\//.test(e.text)) return next(e)
80 const items = await read($, pending)
81 if (items.length === 0) return next(e)
82
83 const ids = [...e.text.matchAll(MARKER)].map((m) => Number(m[1]))
84 // Markers decide what goes; with none in the text (the fill didn't land,
85 // or the person typed over it) everything waiting goes with this prompt.
86 const chosen = ids.length > 0 ? items.filter((it) => ids.includes(it.id)) : items
87 if (chosen.length === 0) return next(e)
88
89 const text = e.text.replace(MARKER, (marker, n) => {
90 const it = chosen.find((c) => c.id === Number(n))
91 return it ? '[clipboard ' + it.kind + ' #' + it.id + ': ' + it.path + ']' : marker
92 })
93 await update($, pending, () => [])
94 await update($, note, () => '')
95 return next({ ...e, text, context: [...(e.context ?? []), contextFor(chosen)] })
96 })
97
98 // The band above the prompt: what's waiting, plus paste and clear buttons
99 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
100 if (e.props.hasSurvey) return next(e)
101 const items = await read($, pending)
102 const message = await read($, note)
103 const { Box, Text, Button } = $.ui.resolve(e)
104 const columns = e.props.bodyColumns ?? 80
105
106 const chips = items.map((it) =>
107 Box({
108 key: 'item-' + it.id,
109 flexDirection: 'row',
110 children: [
111 Text({ color: 'cyan', children: ['#' + it.id + ' '] }),
112 Text({ wrap: 'truncate-middle', children: [columns >= 70 ? it.name : shorten(it.name, 18)] }),
113 ...(it.detail && columns >= 90 ? [Text({ dimColor: true, children: [' ' + it.detail] })] : []),
114 Button({
115 key: 'remove-' + it.id,
116 label: 'x',
117 plain: true,
118 dimColor: true,
119 onPress: async () => {
120 await update($, pending, (list) => list.filter((x) => x.id !== it.id))
121 },
122 }),
123 ],
124 }),
125 )
126
127 return Box({
128 flexDirection: 'row',
129 columnGap: 2,
130 paddingX: 1,
131 children: [
132 items.length
133 ? Text({ color: 'cyan', bold: true, children: ['clipboard ●' + items.length] })
134 : Text({ dimColor: true, children: ['clipboard'] }),
135 ...chips,
136 Button({
137 key: 'paste',
138 label: 'paste',
139 hotkey: 'v',
140 plain: true,
141 onPress: async () => {
142 const result = await pasteFromClipboard($)
143 $.ui.toast(result)
144 },
145 }),
146 ...(items.length
147 ? [Button({ key: 'clear', label: 'clear', hotkey: 'c', plain: true, onPress: async () => { await update($, pending, () => []) } })]
148 : []),
149 ...(message && columns >= 60 ? [Text({ dimColor: true, wrap: 'truncate-end', children: [message] })] : []),
150 ],
151 })
152 })
153}
154
155// --- clipboard ------------------------------------------------------------
156
157async function detectPlatform($) {
158 if (platform) return platform
159 const cwd = await $.session.cwd()
160 if (/^[A-Za-z]:[\\/]/.test(cwd) || cwd.startsWith('\\\\')) {
161 platform = 'windows'
162 } else {
163 platform = (await $.env.get('OS')) === 'Windows_NT' ? 'windows' : 'posix'
164 }
165 return platform
166}
167
168// Reads the clipboard, records what it found and puts markers in the prompt.
169// Returns a one-line message for a toast.
170async function pasteFromClipboard($) {
171 if (busy) return 'Still reading the clipboard…'
172 busy = true
173 try {
174 await update($, note, () => 'reading clipboard…')
175 const os = await detectPlatform($)
176 const cwd = await $.session.cwd()
177 const dir = joinPath(os, cwd, '.claude', 'clipboard-paste')
178 const base = 'clip-' + stamp(await $.clock.now())
179
180 let found = await runReader($, os, dir, base)
181 if (found.url) found = await download($, os, dir, base, found.url)
182 if (!found.items.length) {
183 const reason = found.reason || 'Nothing on the clipboard can be attached.'
184 await update($, note, () => reason)
185 return reason
186 }
187
188 let id = await read($, counter)
189 const added = found.items.map((raw) => {
190 id += 1
191 const path = raw.path || joinPath(os, dir, base + '.png')
192 const name = baseName(path)
193 return {
194 id,
195 kind: raw.kind === 'folder' ? 'folder' : raw.kind === 'image' ? 'image' : 'file',
196 path,
197 name,
198 mediaType: raw.kind === 'folder' ? 'inode/directory' : mediaTypeOf(name),
199 detail: raw.kind === 'file' && /^\d+$/.test(String(raw.detail ?? '')) ? bytes(Number(raw.detail)) : String(raw.detail ?? ''),
200 }
201 })
202 await update($, counter, () => id)
203 await update($, pending, (list) => [...list, ...added])
204 await update($, note, () => '')
205
206 // Put the markers at the cursor so the person sees where the item goes
207 const markers = added.map((it) => '[clip#' + it.id + ' ' + it.name + ']')
208 const text = markers.join(' ') + ' '
209 let offset = 0
210 const decorations = markers.map((m) => {
211 const d = { start: offset, end: offset + m.length, color: 'cyan', bold: true }
212 offset += m.length + 1
213 return d
214 })
215 let filled = false
216 try {
217 filled = (await $.prompt.fill({ text, mode: 'insert', decorations })).isFilled
218 } catch {
219 filled = false
220 }
221 const what = added.length === 1 ? added[0].kind + ' ' + added[0].name : added.length + ' items'
222 return filled
223 ? 'Attached ' + what + '. Type your prompt and press Enter.'
224 : 'Attached ' + what + '. It goes with your next prompt.'
225 } catch (err) {
226 const reason = 'Clipboard paste failed: ' + (err?.message ?? String(err))
227 await update($, note, () => reason)
228 return reason
229 } finally {
230 busy = false
231 }
232}
233
234async function runReader($, os, dir, base) {
235 const argv = os === 'windows'
236 ? ['powershell', '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-EncodedCommand',
237 encodePowerShell('$dir = ' + psQuote(dir) + '\n$base = ' + psQuote(base) + '\n' + WINDOWS_READER)]
238 : ['/bin/sh', '-c', POSIX_READER, 'sh', dir, base, MAC_READER]
239 let r
240 try {
241 r = await $.process.run(argv, { timeoutMs: 20_000 })
242 } catch (err) {
243 return { items: [], reason: 'Could not start the clipboard reader: ' + err.message }
244 }
245 const parsed = parseLastJson(r.stdout)
246 if (!parsed) {
247 const err = (r.stderr || r.stdout || '').replace(/\r\n/g, '\n').trim().split('\n').slice(-1)[0]
248 return { items: [], reason: 'The clipboard reader failed' + (err ? ': ' + err : ' (exit ' + r.exitCode + ')') }
249 }
250 return { items: Array.isArray(parsed.items) ? parsed.items : parsed.items ? [parsed.items] : [], reason: parsed.reason, url: parsed.url }
251}
252
253// "Copy image address" leaves a URL: fetch it with curl (curl.exe ships with
254// Windows 10 1803+ and macOS), keep it only if it is an image, media or PDF.
255async function download($, os, dir, base, url) {
256 const tmp = joinPath(os, dir, base + '.download')
257 let r
258 try {
259 r = await $.process.run(
260 ['curl', '-fsSL', '--proto', '=http,https', '-m', '30', '--max-filesize', MAX_DOWNLOAD_BYTES, '-o', tmp, '-w', '%{content_type}', url],
261 { timeoutMs: 40_000 },
262 )
263 } catch (err) {
264 return { items: [], reason: 'curl could not start to download the copied URL: ' + err.message }
265 }
266 if (r.exitCode !== 0) return { items: [], reason: 'Could not download the copied URL (curl exit ' + r.exitCode + ').' }
267 const mime = r.stdout.trim().split(';')[0].trim().toLowerCase()
268 if (!ATTACHABLE.test(mime)) {
269 await removeFile($, os, tmp)
270 return { items: [], reason: 'The copied URL is ' + (mime || 'an unknown type') + ', not an image, media file or PDF.' }
271 }
272 const dest = joinPath(os, dir, base + (EXTENSIONS[mime] ?? '.bin'))
273 try {
274 const moved = os === 'windows'
275 ? await $.process.run(['cmd', '/d', '/c', 'move', '/y', tmp, dest], { timeoutMs: 10_000 })
276 : await $.process.run(['mv', '-f', tmp, dest], { timeoutMs: 10_000 })
277 if (moved.exitCode !== 0) return { items: [{ kind: mime.startsWith('image/') ? 'image' : 'file', path: tmp }] }
278 } catch {
279 return { items: [{ kind: mime.startsWith('image/') ? 'image' : 'file', path: tmp }] }
280 }
281 return { items: [{ kind: mime.startsWith('image/') ? 'image' : 'file', path: dest }] }
282}
283
284async function removeFile($, os, path) {
285 try {
286 await $.process.run(os === 'windows' ? ['cmd', '/d', '/c', 'del', '/f', '/q', path] : ['rm', '-f', path], { timeoutMs: 10_000 })
287 } catch {
288 // leftover temp file in a git-ignored folder: harmless
289 }
290}
291
292// --- text helpers (no $) --------------------------------------------------
293
294function contextFor(items) {
295 const lines = items.map((it) => '- ' + describeItem(it))
296 const hasMedia = items.some((it) => /^(video|audio)\//.test(it.mediaType))
297 const hasFolder = items.some((it) => it.kind === 'folder')
298 return [
299 'The user attached ' + items.length + ' item' + (items.length === 1 ? '' : 's') + ' from their system clipboard to this prompt:',
300 ...lines,
301 'Open each image, PDF or text file with the Read tool before answering; Read shows images and PDFs to you.' +
302 (hasFolder ? ' For a folder, list its contents with Glob.' : '') +
303 (hasMedia ? ' Read cannot play video or audio; work with those through their paths (for example with ffprobe or ffmpeg) if the task needs it.' : ''),
304 'The [clipboard <kind> #N: <path>] tags in the prompt mark where each item was pasted.',
305 ].join('\n')
306}
307
308function describeItem(it) {
309 const facts = [it.mediaType, it.detail].filter(Boolean).join(', ')
310 return '#' + it.id + ' ' + it.kind + (facts ? ' (' + facts + ')' : '') + ': ' + it.path
311}
312
313// The readers print one {"items":…} object; PowerShell can glue CLIXML
314// progress records in front of it on the same line, so search for the start.
315function parseLastJson(stdout) {
316 const text = String(stdout ?? '').replace(/\r\n/g, '\n')
317 const start = text.lastIndexOf('{"items"')
318 if (start < 0) return null
319 const end = text.indexOf('\n', start)
320 try {
321 return JSON.parse(end < 0 ? text.slice(start) : text.slice(start, end))
322 } catch {
323 return null
324 }
325}
326
327function joinPath(os, ...parts) {
328 const sep = os === 'windows' ? '\\' : '/'
329 return parts
330 .map((p, i) => (i === 0 ? String(p).replace(/[\\/]+$/, '') : String(p).replace(/^[\\/]+|[\\/]+$/g, '')))
331 .join(sep)
332}
333
334function baseName(path) {
335 const parts = String(path).split(/[\\/]/).filter(Boolean)
336 return parts[parts.length - 1] ?? String(path)
337}
338
339function mediaTypeOf(name) {
340 const m = /\.([A-Za-z0-9]+)$/.exec(name)
341 return (m && MEDIA_TYPES[m[1].toLowerCase()]) || 'application/octet-stream'
342}
343
344function stamp(ms) {
345 const d = new Date(Number(ms) || 0)
346 const two = (n) => String(n).padStart(2, '0')
347 return d.getFullYear() + two(d.getMonth() + 1) + two(d.getDate()) + '-' + two(d.getHours()) + two(d.getMinutes()) + two(d.getSeconds()) +
348 '-' + String(d.getMilliseconds()).padStart(3, '0')
349}
350
351function bytes(n) {
352 if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'
353 if (n >= 1024) return Math.round(n / 1024) + ' KB'
354 return n + ' B'
355}
356
357function shorten(s, max) {
358 return s.length <= max ? s : s.slice(0, max - 1) + '…'
359}
360
361function psQuote(s) {
362 return "'" + String(s).replace(/'/g, "''") + "'"
363}
364
365// -EncodedCommand takes base64 of UTF-16LE: no quoting rules to get wrong
366function encodePowerShell(script) {
367 const raw = new Uint8Array(script.length * 2)
368 for (let i = 0; i < script.length; i++) {
369 const c = script.charCodeAt(i)
370 raw[i * 2] = c & 0xff
371 raw[i * 2 + 1] = c >> 8
372 }
373 return base64(raw)
374}
375
376function base64(raw) {
377 const abc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
378 let out = ''
379 for (let i = 0; i < raw.length; i += 3) {
380 const n = (raw[i] << 16) | ((raw[i + 1] ?? 0) << 8) | (raw[i + 2] ?? 0)
381 out += abc[(n >> 18) & 63] + abc[(n >> 12) & 63]
382 out += i + 1 < raw.length ? abc[(n >> 6) & 63] : '='
383 out += i + 2 < raw.length ? abc[n & 63] : '='
384 }
385 return out
386}
387hooks/readers.js 173 lines1// Clipboard readers, one per platform. Each one writes what it finds into the
2// capture folder and prints one JSON line:
3// { items: [{ kind, path?, detail? }], reason?: string, url?: string }
4// kind is 'image' | 'file' | 'folder'. `url` means the clipboard held a web
5// address; register.js downloads it with curl. No $ calls here: these are
6// plain strings, so the hooks module stays the only place that touches $.
7
8// Windows PowerShell 5.1 (ships with every Windows). Run with -STA: the
9// clipboard API needs a single-threaded apartment. $dir and $base are
10// prepended by register.js as single-quoted literals.
11export const WINDOWS_READER = `
12$ErrorActionPreference = 'Stop'
13# Otherwise "Preparing modules for first use" arrives as CLIXML on stdout
14$ProgressPreference = 'SilentlyContinue'
15[Console]::OutputEncoding = [Text.Encoding]::UTF8
16function Done($items, $reason, $url) {
17 $o = [ordered]@{ items = @($items); reason = $reason; url = $url }
18 ConvertTo-Json -InputObject $o -Compress -Depth 4
19 exit 0
20}
21function MakeItem($kind, $path, $detail) { [ordered]@{ kind = $kind; path = $path; detail = $detail } }
22function PathItem($p) {
23 $it = Get-Item -LiteralPath $p -Force
24 if ($it.PSIsContainer) { MakeItem 'folder' $it.FullName '' } else { MakeItem 'file' $it.FullName ('' + $it.Length) }
25}
26function Size($p) {
27 try { $i = [Drawing.Image]::FromFile($p); $s = '' + $i.Width + 'x' + $i.Height; $i.Dispose(); $s } catch { '' }
28}
29function ExtOf($mime) {
30 switch ($mime.ToLower()) {
31 'image/png' { '.png' } 'image/jpeg' { '.jpg' } 'image/gif' { '.gif' } 'image/webp' { '.webp' }
32 'image/bmp' { '.bmp' } 'image/svg+xml' { '.svg' } 'application/pdf' { '.pdf' }
33 'video/mp4' { '.mp4' } 'video/webm' { '.webm' } 'audio/mpeg' { '.mp3' } 'audio/wav' { '.wav' }
34 default { '.bin' }
35 }
36}
37try {
38 Add-Type -AssemblyName System.Windows.Forms, System.Drawing
39 New-Item -ItemType Directory -Force -Path $dir | Out-Null
40 $ignore = Join-Path $dir '.gitignore'
41 if (-not (Test-Path -LiteralPath $ignore)) { [IO.File]::WriteAllText($ignore, '*' + [Environment]::NewLine) }
42
43 $data = [Windows.Forms.Clipboard]::GetDataObject()
44 if ($null -eq $data) { Done @() 'The clipboard is empty.' $null }
45
46 # Files copied in Explorer (or a browser download list)
47 if ($data.GetDataPresent([Windows.Forms.DataFormats]::FileDrop)) {
48 $list = @()
49 foreach ($f in $data.GetData([Windows.Forms.DataFormats]::FileDrop)) { $list += PathItem $f }
50 Done $list $null $null
51 }
52
53 $png = Join-Path $dir ($base + '.png')
54 # Browsers put a lossless PNG stream next to the bitmap: keeps transparency
55 if ($data.GetDataPresent('PNG')) {
56 $s = $data.GetData('PNG')
57 if ($s -is [IO.Stream]) {
58 $fs = [IO.File]::Create($png)
59 try { $s.Position = 0; $s.CopyTo($fs) } finally { $fs.Dispose() }
60 Done @(MakeItem 'image' $png (Size $png)) $null $null
61 }
62 }
63 # Screenshots (Win+Shift+S, PrtScn) and other apps put a plain bitmap
64 if ([Windows.Forms.Clipboard]::ContainsImage()) {
65 $img = [Windows.Forms.Clipboard]::GetImage()
66 $img.Save($png, [Drawing.Imaging.ImageFormat]::Png)
67 $size = '' + $img.Width + 'x' + $img.Height
68 $img.Dispose()
69 Done @(MakeItem 'image' $png $size) $null $null
70 }
71
72 if ([Windows.Forms.Clipboard]::ContainsText()) {
73 $t = [Windows.Forms.Clipboard]::GetText().Trim().Trim('"')
74 if ($t.Length -gt 0 -and $t.Length -lt 2048 -and $t -notmatch '[\\r\\n]' -and (Test-Path -LiteralPath $t)) {
75 Done @(PathItem $t) $null $null
76 }
77 if ($t -match '^data:([\\w.+-]+/[\\w.+-]+);base64,(.+)$') {
78 $mime = $Matches[1]
79 $p = Join-Path $dir ($base + (ExtOf $mime))
80 [IO.File]::WriteAllBytes($p, [Convert]::FromBase64String($Matches[2]))
81 if ($mime -like 'image/*') { Done @(MakeItem 'image' $p (Size $p)) $null $null }
82 Done @(MakeItem 'file' $p '') $null $null
83 }
84 if ($t -match '^https?://\\S+$') { Done @() $null $t }
85 Done @() 'The clipboard holds text, not an image, file or media. Paste text with Ctrl+V.' $null
86 }
87 Done @() 'Nothing on the clipboard can be attached (no image, file or media).' $null
88} catch {
89 Done @() ('Could not read the clipboard: ' + $_.Exception.Message) $null
90}
91`
92
93// macOS: JavaScript for Automation with the AppKit bridge. Arguments arrive
94// in run(argv), so nothing user-controlled is spliced into the source.
95export const MAC_READER = `
96ObjC.import('AppKit');
97function run(argv) {
98 var dir = argv[0], base = argv[1];
99 var fm = $.NSFileManager.defaultManager;
100 var pb = $.NSPasteboard.generalPasteboard;
101 function done(items, reason, url) { return JSON.stringify({ items: items, reason: reason || null, url: url || null }); }
102 function pathItem(p) {
103 var isDir = Ref();
104 fm.fileExistsAtPathIsDirectory(p, isDir);
105 return { kind: isDir[0] ? 'folder' : 'file', path: p };
106 }
107 fm.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(dir, true, $(), $());
108 var ignore = dir + '/.gitignore';
109 if (!fm.fileExistsAtPath(ignore)) $('*\\n').writeToFileAtomicallyEncodingError(ignore, true, $.NSUTF8StringEncoding, $());
110
111 var opts = $.NSDictionary.dictionaryWithObjectForKey($.NSNumber.numberWithBool(true), $.NSPasteboardURLReadingFileURLsOnlyKey);
112 var urls = pb.readObjectsForClassesOptions($.NSArray.arrayWithObject($.NSURL), opts);
113 if (urls && !urls.isNil() && urls.count > 0) {
114 var items = [];
115 for (var i = 0; i < urls.count; i++) items.push(pathItem(ObjC.unwrap(urls.objectAtIndex(i).path)));
116 return done(items);
117 }
118
119 var png = dir + '/' + base + '.png';
120 var data = pb.dataForType($.NSPasteboardTypePNG);
121 if (data.isNil()) {
122 var tiff = pb.dataForType($.NSPasteboardTypeTIFF);
123 if (!tiff.isNil()) {
124 var rep0 = $.NSBitmapImageRep.imageRepWithData(tiff);
125 if (!rep0.isNil()) data = rep0.representationUsingTypeProperties(4, $.NSDictionary.dictionary); // 4 = PNG; non-nil properties
126 }
127 }
128 if (!data.isNil()) {
129 data.writeToFileAtomically(png, true);
130 var rep = $.NSBitmapImageRep.imageRepWithData(data);
131 return done([{ kind: 'image', path: png, detail: rep.isNil() ? '' : rep.pixelsWide + 'x' + rep.pixelsHigh }]);
132 }
133
134 var s = pb.stringForType($.NSPasteboardTypeString);
135 if (!s.isNil()) {
136 var t = ObjC.unwrap(s).trim().replace(/^"|"$/g, '');
137 if (t.length > 0 && t.length < 2048 && !/[\\r\\n]/.test(t) && fm.fileExistsAtPath(t)) return done([pathItem(t)]);
138 var m = /^data:([\\w.+-]+\\/[\\w.+-]+);base64,(.+)$/.exec(t);
139 if (m) {
140 var exts = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', 'image/svg+xml': '.svg', 'application/pdf': '.pdf' };
141 var p = dir + '/' + base + (exts[m[1].toLowerCase()] || '.bin');
142 $.NSData.alloc.initWithBase64EncodedStringOptions(m[2], 0).writeToFileAtomically(p, true);
143 return done([{ kind: /^image\\//i.test(m[1]) ? 'image' : 'file', path: p }]);
144 }
145 if (/^https?:\\/\\/\\S+$/.test(t)) return done([], null, t);
146 return done([], 'The clipboard holds text, not an image, file or media. Paste text with Cmd+V.');
147 }
148 return done([], 'Nothing on the clipboard can be attached (no image, file or media).');
149}
150`
151
152// POSIX entry point: $1 dir, $2 base, $3 the macOS reader. macOS runs the
153// JXA reader; Linux uses wl-paste (Wayland) or xclip (X11) for images.
154export const POSIX_READER = `
155dir="$1"; base="$2"
156mkdir -p "$dir" || { printf '{"items":[],"reason":"Could not create the capture folder."}\\n'; exit 0; }
157[ -f "$dir/.gitignore" ] || printf '*\\n' > "$dir/.gitignore"
158if [ "$(uname -s)" = Darwin ]; then exec osascript -l JavaScript -e "$3" "$dir" "$base"; fi
159out="$dir/$base.png"
160if [ -n "$WAYLAND_DISPLAY" ] && command -v wl-paste >/dev/null 2>&1; then
161 if wl-paste --list-types | grep -qx 'image/png'; then
162 wl-paste --type image/png > "$out" && printf '{"items":[{"kind":"image"}]}\\n' && exit 0
163 fi
164elif command -v xclip >/dev/null 2>&1; then
165 if xclip -selection clipboard -t TARGETS -o | grep -qx 'image/png'; then
166 xclip -selection clipboard -t image/png -o > "$out" && printf '{"items":[{"kind":"image"}]}\\n' && exit 0
167 fi
168else
169 printf '{"items":[],"reason":"Install wl-clipboard or xclip to paste images on Linux."}\\n'; exit 0
170fi
171printf '{"items":[],"reason":"No image on the clipboard."}\\n'
172`
173types/index.d.ts 20 lines1declare module 'claude-code' {
2 interface PluginState {
3 'clipboard-paste': {
4 // Items read from the clipboard and waiting for the next prompt
5 pending: Array<{
6 id: number
7 kind: 'image' | 'file' | 'folder'
8 path: string
9 name: string
10 mediaType: string
11 detail: string
12 }>
13 // Last id handed out, so markers stay unique within a session
14 counter: number
15 // One-line note shown in the band (last error or "reading…")
16 note: string
17 }
18 }
19}
20