SLOPSHOPPER

imagegen

Lets Claude make and edit images through Codex, and shows them in the chat when done

newrowsguardtoaststatustool
v0.1.0no licenseupdated 2026-10-16arjunkambj/claude-mods/imagegen
A shopper browsing a rack in a slop shop
README

Arjun's Claude Code mods

Four small mods for Claude Code. Install the ones you want.

ModWhat it does
codexClaude can hand a task to Codex (OpenAI's gpt-6.1-sol), like a code review or a bug hunt. It runs in the background so the chat isn't blocked.
imagegenClaude can make and edit images through Codex, and shows the finished images right in the chat.
devserver-limitStops Claude from starting a dev server when 2 are already running. Good for laptops with little memory.
done-soundPlays a chime when a task that took over a minute finishes.

Install

In a Claude Code terminal session, type one line per mod:

/plugin install codex --marketplace arjunkambj/claude-mods
/plugin install imagegen --marketplace arjunkambj/claude-mods
/plugin install devserver-limit --marketplace arjunkambj/claude-mods
/plugin install done-sound --marketplace arjunkambj/claude-mods

The first time, it asks to add the marketplace: press y. Then pick a scope (Enter picks "user", so it works in every project).

What you need

  • A Mac. imagegen and done-sound use macOS tools and sounds.
  • For codex and imagegen: the Codex CLI installed and signed in (codex login). Images count against your Codex plan.
Source 2 files
hooks/register.tsx 174 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Shot } from '../types'
5
6const TOOL = /^mcp__imagegen__image$/
7const SCRIPT = 'skills/imagegen/scripts/codex_image.sh'
8const PREVIEW_PX = 480
9const SVG_LIMIT = 131072
10
11const shots = atom({ plugin: 'imagegen', key: 'shots' } as const, {})
12
13type ImageInput = {
14  spec: string
15  output?: string
16  outputDir?: string
17  inputs?: string[]
18  count?: number
19  overwrite?: boolean
20}
21
22export const register: Register = on => {
23  on('session.start', async ($, e, next) => {
24    const started = await next(e)
25    await $.tool.register({
26      name: 'image',
27      description:
28        "Generates or edits raster images through Codex's built-in image tool (the user's Codex subscription, no API key). " +
29        'Write the spec as the imagegen skill describes, then call this instead of running codex_image.sh. Each image takes ' +
30        'about a minute; the finished images are shown to the user in the chat. Returns one saved path per line: open each ' +
31        'with Read and check it before reporting success. Never overwrite an existing asset unless the user asked for it.',
32      inputSchema: {
33        type: 'object',
34        properties: {
35          spec: { type: 'string', description: 'The full image spec, passed verbatim to the image tool.' },
36          output: { type: 'string', description: 'Destination file. With count > 1, -1, -2 … are added before the extension.' },
37          outputDir: { type: 'string', description: 'Destination directory instead of output (files named image_1.png …).' },
38          inputs: {
39            type: 'array',
40            items: { type: 'string' },
41            description: 'Input images on disk (edit target or references, in order). The spec must say each one’s role by index.',
42          },
43          count: { type: 'integer', minimum: 1, maximum: 8, description: 'Variants of the same spec; default 1.' },
44          overwrite: { type: 'boolean', description: 'Replace existing destination files; default false.' },
45        },
46        required: ['spec'],
47      },
48      isDeferred: false,
49    })
50    return started
51  })
52
53  on('tool.call', { tool: TOOL }, async ($, e, next) => {
54    const input = e as unknown as ImageInput
55    const count = input.count ?? 1
56
57    const argv = [`${$.plugin.root}/${SCRIPT}`, '-p', input.spec, '-n', String(count)]
58    for (const image of input.inputs ?? []) argv.push('-i', image)
59    if (input.output) argv.push('-o', input.output)
60    else if (input.outputDir) argv.push('-d', input.outputDir)
61    if (input.overwrite) argv.push('--force')
62
63    $.ui.status(count === 1 ? 'Making an image…' : `Making ${count} images…`)
64    try {
65      const it = $.process.spawn({ argv })[Symbol.asyncIterator]()
66      let out = ''
67      let errors = ''
68      for (;;) {
69        if (next.signal.aborted) {
70          await it.return?.(undefined as never)
71          return { result: 'Image run stopped.' }
72        }
73        const step = await it.next()
74        if (step.done) {
75          if (step.value.code !== 0) {
76            $.ui.toast('Image failed')
77            return { result: `Image run failed (exit ${step.value.code ?? step.value.signal}).\n${errors.slice(-4000)}` }
78          }
79          break
80        }
81        if (step.value.stream === 'stdout') out += step.value.text
82        else errors += step.value.text
83      }
84
85      const paths = out.split('\n').map(line => line.trim()).filter(Boolean)
86      if (paths.length === 0) return { result: `Codex finished without saving an image.\n${errors.slice(-4000)}` }
87
88      const made = await Promise.all(paths.map(path => preview($, path)))
89      if (e.tool_use_id) await update($, shots, all => ({ ...all, [e.tool_use_id as string]: made }))
90
91      const done = paths.length === 1 ? `Image ready: ${basename(paths[0] ?? "")}` : `${paths.length} images ready`
92      $.ui.toast(done)
93      void $.ui.notify(done, { title: 'Images' }).catch(() => undefined)
94
95      return { result: paths.join('\n') }
96    } finally {
97      $.ui.status(undefined)
98    }
99  }).catch(($, e, next) => (next.called ? next(e) : { deny: `${$.plugin.name}: the image run failed to start.` }))
100
101  // Shows the finished images in the chat, with buttons to open them.
102  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
103    if (!TOOL.test(e.props.tool) || e.props.isErrored) return next(e)
104    const list = (await read($, shots))[e.props.tool_use_id]
105    if (!list?.length) return next(e)
106
107    if (e.surface === 'desktop' || e.surface === 'vscode') {
108      const { Box, Button, Svg, Text } = $.ui.resolve(e)
109      return (
110        <Box flexDirection="column">
111          {list.map(shot => (
112            <Box key={shot.path} flexDirection="column" marginBottom={1}>
113              {shot.svg && (
114                <Svg source={shot.svg} alt={basename(shot.path)} width={Math.min(shot.width, 360)} />
115              )}
116              <Box>
117                <Text dimColor>{basename(shot.path)} </Text>
118                <Button key={`open-${shot.path}`} plain label="Open" onPress={() => openFile($, shot.path)} />
119                <Button key={`show-${shot.path}`} plain label="Show in Finder" onPress={() => openFile($, shot.path, true)} />
120              </Box>
121            </Box>
122          ))}
123        </Box>
124      )
125    }
126
127    const { Box, Button, Text } = $.ui.resolve(e)
128    return (
129      <Box flexDirection="column">
130        {list.map(shot => (
131          <Box key={shot.path}>
132            <Text>{shot.path} </Text>
133            <Button key={`open-${shot.path}`} plain label="Open" onPress={() => openFile($, shot.path)} />
134          </Box>
135        ))}
136      </Box>
137    )
138  })
139}
140
141// A small JPEG copy wrapped in an SVG, since the app draws SVG and not PNG files.
142async function preview($: EngineInterface, path: string): Promise<Shot> {
143  const tmp = (await $.process.run(['mktemp', '-t', 'imagegen'])).stdout.trim()
144  const jpg = `${tmp}.jpg`
145  try {
146    const made = await $.process.run([
147      'sips', '-Z', String(PREVIEW_PX), '-s', 'format', 'jpeg', '-s', 'formatOptions', '70', path, '--out', jpg,
148    ])
149    if (made.exitCode !== 0) return { path, svg: null, width: 0, height: 0 }
150
151    const size = (await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', jpg])).stdout
152    const width = Number(/pixelWidth: (\d+)/.exec(size)?.[1] ?? 0)
153    const height = Number(/pixelHeight: (\d+)/.exec(size)?.[1] ?? 0)
154    const { base64 } = await $.fs.read(jpg, { as: 'bytes' })
155
156    const svg =
157      `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
158      `<image href="data:image/jpeg;base64,${base64}" width="${width}" height="${height}"/></svg>`
159    return { path, svg: svg.length <= SVG_LIMIT ? svg : null, width, height }
160  } catch {
161    return { path, svg: null, width: 0, height: 0 }
162  } finally {
163    await $.process.run(['rm', '-f', tmp, jpg])
164  }
165}
166
167async function openFile($: EngineInterface, path: string, reveal = false) {
168  await $.process.run(reveal ? ['open', '-R', path] : ['open', path])
169}
170
171function basename(path: string): string {
172  return path.split('/').pop() ?? path
173}
174
types/index.d.ts 14 lines
1export type Shot = {
2  path: string
3  // A small preview as an SVG document, or null when it could not be made.
4  svg: string | null
5  width: number
6  height: number
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    imagegen: { shots: Record<string, Shot[]> }
12  }
13}
14