/snap: drag a box on screen with Spectacle and the screenshot goes into your next message.

A Claude Code mod. Type /snap, drag a box on the screen, and the screenshot goes into your next message.
/snap runs Spectacle in region mode: spectacle --region --background --nonotify --output <file>.~/.cache/claude-snaps/snap-<YYYYMMDD-HHMMSS>.png.@<path> at the cursor in your prompt box. Claude Code reads an @-mentioned image file.Snap cancelled and changes nothing. A missing Spectacle shows Snap needs Spectacle (KDE).A plugin cannot attach an image to the draft itself (the types only describe attachments of a prompt already sent), so the mod uses the @path text.
You have 2 minutes to drag the box.
KDE with Spectacle (/usr/bin/spectacle).
claude plugin validate . tsc -p . claude plugin test .
hooks/register.ts 55 lines1import type { Register } from 'claude-code'
2
3import { snapArgv, snapFile } from './snap'
4
5const DRAG_TIMEOUT_MS = 120_000
6
7export const register: Register = on => {
8 on('session.start', async ($, e, next) => {
9 try {
10 await $.command.register({
11 name: 'snap',
12 description: 'Drag a box on screen; the screenshot goes into your message',
13 })
14 } catch {}
15 return next(e)
16 })
17
18 // A process.run in flight does not count against the hook's 10 s budget,
19 // so the wait while the person drags is free.
20 on('command.run', { command: 'snap' }, async $ => {
21 const home = (await $.env.get('HOME')) ?? ''
22 const startedAt = await $.clock.now()
23 const file = snapFile(home, startedAt)
24 const dir = file.slice(0, file.lastIndexOf('/'))
25 try {
26 await $.process.run(['mkdir', '-p', dir])
27 const shot = await $.process.run(snapArgv(file), { timeoutMs: DRAG_TIMEOUT_MS })
28 const check = shot.exitCode === 0 ? await $.process.run(['test', '-s', file]) : shot
29 if (check.exitCode !== 0) {
30 $.ui.toast('Snap cancelled')
31 return { text: 'Snap cancelled.' }
32 }
33 } catch (error) {
34 const message = await failureMessage($, error, startedAt)
35 $.ui.toast(message)
36 return { text: `${message}.` }
37 }
38 await $.prompt.fill({ text: `@${file} `, mode: 'insert' })
39 return { text: 'Snap added to your message.' }
40 })
41}
42
43// The engine rejects the same way for a missing binary, a timeout and a failed
44// start, so the cause is told apart by asking the shell and by the clock.
45async function failureMessage($: any, error: unknown, startedAt: number): Promise<string> {
46 try {
47 const lookup = await $.process.run(['sh', '-c', 'command -v spectacle'])
48 if (lookup.exitCode !== 0) return 'Snap needs Spectacle (KDE)'
49 } catch {}
50 const elapsed = (await $.clock.now()) - startedAt
51 if (elapsed >= DRAG_TIMEOUT_MS - 1000) return 'Snap timed out after 2 minutes'
52 const reason = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').slice(0, 80)
53 return `Snap failed: ${reason}`
54}
55hooks/snap.ts 16 lines1function pad(n: number): string {
2 return String(n).padStart(2, '0')
3}
4
5// ~/.cache/claude-snaps/snap-YYYYMMDD-HHMMSS.png in local time.
6export function snapFile(home: string, nowMs: number): string {
7 const d = new Date(nowMs)
8 const day = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`
9 const time = `${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
10 return `${home}/.cache/claude-snaps/snap-${day}-${time}.png`
11}
12
13export function snapArgv(file: string): string[] {
14 return ['spectacle', '--region', '--background', '--nonotify', '--output', file]
15}
16