SLOPSHOPPER

jr-image-guard

Keeps oversized images out of the conversation: an image over 7800 px is read through a resized copy, and browser viewports over the limit are refused.

newguardtoastprocess
★ 2v0.1.0MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-image-guard
A shopper browsing a rack in a slop shop
README

jr-image-guard

The API refuses images over about 8000 px on a side, and a refused image breaks the turn. This mod keeps them out:

  • When Claude reads an image (PNG, JPEG, WebP, GIF, TIFF, HEIC, BMP) larger than the limit on either side, it reads a resized copy instead. Your file is never touched; the copy goes to /tmp/jr-image-guard. A toast says what happened.
  • A Playwright browser viewport larger than the limit is refused, so the screenshots taken from it stay readable.

Resizing uses ImageMagick when it is installed, sips otherwise (macOS).

Settings (/config)

OptionDefault
Largest side (px)7800Images larger than this on either side are resized

Install

/plugin install jr-image-guard --marketplace jrlprost/claude-code-mods

Access

Runs sips (and magick when present) on images Claude is about to read. Writes resized copies to /tmp/jr-image-guard. Sends nothing over the network.

Source 2 files
hooks/register.ts 45 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { IMAGE, isTooLarge, parseSips, resizedPath } from './image'
4
5const DIR = '/tmp/jr-image-guard'
6
7async function sizeOf($: EngineInterface, file: string) {
8  const { exitCode, stdout } = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file])
9  return exitCode === 0 ? parseSips(stdout) : null
10}
11
12/** A copy that fits in `max` on both axes: ImageMagick when present, sips otherwise. */
13async function shrink($: EngineInterface, file: string, max: number) {
14  await $.process.run(['mkdir', '-p', DIR])
15  const out = resizedPath(DIR, file, await $.clock.now())
16  const magick = await $.process.run(['sh', '-c', 'command -v magick >/dev/null && magick "$0" -resize "$1x$1>" "$2"', file, String(max), out])
17  if (magick.exitCode === 0) return out
18  const sips = await $.process.run(['sips', '-Z', String(max), file, '--out', out])
19  return sips.exitCode === 0 ? out : null
20}
21
22export const register: Register = (on, options) => {
23  const max = Number(options.maxPixels ?? 7800)
24
25  // Images over the limit break the conversation: Claude reads a resized copy instead.
26  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
27    if (!IMAGE.test(e.file_path)) return next(e)
28    const size = await sizeOf($, e.file_path)
29    if (!isTooLarge(size, max)) return next(e)
30    const copy = await shrink($, e.file_path, max)
31    if (!copy) return { deny: `jr-image-guard: ${e.file_path} is ${size![0]}×${size![1]} px, over ${max} px, and could not be resized.` }
32    $.ui.toast(`jr image guard · ${size![0]}×${size![1]} px → resized to fit ${max} px`)
33    return next({ ...e, file_path: copy })
34  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'jr-image-guard: the size check failed, so the image was not read.' }))
35
36  // A browser viewport over the limit makes screenshots that break the conversation.
37  on('tool.call', { tool: 'mcp__playwright__browser_resize' }, ($, e, next) => {
38    const { width, height } = e as unknown as { width?: number; height?: number }
39    if ((width ?? 0) > max || (height ?? 0) > max) {
40      return { deny: `jr-image-guard: a ${width}×${height} viewport is over ${max} px; screenshots that size break the conversation. Use ${max} px or less.` }
41    }
42    return next(e)
43  })
44}
45
hooks/image.ts 13 lines
1export const IMAGE = /\.(png|jpe?g|webp|gif|tiff?|heic|bmp)$/i
2
3/** `sips -g pixelWidth -g pixelHeight` output → [width, height], or null. */
4export const parseSips = (stdout: string): [number, number] | null => {
5  const w = stdout.match(/pixelWidth:\s*(\d+)/)
6  const h = stdout.match(/pixelHeight:\s*(\d+)/)
7  return w && h ? [Number(w[1]), Number(h[1])] : null
8}
9
10export const isTooLarge = (size: [number, number] | null, max: number) => !!size && (size[0] > max || size[1] > max)
11
12export const resizedPath = (dir: string, file: string, stamp: number) => `${dir}/${stamp}-${file.split('/').pop() ?? 'image.png'}`
13