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

Four small mods for Claude Code. Install the ones you want.
| Mod | What it does |
|---|---|
codex | Claude 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. |
imagegen | Claude can make and edit images through Codex, and shows the finished images right in the chat. |
devserver-limit | Stops Claude from starting a dev server when 2 are already running. Good for laptops with little memory. |
done-sound | Plays a chime when a task that took over a minute finishes. |
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).
imagegen and done-sound use macOS tools and sounds.codex and imagegen: the Codex CLI installed and signed in (codex login). Images count against your Codex plan.hooks/register.tsx 174 lines1import { 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}
174types/index.d.ts 14 lines1export 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