SLOPSHOPPER

forge-studio

A studio pane for Forge's art: renders of the body map and any image beside the chat, Approve / Redo / Compare, /tune for the body-map knobs, and tools to…

newpaneguardcommandtoasttool
v0.1.0UNLICENSEDupdated 2026-10-25Morrow707/Forge/.claude/skills/forge-studio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · forge-studio
│ ┃ Forge Studio ✕ › fix the failing auth test and add an audit log call │ ┃ Forge Studio │ ┃ Nothing rendered yet. Ask Claude to render ⏺ Read(src/auth.ts) │ ┃ the body map, or type /tune list. ⎿ 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 │ │ › /studio │ ⎿ forge-studio: Forge Studio open. Nothing rendered yet: call the │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Forge Studio
Forge Studio Nothing rendered yet. Ask Claude to render the body map, or type /tune list.
README

Forge Studio

A mod (a Claude Code plugin of function hooks) that gives a session a studio pane for Forge's art. It lives in the repo so every session working on Forge loads the same one.

  • The pane. /studio opens it. It shows the current render with a strip of earlier versions, Approve, Redo (with a note that reaches Claude as the next prompt) and Compare (the previous version beside the current one).
  • /tune turns the body-map knobs: /tune striations 0.12 denoise 6, /tune list, /tune reset. It reruns the finish stage of tools/body-map/pipeline.py, shows the result in the pane and asks Claude to republish the review artifact.
  • Tools Claude can call: render (the body-map pipeline, any stage, with knobs), show (any image file into the pane) and cloudinary_enhance (run an image through Cloudinary's improve / upscale / enhance / background removal, which needs CLOUDINARY_URL as a secret in the environment and the Cloudinary hosts allowed on its network policy).

Renders land in .studio/ at the repo root, which is ignored by git.

Loading it

A project plugin in .claude/skills/ loads with the session. To install it explicitly instead, at a terminal session's prompt:

/plugin install forge-studio --marketplace Morrow707/Forge

Or set CLAUDE_CODE_PLUGIN_DIRS to this folder's absolute path in the cloud environment.

Source 2 files
hooks/register.tsx 313 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { StudioVersion } from '../types'
5
6// FORGE STUDIO: the picture beside the chat, so a round of art is a look and a button, not a
7// paragraph describing a flaw and a guess at which knob it names. The body map took nine rounds
8// that way (2026-10-18/19). The pipeline it drives is tools/body-map (every number there is a
9// knob); the pane shows what came out; Approve / Redo / Compare come straight back as prompts.
10
11const PLUGIN = 'forge-studio'
12
13/** The repository the pipeline lives in. Taken from the session's own working directory at
14 *  session.start -- NOT derived from `$.plugin.root`, which is wherever the plugin was loaded
15 *  from (a symlink in a session's mods folder on the first run, where stripping
16 *  `.claude/skills/forge-studio` off it finds nothing and every tool looked for the pipeline
17 *  in the wrong place). The derivation stays as the fallback for a hook that runs before
18 *  session.start has. */
19let repoRoot: string | null = null
20const repoRootFor = ($: { plugin: { root: string } }) =>
21  repoRoot ?? $.plugin.root.replace(/\/\.claude\/skills\/forge-studio$/, '')
22
23/** A tool answer the model reads as text. `result` is what the engine stores; `text` what it shows. */
24const ok = (text: string) => ({ result: text, text })
25const fail = (text: string) => ({ isError: true as const, result: text, text })
26const PANE = 'forge-studio'
27const ARTIFACT = 'https://claude.ai/artifact/547k9BLoUmuYF12LvyA3dM'
28const PY = 'python3.13'
29
30const versions = atom({ plugin: 'forge-studio', key: 'versions' }, [] as StudioVersion[], { shape: 'versions-1' })
31const currentId = atom({ plugin: 'forge-studio', key: 'currentId' }, null as string | null)
32const compareId = atom({ plugin: 'forge-studio', key: 'compareId' }, null as string | null)
33const busy = atom({ plugin: 'forge-studio', key: 'busy' }, null as string | null)
34
35
36const KNOB_HELP = [
37  'denoise 8 (grain removal; 0 off)', 'blur 1.8', 'sharpen 0.38', 'sharpen_sigma 3.0', 'gamma 0.92',
38  'color 1.05', 'striations 0.17 (fibre-line darkness; 0 off)', 'stripe_period 13', 'stripe_min_area 1400',
39  'edge_shade 0.07', 'feather 1.6', 'face_scale 0.96', 'face_fade 14', 'preview_height 520',
40  '-- the smoothing pass (2026-10-24) --',
41  'outline_smooth 2.5 (silhouette blur before threshold)', 'label_smooth 4 (hover-shape blur; 0 = staircase)',
42  'label_min_island 2500 (a group speck under this many px joins its neighbour)', 'label_close 3 (close + fill each group first)',
43  'hand_zone 0.08 (hand = this fraction of figure height above each arm\'s end: skin, no group)', 'foot_zone 0.075 (same, from the bottom)',
44  'flatten 0.6 (pull each blob toward its median colour)', 'flatten_blur 3 (soften the seams)',
45  'skin_smooth 0.85 (ungrouped body pixels toward smooth skin)', 'skin_sigma 7', 'match_color 1 (match each tile to the male front)',
46].join('\n  ')
47
48function parseResult(stdout: string, tag: string): Record<string, unknown> | null {
49  const line = stdout.split('\n').reverse().find(l => l.startsWith(tag + ' '))
50  if (!line) return null
51  try { return JSON.parse(line.slice(tag.length + 1)) } catch { return null }
52}
53
54async function readPreviewInfo($: any, dir: string) {
55  try {
56    const text = await $.fs.read(`${dir}/preview.json`)
57    return JSON.parse(text) as { preview: { w: number; h: number }; previewSmall: { w: number; h: number } }
58  } catch { return null }
59}
60
61async function addVersion($: any, v: StudioVersion) {
62  await update($, versions, list => [...list, v].slice(-30))
63  await update($, currentId, () => v.id)
64  await update($, compareId, () => null)
65  await $.ui.open({ id: PANE, title: 'Forge Studio' })
66}
67
68function newId() { return Date.now().toString(36) }
69
70/** Run the body-map pipeline and register what it produced as a version. */
71async function renderBodyMap($: any, cwd: string, args: { stage?: string; knobs?: Record<string, number>; label?: string; reset?: boolean }) {
72  const work = `${cwd}/.studio/bodymap`
73  const argv = [PY, `${cwd}/tools/body-map/pipeline.py`, '--work', work, '--stage', args.stage ?? 'hd']
74  if (args.reset) argv.push('--reset')
75  for (const [k, v] of Object.entries(args.knobs ?? {})) argv.push('--set', `${k}=${v}`)
76  // A first run needs every stage; `hd` alone wants the tiles from an earlier run.
77  if ((args.stage ?? 'hd') === 'hd' && !(await $.fs.exists(`${work}/manifest.json`))) argv[argv.indexOf('--stage') + 1] = 'all'
78  await update($, busy, () => `rendering body map (${argv[argv.indexOf('--stage') + 1]})`)
79  try {
80    const ran = await $.process.run(argv, { cwd, timeoutMs: 600_000 })
81    if (ran.exitCode !== 0) {
82      return { ok: false as const, text: `pipeline failed (${ran.exitCode}):\n${ran.stderr.slice(-1500) || ran.stdout.slice(-1500)}` }
83    }
84    const res = parseResult(ran.stdout, 'RESULT')
85    const info = await readPreviewInfo($, work)
86    if (!res || !info) return { ok: false as const, text: `pipeline ran but wrote no result line:\n${ran.stdout.slice(-800)}` }
87    const knobs = res.knobs as Record<string, number>
88    const v: StudioVersion = {
89      id: newId(), label: args.label ?? `body map ${new Date().toISOString().slice(11, 16)}`, at: Date.now(), kind: 'bodymap',
90      preview: `${work}/preview.webp`, previewSmall: `${work}/preview-sm.webp`, previewPng: `${work}/preview.png`,
91      w: info.preview.w, h: info.preview.h, smallW: info.previewSmall.w, smallH: info.previewSmall.h,
92      knobs, site: String(res.site), decision: null,
93    }
94    // Each version keeps its own preview files, or the strip would show the latest for all.
95    const keep = `${cwd}/.studio/versions/${v.id}`
96    await $.process.run(['mkdir', '-p', keep])
97    await $.process.run(['cp', `${work}/preview.webp`, `${work}/preview-sm.webp`, `${work}/preview.png`, `${work}/preview.json`, keep])
98    v.preview = `${keep}/preview.webp`; v.previewSmall = `${keep}/preview-sm.webp`; v.previewPng = `${keep}/preview.png`
99    await addVersion($, v)
100    return { ok: true as const, version: v, seconds: Number(res.seconds), text: `Rendered ${v.label} in ${res.seconds}s. Knobs: ${JSON.stringify(knobs)}. Site: ${res.site}` }
101  } finally {
102    await update($, busy, () => null)
103  }
104}
105
106/** Show any one image in the pane. */
107async function showImage($: any, cwd: string, path: string, label?: string, kind: StudioVersion['kind'] = 'image') {
108  const id = newId()
109  const out = `${cwd}/.studio/versions/${id}`
110  await update($, busy, () => `preparing ${label ?? path}`)
111  try {
112    const ran = await $.process.run([PY, `${cwd}/tools/body-map/preview.py`, '--image', path, '--out', out], { cwd, timeoutMs: 120_000 })
113    if (ran.exitCode !== 0) return { ok: false as const, text: `could not preview ${path}: ${ran.stderr.slice(-600)}` }
114    const info = await readPreviewInfo($, out)
115    if (!info) return { ok: false as const, text: `no preview written for ${path}` }
116    const v: StudioVersion = {
117      id, label: label ?? path.split('/').pop() ?? path, at: Date.now(), kind,
118      preview: `${out}/preview.webp`, previewSmall: `${out}/preview-sm.webp`, previewPng: `${out}/preview.png`,
119      w: info.preview.w, h: info.preview.h, smallW: info.previewSmall.w, smallH: info.previewSmall.h, decision: null,
120    }
121    await addVersion($, v)
122    return { ok: true as const, version: v, text: `Showing ${v.label} in the studio pane.` }
123  } finally {
124    await update($, busy, () => null)
125  }
126}
127
128export const register: Register = on => {
129  on('session.start', async ($, e, next) => {
130    repoRoot = e.cwd
131    await $.command.register({ name: 'studio', description: 'Open the Forge Studio pane (renders, Approve / Redo / Compare).' })
132    await $.command.register({ name: 'tune', description: 'Turn a body-map knob and re-render: /tune striations 0.12 denoise 6 | list | reset', argumentHint: 'knob value ... | list | reset' })
133    await $.tool.register({
134      name: 'render', isDeferred: false,
135      description: 'Run the Forge body-map pipeline (tools/body-map) and show the result in the studio pane. stage "hd" reruns the finish with the knobs given (fast); "all" rebuilds from the source renders. Knobs: denoise, blur, sharpen, sharpen_sigma, gamma, color, striations, stripe_period, stripe_min_area, edge_shade, feather, outline_smooth, label_smooth, label_min_island, label_close, hand_zone, foot_zone, flatten, flatten_blur, skin_smooth, skin_sigma, match_color, face_scale, face_fade, preview_height. Returns the knobs in force and the site folder to publish the artifact from.',
136      inputSchema: { type: 'object', properties: {
137        stage: { type: 'string', enum: ['hd', 'all'] },
138        knobs: { type: 'object', additionalProperties: { type: 'number' } },
139        label: { type: 'string' }, reset: { type: 'boolean' },
140      } },
141    })
142    await $.tool.register({
143      name: 'show', isDeferred: false,
144      description: 'Show an image file (png, jpg, webp) in the Forge Studio pane beside the chat as a new version, for Scott to approve, redo or compare. Use after rendering any artwork.',
145      inputSchema: { type: 'object', properties: { path: { type: 'string' }, label: { type: 'string' } }, required: ['path'] },
146    })
147    await $.tool.register({
148      name: 'cloudinary_enhance',
149      description: 'Run an image through Cloudinary (improve, upscale, enhance, background_removal, sharpen, auto_contrast) and show the result in the studio pane. Needs CLOUDINARY_URL in the environment; says so when it is missing.',
150      inputSchema: { type: 'object', properties: {
151        path: { type: 'string' }, out: { type: 'string', description: 'where to write the result (png); default beside the input' },
152        effects: { type: 'array', items: { type: 'string' } }, label: { type: 'string' },
153      }, required: ['path'] },
154    })
155    return next(e)
156  })
157
158  on('command.run', { command: 'studio' }, async $ => {
159    await $.ui.open({ id: PANE, title: 'Forge Studio', focus: true })
160    const list = await read($, versions)
161    return { text: list.length ? `Forge Studio open: ${list.length} version(s).` : 'Forge Studio open. Nothing rendered yet: call the render tool, or /tune.' }
162  })
163
164  on('command.run', { command: 'tune' }, async ($, e) => {
165    const words = e.args.trim().split(/\s+/).filter(Boolean)
166    const cwd = repoRootFor($)
167    if (!words.length || words[0] === 'list') {
168      const cur = (await read($, versions)).slice().reverse().find(v => v.kind === 'bodymap')
169      return { text: `Body-map knobs (default in brackets):\n  ${KNOB_HELP}\n\nIn force: ${cur?.knobs ? JSON.stringify(cur.knobs) : 'defaults (nothing rendered yet)'}\n\n/tune striations 0.12 denoise 6` }
170    }
171    if (words[0] === 'reset') {
172      const r = await renderBodyMap($, cwd, { stage: 'hd', reset: true, label: 'defaults' })
173      if (r.ok) void $.prompt.submit({ text: `Forge Studio: /tune reset re-rendered the body map at the default knobs (version "${r.version.label}", site ${r.version.site}). Republish the body map artifact ${ARTIFACT} from that site folder (index.html plus img/, lab/, manifest.json, exercises.json) and tell Scott in one line.` })
174      return { text: r.text }
175    }
176    if (words.length % 2 !== 0) return { text: 'Pairs, please: /tune knob value [knob value ...]. /tune list shows the knobs.' }
177    const knobs: Record<string, number> = {}
178    for (let i = 0; i < words.length; i += 2) {
179      const knob = words[i]
180      const raw = words[i + 1]
181      if (!knob || raw === undefined) break
182      const n = Number(raw)
183      if (!Number.isFinite(n)) return { text: `"${raw}" is not a number for ${knob}.` }
184      knobs[knob] = n
185    }
186    const label = Object.entries(knobs).map(([k, v]) => `${k} ${v}`).join(', ')
187    const r = await renderBodyMap($, cwd, { stage: 'hd', knobs, label })
188    if (r.ok) {
189      void $.prompt.submit({ text: `Forge Studio: /tune re-rendered the body map with ${label} (version "${r.version.label}", site ${r.version.site}). Republish the body map artifact ${ARTIFACT} from that site folder (index.html plus img/, lab/, manifest.json, exercises.json) and tell Scott in one line what moved.` })
190    }
191    return { text: r.text }
192  })
193
194  on('tool.call', { tool: 'mcp__forge-studio__render' }, async ($, e) => {
195    const cwd = repoRootFor($)
196    const input = e as unknown as { stage?: string; knobs?: Record<string, number>; label?: string; reset?: boolean }
197    const r = await renderBodyMap($, cwd, input)
198    return r.ok ? ok(r.text) : fail(r.text)
199  })
200
201  on('tool.call', { tool: 'mcp__forge-studio__show' }, async ($, e) => {
202    const cwd = repoRootFor($)
203    const input = e as unknown as { path: string; label?: string }
204    const r = await showImage($, cwd, input.path, input.label)
205    return r.ok ? ok(r.text) : fail(r.text)
206  })
207
208  on('tool.call', { tool: 'mcp__forge-studio__cloudinary_enhance' }, async ($, e) => {
209    const cwd = repoRootFor($)
210    const input = e as unknown as { path: string; out?: string; effects?: string[]; label?: string }
211    const out = input.out ?? input.path.replace(/\.[a-z0-9]+$/i, '') + `.cloudinary.png`
212    await update($, busy, () => 'asking Cloudinary')
213    let ran
214    try {
215      ran = await $.process.run([PY, `${cwd}/tools/body-map/cloudinary_enhance.py`, '--in', input.path, '--out', out, '--effects', (input.effects ?? ['improve']).join(',')], { cwd, timeoutMs: 300_000 })
216    } finally {
217      await update($, busy, () => null)
218    }
219    if (ran.exitCode !== 0) return fail(ran.stderr.trim() || `cloudinary_enhance failed (${ran.exitCode})`)
220    const r = await showImage($, cwd, out, input.label ?? `cloudinary ${(input.effects ?? ['improve']).join('+')}`)
221    const text = `${ran.stdout.trim()}\n${r.text}`
222    return r.ok ? ok(text) : fail(text)
223  })
224
225  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
226    const { Box, Text, Button } = $.ui.resolve(e)
227    const list = await read($, versions)
228    const cur = await read($, currentId)
229    const cmp = await read($, compareId)
230    const running = await read($, busy)
231    const current = list.find(v => v.id === cur) ?? list[list.length - 1]
232    const other = cmp ? list.find(v => v.id === cmp) : undefined
233    const width = Math.max(20, (e.props.bodyColumns ?? e.viewport?.columns ?? 60) - 2)
234
235    const picture = async (v: StudioVersion, small: boolean) => {
236      const path = small ? v.previewSmall : v.preview
237      const w = small ? v.smallW : v.w
238      const h = small ? v.smallH : v.h
239      if (e.surface === 'terminal') {
240        const { Image } = $.ui.resolve(e)
241        const { base64 } = await $.fs.read(v.previewPng, { as: 'bytes' })
242        const cols = small ? Math.floor(width / 2) - 1 : width
243        return <Image key={`img-${v.id}-${small ? 's' : 'l'}`} source={{ png: base64 }} columns={cols} rows={Math.max(6, Math.round((cols * h) / (w * 2)))} alt={v.label} />
244      }
245      const { Svg } = $.ui.resolve(e)
246      const { base64 } = await $.fs.read(path, { as: 'bytes' })
247      const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><image href="data:image/webp;base64,${base64}" width="${w}" height="${h}"/></svg>`
248      return <Svg source={svg} alt={v.label} />
249    }
250
251    const act = (label: string, hotkey: string, fn: () => void, variant?: 'primary') => (
252      <Button key={label.toLowerCase()} label={label} hotkey={hotkey} variant={variant} onPress={fn} />
253    )
254
255    const approve = () => {
256      if (!current) return
257      void update($, versions, l => l.map(v => (v.id === current.id ? { ...v, decision: 'approved' as const } : v)))
258      $.ui.toast(`Approved ${current.label}`)
259      void $.prompt.submit({ text: `Forge Studio: Scott approved "${current.label}"${current.kind === 'bodymap' ? ` (knobs ${JSON.stringify(current.knobs)}, site ${current.site})` : ''}. Treat it as the version to keep.` })
260    }
261    const redo = () => {
262      if (!current) return
263      void update($, versions, l => l.map(v => (v.id === current.id ? { ...v, decision: 'redo' as const } : v)))
264      void $.prompt.fill({ text: `Forge Studio: another pass on "${current.label}": `, mode: 'replace' })
265    }
266    const compare = () => {
267      if (!current) return
268      const i = list.findIndex(v => v.id === current.id)
269      const prev = i > 0 ? list[i - 1] : undefined
270      void update($, compareId, c => (c ? null : (prev?.id ?? null)))
271    }
272
273    if (!current) {
274      return (
275        <Box flexDirection="column">
276          <Text bold>Forge Studio</Text>
277          <Text dimColor>Nothing rendered yet. Ask Claude to render the body map, or type /tune list.</Text>
278          {running && <Text>{running}…</Text>}
279        </Box>
280      )
281    }
282
283    const pictures = other
284      ? [<Box flexDirection="row">{await picture(other, true)}{await picture(current, true)}</Box>]
285      : [await picture(current, false)]
286
287    return (
288      <Box flexDirection="column">
289        <Box flexDirection="row">
290          <Text bold>{current.label}</Text>
291          <Text dimColor> {new Date(current.at).toLocaleTimeString()}{current.decision ? ` · ${current.decision}` : ''}</Text>
292        </Box>
293        {other && <Text dimColor>left: {other.label} · right: {current.label}</Text>}
294        {pictures}
295        {running && <Text dimColor>{running}…</Text>}
296        <Box flexDirection="row">
297          {act('Approve', 'a', approve, 'primary')}
298          {act('Redo', 'r', redo)}
299          {act(other ? 'Uncompare' : 'Compare', 'c', compare)}
300        </Box>
301        {current.kind === 'bodymap' && current.knobs && (
302          <Text dimColor>knobs: {Object.entries(current.knobs).filter(([k]) => k !== 'preview_height').map(([k, v]) => `${k} ${v}`).join(' · ')}</Text>
303        )}
304        <Box flexDirection="row" flexWrap="wrap">
305          {list.slice(-8).map(v => (
306            <Button key={`v-${v.id}`} plain label={`${v.id === current.id ? '▸ ' : ''}${v.label}${v.decision === 'approved' ? ' ✓' : ''}`} onPress={() => { void update($, currentId, () => v.id); void update($, compareId, () => null) }} />
307          ))}
308        </Box>
309      </Box>
310    )
311  })
312}
313
types/index.d.ts 35 lines
1/** One thing the studio has shown: a body-map render or any one image. */
2export type StudioVersion = {
3  id: string
4  /** What the strip calls it. */
5  label: string
6  /** Epoch ms. */
7  at: number
8  kind: 'bodymap' | 'image'
9  /** Absolute paths of the previews preview.py wrote. */
10  preview: string
11  previewSmall: string
12  previewPng: string
13  /** Pixel sizes of the previews, for the Svg box. */
14  w: number
15  h: number
16  smallW: number
17  smallH: number
18  /** The knobs this render was made with (body map only). */
19  knobs?: Record<string, number>
20  /** The rendered site folder to publish from (body map only). */
21  site?: string
22  decision?: 'approved' | 'redo' | null
23}
24
25declare module 'claude-code' {
26  interface PluginState {
27    'forge-studio': {
28      versions: Shaped<StudioVersion[]>
29      currentId: string | null
30      compareId: string | null
31      busy: string | null
32    }
33  }
34}
35