SLOPSHOPPER

clipboard-paste

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

newbandcommandtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clipboard-paste
› fix the failing auth test and add an audit log call ╭──────────────────────────────────────╮ │ clipboard-paste │ ⏺ Read(src/auth.ts) │ The clipboard reader failed (exit 0) │ ⎿ Read 6 lines ╰──────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /paste clipboard v: paste The clipboard reader failed (exit 0) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
clipboard v: paste The clipboard reader failed (exit 0)
README

clipboard-paste

A Claude Code mod that attaches what's on your system clipboard to your next prompt:

  • Images: "Copy image" in a browser, screenshots (Win+Shift+S, PrtScn, Cmd+Ctrl+Shift+4), images copied from apps
  • Files and folders copied in Explorer or Finder: images, PDFs, video, audio, documents. Any type works.
  • Image addresses: "Copy image address" puts a URL on the clipboard. The mod downloads it with 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.

Use it

WhereHow
PromptType /paste and press Enter. It runs even while Claude is working.
Band above the promptPress 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:

  1. The marker becomes the file's full path: [clipboard image #1: C:\proj\.claude\clipboard-paste\clip-….png].
  2. Claude gets a hidden note listing each item (type, size, path) and is told to open it with the Read tool. Read shows images and PDFs to the model.

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.

How it works, and why it isn't a native attachment

Mods can't add image bytes to a prompt; prompt.submit only reports native attachments, it can't create them. So the mod:

  • reads the clipboard with a platform helper through $.process.run:
  • Windows: powershell -STA -EncodedCommand … (System.Windows.Forms). It prefers the browser's lossless PNG format, then the plain bitmap, then file lists, then text.
  • macOS: osascript -l JavaScript with the AppKit bridge (NSPasteboard: file URLs, PNG, TIFF→PNG, text)
  • Linux: wl-paste (Wayland) or xclip (X11), images only
  • saves images to <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.
  • swaps markers for paths and adds the Read instruction on 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.

What the mod calls

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.

Develop

# 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).

Source 3 files
hooks/register.js 387 lines
1// 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}
387
hooks/readers.js 173 lines
1// 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`
173
types/index.d.ts 20 lines
1declare 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