SLOPSHOPPER

meshy-tray

Start Meshy 3D generations from Claude Code and watch them finish above the prompt

newpanebandguardcommandtoast
v0.1.0no licenseupdated 2026-10-02serafin-meshy/claude-mods/meshy-tray
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · meshy-tray
│ ┃ Meshy tasks ✕ › fix the failing auth test and add an audit log call │ ┃ No Meshy tasks in this session yet. │ ┃ Ask Claude to make a 3D model; it calls ⏺ Read(src/auth.ts) │ ┃ meshy_generate. ⎿ 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 │ │ › /meshy-tasks │ ⎿ meshy-tray: Opened the Meshy tasks pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Meshy tasks
No Meshy tasks in this session yet. Ask Claude to make a 3D model; it calls meshy_generate.
README

meshy-tray

Start Meshy generations from a Claude Code session and watch them finish without leaving it.

What it adds

  • meshy_generate, a tool Claude calls. Ask for a model in plain words ("make a 3D model of a brass lantern", "turn refs/chair.png into a model") and Claude starts an image-to-3D, text-to-3D or multi-image-to-3D task. The tool returns the task id at once; nothing blocks while Meshy works. Text-to-3D runs Meshy's two stages, the untextured preview and then texturing, unless asked for texture: false.
  • A band above the prompt with one line per running task: type, short id, status, a progress bar, percent and elapsed time. It hides when nothing is running.
  • A toast when a task finishes: the GLB is saved to ./meshy-models/<task-id>.glb under the project. A failed task toasts Meshy's error message.
  • /meshy-tasks opens a pane listing this session's tasks with status, prompt or image, and file path.

Tasks survive a reload of the mod; polling picks them back up.

While a task runs, the band above the prompt shows its type, short id, stage, a progress bar, percent and elapsed time, updating every second. Here it is in the Claude desktop app, 13 seconds and then 35 seconds into a text-to-3D preview:

The band above the prompt: "text 21c6bf85 preview", a bar a fifth full, 20%, 13s

The same band later: the bar most of the way full, 83%, 35s

Once the preview is done, the stage reads refine while Meshy textures the model and the bar starts over. When it finishes, a toast says Meshy model ready: ./meshy-models/01a0fd60-e2e6-70e7-b5ab-89854813758d.glb, and with meshy-viewer installed a View button appears above the prompt.

Setup

The mod needs a Meshy API key. When you install from the marketplace, Claude Code asks for it and stores it in the system's secure storage. Otherwise set MESHY_API_KEY in your environment or put MESHY_API_KEY=... in the project's .env. The key is only ever sent as a Bearer token to api.meshy.ai; if an API error echoes it back, the mod replaces it with *** before anything reaches the transcript.

Local images are sent as data URIs and must be .png, .jpg or .jpeg up to 4 MiB; larger ones can be passed as a URL. Downloads use curl.

Tests

claude plugin validate ./meshy-tray
claude plugin test ./meshy-tray

The tests stub the Meshy API, the shell and the files, so they spend no credits.

Source 3 files
hooks/register.tsx 478 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, Timer } from 'claude-code'
3
4import type { MeshyKind, MeshyTask } from '../types'
5import {
6  BASE_URL,
7  ENDPOINTS,
8  KIND_LABELS,
9  MeshyError,
10  describeImage,
11  formatElapsed,
12  imageMime,
13  isActive,
14  isRemoteImage,
15  keyFromDotenv,
16  parseResponse,
17  progressBar,
18  shortId,
19  statusLabel,
20} from './meshy'
21import type { MeshyTaskObject } from './meshy'
22
23const PANE = 'meshy-tasks'
24const TOOL_NAME = 'meshy_generate'
25const TOOL = 'mcp__meshy-tray__meshy_generate'
26const TICK_MS = 1000
27const POLL_EVERY_TICKS = 5
28const TOAST_MS = 8000
29const MAX_TASKS = 100
30
31const tasks = atom({ plugin: 'meshy-tray', key: 'tasks' } as const, [])
32
33const MODES: Record<string, MeshyKind> = {
34  'image-to-3d': 'image',
35  'text-to-3d': 'text',
36  'multi-image-to-3d': 'multi',
37}
38
39const INPUT_SCHEMA = {
40  type: 'object',
41  properties: {
42    mode: {
43      type: 'string',
44      enum: Object.keys(MODES),
45      description: 'Which Meshy generation to start.',
46    },
47    prompt: {
48      type: 'string',
49      description: 'text-to-3d: what to make (max 800 characters).',
50    },
51    image: {
52      type: 'string',
53      description:
54        'image-to-3d: a local .png/.jpg/.jpeg path (relative to the working directory or absolute, up to 4 MiB), an https URL, or a data URI.',
55    },
56    images: {
57      type: 'array',
58      items: { type: 'string' },
59      minItems: 1,
60      maxItems: 4,
61      description:
62        'multi-image-to-3d: 1 to 4 views of the same object, each a local path, URL or data URI; the first is the front view.',
63    },
64    ai_model: {
65      type: 'string',
66      description: 'meshy-6-lite, meshy-6, meshy-7.1 or latest (the default).',
67    },
68    texture: {
69      type: 'boolean',
70      description:
71        'Default true. image and multi-image: should_texture. text-to-3d: run the refine (texturing) stage after the untextured preview.',
72    },
73    texture_prompt: { type: 'string', description: 'Guides texturing (max 800 characters).' },
74    enable_pbr: { type: 'boolean', description: 'Also generate metallic, roughness and normal maps.' },
75    should_remesh: { type: 'boolean' },
76    topology: { type: 'string', enum: ['triangle', 'quad'] },
77    target_polycount: { type: 'integer', minimum: 100, maximum: 300000 },
78    pose_mode: { type: 'string', enum: ['', 'a-pose', 't-pose'] },
79    options: {
80      type: 'object',
81      description:
82        'Any other Meshy create-request fields (e.g. geometry_resolution, texture_resolution, auto_size), sent as given.',
83    },
84  },
85  required: ['mode'],
86}
87
88const DESCRIPTION = [
89  'Start a Meshy 3D generation: image-to-3d (one image), text-to-3d (a prompt) or multi-image-to-3d (1-4 views).',
90  'Returns the task id at once; the task runs in the background, shown in the band above the prompt.',
91  'When it succeeds the GLB is saved to ./meshy-models/<task-id>.glb and the user gets a toast; do not poll or wait for it.',
92  'The user can list tasks with /meshy-tasks. Each generation spends Meshy credits.',
93].join(' ')
94
95type Args = Record<string, unknown>
96
97type Plan = {
98  kind: MeshyKind
99  body: Record<string, unknown>
100  source: string
101  images: string[]
102  imageField?: 'image_url' | 'image_urls'
103  shouldRefine?: boolean
104  refineOptions?: Record<string, string | number | boolean>
105}
106
107const str = (value: unknown) => (typeof value === 'string' && value.trim() ? value.trim() : undefined)
108
109/** The optional create fields the tool's schema names, when given. */
110const commonFields = (args: Args) => {
111  const fields: Record<string, unknown> = {}
112  for (const name of ['ai_model', 'should_remesh', 'topology', 'target_polycount', 'pose_mode']) {
113    if (args[name] !== undefined) fields[name] = args[name]
114  }
115  const options = args.options
116  if (options && typeof options === 'object' && !Array.isArray(options)) {
117    Object.assign(fields, options)
118  }
119
120  return fields
121}
122
123const textureFields = (args: Args) => {
124  const fields: Record<string, string | number | boolean> = {}
125  if (typeof args.enable_pbr === 'boolean') fields.enable_pbr = args.enable_pbr
126  const prompt = str(args.texture_prompt)
127  if (prompt) fields.texture_prompt = prompt
128
129  return fields
130}
131
132/** The request a tool call asks for, its images still as given. */
133const plan = (args: Args): Plan => {
134  const kind = MODES[String(args.mode)]
135  if (!kind) throw new Error(`mode must be one of ${Object.keys(MODES).join(', ')}`)
136
137  if (kind === 'text') {
138    const prompt = str(args.prompt)
139    if (!prompt) throw new Error('text-to-3d needs a prompt')
140
141    return {
142      kind,
143      source: prompt,
144      images: [],
145      body: { ...commonFields(args), mode: 'preview', prompt },
146      shouldRefine: args.texture !== false,
147      refineOptions: textureFields(args),
148    }
149  }
150
151  const body = {
152    ...commonFields(args),
153    ...textureFields(args),
154    ...(typeof args.texture === 'boolean' ? { should_texture: args.texture } : {}),
155  }
156
157  if (kind === 'image') {
158    const image = str(args.image)
159    if (!image) throw new Error('image-to-3d needs an image (a local path or a URL)')
160
161    return { kind, source: describeImage(image), images: [image], imageField: 'image_url', body }
162  }
163
164  const images = Array.isArray(args.images) ? (args.images.map(str).filter(Boolean) as string[]) : []
165  if (images.length < 1 || images.length > 4) {
166    throw new Error('multi-image-to-3d needs 1 to 4 images')
167  }
168
169  return { kind, source: images.map(describeImage).join(', '), images, imageField: 'image_urls', body }
170}
171
172const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error))
173
174export const register: Register = (on, options) => {
175  // Built in session.start over the session's own `$`, which outlives any one
176  // dispatch; the tool call hands its work to it.
177  let generate: ((args: Args) => Promise<string>) | undefined
178  let timer: Timer | undefined
179  let ticks = 0
180  let isPolling = false
181  const downloading = new Set<string>()
182
183  on('session.start', async ($, e, next) => {
184    const loadKey = async () => {
185      // The plugin's own option (kept in secure storage), else the environment, else the project's .env.
186      const fromOptions = typeof options.meshy_api_key === 'string' ? options.meshy_api_key.trim() : ''
187      if (fromOptions) return fromOptions
188      const fromEnv = (await $.env.get('MESHY_API_KEY'))?.trim()
189      if (fromEnv) return fromEnv
190      for (const dir of new Set([await $.session.cwd(), await $.session.root()])) {
191        const path = `${dir}/.env`
192        if (!(await $.fs.exists(path))) continue
193        const key = keyFromDotenv(await $.fs.read(path))
194        if (key) return key
195      }
196
197      return undefined
198    }
199
200    const request = async (key: string, method: 'GET' | 'POST', path: string, body?: unknown) => {
201      const response = await $.http.fetch(`${BASE_URL}${path}`, {
202        method,
203        headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
204        body: body === undefined ? undefined : JSON.stringify(body),
205      })
206
207      return parseResponse(response, key)
208    }
209
210    const toImageUrl = async (image: string) => {
211      if (isRemoteImage(image)) return image
212      const mime = imageMime(image)
213      const path = image.startsWith('/') ? image : `${await $.session.cwd()}/${image}`
214      if (!(await $.fs.exists(path))) throw new Error(`${image}: no such file`)
215      try {
216        const { base64 } = await $.fs.read(path, { as: 'bytes' })
217
218        return `data:${mime};base64,${base64}`
219      } catch {
220        throw new Error(`${image}: could not be read (files over 4 MiB cannot be sent; pass a URL instead)`)
221      }
222    }
223
224    const patch = (id: string, change: Partial<MeshyTask>) =>
225      update($, tasks, list => list.map(task => (task.id === id ? { ...task, ...change } : task)))
226
227    const fail = async (task: MeshyTask, message: string) => {
228      await patch(task.id, { status: 'FAILED', error: message, finishedAt: await $.clock.now() })
229      $.ui.toast(`Meshy ${KIND_LABELS[task.kind]} ${shortId(task.id)} failed: ${message}`, {
230        timeoutMs: TOAST_MS,
231      })
232    }
233
234    const download = async (task: MeshyTask, key: string) => {
235      if (downloading.has(task.id)) return
236      downloading.add(task.id)
237      try {
238        // Asked again here so a download restarted after a reload gets a fresh signed URL.
239        const object = (await request(key, 'GET', `${ENDPOINTS[task.kind]}/${task.apiId}`)) as MeshyTaskObject
240        const url = object.model_urls?.glb
241        if (!url) {
242          await fail(task, 'the task succeeded but returned no GLB')
243          return
244        }
245        const relative = `meshy-models/${task.id}.glb`
246        // The asset URL is pre-signed: the key never reaches curl's argv.
247        const ran = await $.process.run(
248          ['curl', '-fsSL', '--retry', '2', '--create-dirs', '-o', relative, url],
249          { cwd: task.cwd, timeoutMs: 10 * 60 * 1000 },
250        )
251        if (ran.exitCode !== 0) {
252          await fail(task, `download failed: ${ran.stderr.trim() || `curl exited ${ran.exitCode}`}`)
253          return
254        }
255        const file = `./${relative}`
256        await patch(task.id, { status: 'SUCCEEDED', progress: 100, file, finishedAt: await $.clock.now() })
257        $.ui.toast(`Meshy model ready: ${file}`, { timeoutMs: TOAST_MS })
258      } catch (error) {
259        await fail(task, `download failed: ${errorText(error)}`)
260      } finally {
261        downloading.delete(task.id)
262      }
263    }
264
265    const poll = async (task: MeshyTask, key: string) => {
266      let object: MeshyTaskObject
267      try {
268        object = (await request(key, 'GET', `${ENDPOINTS[task.kind]}/${task.apiId}`)) as MeshyTaskObject
269      } catch (error) {
270        // A rate limit, a 5xx or a network blip is retried next round.
271        const isFinal =
272          error instanceof MeshyError && error.status >= 400 && error.status < 500 && error.status !== 429
273        if (isFinal) await fail(task, error.message)
274        return
275      }
276
277      if (object.status === 'PENDING' || object.status === 'IN_PROGRESS') {
278        await patch(task.id, { status: object.status, progress: object.progress ?? 0 })
279        return
280      }
281      if (object.status === 'FAILED' || object.status === 'CANCELED') {
282        const fallback = object.status === 'CANCELED' ? 'canceled' : 'no reason given'
283        await fail(task, object.task_error?.message || fallback)
284        return
285      }
286      if (object.status !== 'SUCCEEDED') return
287
288      if (task.kind === 'text' && task.stage === 'preview' && task.shouldRefine) {
289        try {
290          const created = (await request(key, 'POST', ENDPOINTS.text, {
291            ...task.refineOptions,
292            mode: 'refine',
293            preview_task_id: task.apiId,
294          })) as { result?: string }
295          if (!created?.result) throw new Error('Meshy returned no task id')
296          await patch(task.id, { apiId: created.result, stage: 'refine', status: 'PENDING', progress: 0 })
297        } catch (error) {
298          await fail(task, `refine: ${errorText(error)}`)
299        }
300        return
301      }
302
303      await patch(task.id, { status: 'DOWNLOADING', progress: 100 })
304      await download(task, key)
305    }
306
307    const tick = async () => {
308      const active = (await read($, tasks)).filter(isActive)
309      if (active.length === 0) {
310        timer?.cancel()
311        timer = undefined
312        return
313      }
314      // The elapsed times move every second; the state only every poll.
315      $.ui.invalidate('ui.render')
316      ticks += 1
317      if (isPolling || ticks % POLL_EVERY_TICKS !== 0) return
318      const key = await loadKey()
319      if (!key) return
320      isPolling = true
321      try {
322        await Promise.all(
323          active.map(task => (task.status === 'DOWNLOADING' ? download(task, key) : poll(task, key))),
324        )
325      } finally {
326        isPolling = false
327      }
328    }
329
330    const ensureTicking = () => {
331      if (timer) return
332      ticks = 0
333      timer = $.clock.every(TICK_MS, () => void tick())
334    }
335
336    generate = async args => {
337      const key = await loadKey()
338      if (!key) {
339        throw new Error('MESHY_API_KEY is not set: export it, or add MESHY_API_KEY=... to the project .env file')
340      }
341      const planned = plan(args)
342      const imageUrls: string[] = []
343      for (const image of planned.images) imageUrls.push(await toImageUrl(image))
344      const body =
345        planned.imageField === 'image_url'
346          ? { ...planned.body, image_url: imageUrls[0] }
347          : planned.imageField === 'image_urls'
348            ? { ...planned.body, image_urls: imageUrls }
349            : planned.body
350      const answer = (await request(key, 'POST', ENDPOINTS[planned.kind], body)) as { result?: string }
351      const id = answer?.result
352      if (!id) throw new Error('Meshy returned no task id')
353
354      const task: MeshyTask = {
355        id,
356        kind: planned.kind,
357        apiId: id,
358        status: 'PENDING',
359        progress: 0,
360        source: planned.source,
361        cwd: await $.session.cwd(),
362        startedAt: await $.clock.now(),
363        ...(planned.kind === 'text'
364          ? { stage: 'preview' as const, shouldRefine: planned.shouldRefine, refineOptions: planned.refineOptions }
365          : {}),
366      }
367      await update($, tasks, list => [...list, task].slice(-MAX_TASKS))
368      ensureTicking()
369
370      const stages = planned.kind === 'text' && planned.shouldRefine ? ' (preview, then texturing)' : ''
371
372      return `Started ${String(args.mode)} task ${id}${stages}. It runs in the background and shows in the band above the prompt; when it succeeds the model is saved to ./meshy-models/${id}.glb and the user is notified. Do not poll for it.`
373    }
374
375    await $.tool.register({ name: TOOL_NAME, description: DESCRIPTION, inputSchema: INPUT_SCHEMA })
376    await $.command.register({
377      name: 'meshy-tasks',
378      description: "List this session's Meshy generations in a pane",
379    })
380    // Tasks kept in $.state outlive a reload; pick their polling back up.
381    if ((await read($, tasks)).some(isActive)) ensureTicking()
382
383    return next(e)
384  })
385
386  on('tool.call', { tool: TOOL }, async (_$, e) => {
387    if (!generate) return { deny: 'meshy-tray is still starting; try again in a moment.' }
388    try {
389      return { result: await generate(e as Args) }
390    } catch (error) {
391      return { deny: `meshy_generate: ${errorText(error)}` }
392    }
393  })
394
395  on('command.run', { command: 'meshy-tasks' }, async $ => {
396    await $.ui.open({ id: PANE, title: 'Meshy tasks', closeOnEscape: true })
397
398    return { text: 'Opened the Meshy tasks pane.' }
399  })
400
401  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
402    if (e.props.hasSurvey) return next(e)
403    const active = (await read($, tasks)).filter(isActive)
404    if (active.length === 0) return next(e)
405
406    const { Box, Text } = $.ui.resolve(e)
407    const now = await $.clock.now()
408
409    return (
410      <Box flexDirection="column">
411        {active.map(task => {
412          const { filled, track } = progressBar(task.progress)
413
414          return (
415            <Box key={task.id} flexDirection="row">
416              <Text>
417                {KIND_LABELS[task.kind].padEnd(6)}
418                {shortId(task.id)} {statusLabel(task).padEnd(8)}{' '}
419              </Text>
420              <Text color="cyan">{filled}</Text>
421              <Text dimColor>{track}</Text>
422              <Text>{` ${`${task.progress}%`.padStart(4)} `}</Text>
423              <Text dimColor>{formatElapsed(now - task.startedAt)}</Text>
424            </Box>
425          )
426        })}
427      </Box>
428    )
429  })
430
431  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
432    const { Box, Text } = $.ui.resolve(e)
433    const list = [...(await read($, tasks))].reverse()
434    const now = await $.clock.now()
435
436    if (list.length === 0) {
437      return (
438        <Box flexDirection="column">
439          <Text dimColor>No Meshy tasks in this session yet.</Text>
440          <Text dimColor>Ask Claude to make a 3D model; it calls meshy_generate.</Text>
441        </Box>
442      )
443    }
444
445    const running = list.filter(isActive).length
446
447    return (
448      <Box flexDirection="column">
449        <Text dimColor>
450          {list.length} task{list.length === 1 ? '' : 's'}, {running} running
451        </Text>
452        {list.map(task => {
453          const color =
454            task.status === 'SUCCEEDED' ? 'green' : task.status === 'FAILED' || task.status === 'CANCELED' ? 'red' : 'cyan'
455          const elapsed = formatElapsed((task.finishedAt ?? now) - task.startedAt)
456
457          return (
458            <Box key={task.id} flexDirection="column" marginTop={1}>
459              <Box flexDirection="row">
460                <Text bold>{`${KIND_LABELS[task.kind]} ${shortId(task.id)} `}</Text>
461                <Text color={color}>{statusLabel(task)}</Text>
462                <Text>{isActive(task) ? ` ${task.progress}%` : ''} </Text>
463                <Text dimColor>{elapsed}</Text>
464              </Box>
465              <Text dimColor wrap="truncate-end">
466                {`${task.kind === 'text' ? 'prompt' : 'image'}: ${task.source}`}
467              </Text>
468              {task.file ? <Text wrap="truncate-start">{`file: ${task.file}`}</Text> : null}
469              {task.error ? <Text color="red">{`error: ${task.error}`}</Text> : null}
470              <Text dimColor wrap="truncate">{`id: ${task.id}`}</Text>
471            </Box>
472          )
473        })}
474      </Box>
475    )
476  })
477}
478
hooks/meshy.ts 137 lines
1import type { HttpResponse } from 'claude-code'
2
3import type { MeshyKind, MeshyStatus, MeshyTask } from '../types'
4
5export const BASE_URL = 'https://api.meshy.ai'
6
7// Create and retrieve share a path per kind: POST <path>, GET <path>/<id>.
8export const ENDPOINTS: Record<MeshyKind, string> = {
9  image: '/openapi/v1/image-to-3d',
10  multi: '/openapi/v1/multi-image-to-3d',
11  text: '/openapi/v2/text-to-3d',
12}
13
14export const KIND_LABELS: Record<MeshyKind, string> = {
15  image: 'image',
16  multi: 'multi',
17  text: 'text',
18}
19
20export type MeshyTaskObject = {
21  id: string
22  status: MeshyStatus
23  progress?: number
24  model_urls?: { glb?: string }
25  task_error?: { message?: string }
26}
27
28export class MeshyError extends Error {
29  status: number
30
31  constructor(status: number, message: string) {
32    super(message)
33    this.status = status
34  }
35}
36
37/** True while the task still needs polling or downloading. */
38export const isActive = (task: MeshyTask) =>
39  task.status === 'PENDING' ||
40  task.status === 'IN_PROGRESS' ||
41  task.status === 'DOWNLOADING'
42
43/** The last 8 characters: Meshy ids are UUIDv7, so their heads repeat. */
44export const shortId = (id: string) => id.replace(/-/g, '').slice(-8)
45
46const LEVELS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
47
48/**
49 * A bar `width` cells wide: full cells, one partial cell from the eighths,
50 * and the empty track, returned apart so the track can be drawn dim.
51 */
52export const progressBar = (percent: number, width = 10) => {
53  const eighths = Math.round((Math.max(0, Math.min(100, percent)) / 100) * width * 8)
54  const full = Math.floor(eighths / 8)
55  const part = eighths % 8
56  const filled = '█'.repeat(full) + (part > 0 ? LEVELS[part - 1] : '')
57
58  return { filled, track: '▁'.repeat(width - filled.length) }
59}
60
61export const formatElapsed = (ms: number) => {
62  const seconds = Math.max(0, Math.floor(ms / 1000))
63  if (seconds < 60) return `${seconds}s`
64  const minutes = Math.floor(seconds / 60)
65  if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
66
67  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
68}
69
70export const statusLabel = (task: MeshyTask) => {
71  if (task.status === 'PENDING') return 'queued'
72  if (task.status === 'DOWNLOADING') return 'saving'
73  if (task.status === 'IN_PROGRESS') return task.stage ?? 'running'
74
75  return task.status.toLowerCase()
76}
77
78/** Reads MESHY_API_KEY from a .env file's text, or undefined. */
79export const keyFromDotenv = (text: string) => {
80  for (const line of text.split(/\r?\n/)) {
81    const match = /^\s*(?:export\s+)?MESHY_API_KEY\s*=\s*(.*)$/.exec(line)
82    if (!match) continue
83    let value = (match[1] ?? '').trim()
84    const quote = value[0]
85    if ((quote === '"' || quote === "'") && value.indexOf(quote, 1) > 0) {
86      value = value.slice(1, value.indexOf(quote, 1))
87    } else {
88      value = value.replace(/\s+#.*$/, '')
89    }
90    if (value) return value
91  }
92
93  return undefined
94}
95
96/** Never lets the key reach a message, whatever an error echoes back. */
97export const scrub = (text: string, key: string) =>
98  key ? text.split(key).join('***') : text
99
100/** A Meshy response's JSON, or a MeshyError with the API's own message. */
101export const parseResponse = (response: HttpResponse, key: string): unknown => {
102  let json: unknown
103  try {
104    json = JSON.parse(response.text)
105  } catch {
106    json = undefined
107  }
108  if (!response.ok) {
109    const message = (json as { message?: unknown } | undefined)?.message
110    const text = typeof message === 'string' && message ? message : 'request failed'
111    throw new MeshyError(response.status, scrub(`${text} (HTTP ${response.status})`, key))
112  }
113
114  return json
115}
116
117const MIME: Record<string, string> = {
118  png: 'image/png',
119  jpg: 'image/jpeg',
120  jpeg: 'image/jpeg',
121}
122
123/** True for an image Meshy fetches itself: an http(s) URL or a data URI. */
124export const isRemoteImage = (image: string) => /^(https?:|data:)/i.test(image)
125
126/** The MIME type of a local image Meshy accepts, or an error. */
127export const imageMime = (image: string) => {
128  const mime = MIME[image.split('.').pop()?.toLowerCase() ?? '']
129  if (!mime) throw new Error(`${image}: Meshy takes .png, .jpg or .jpeg images`)
130
131  return mime
132}
133
134/** How an image argument shows in the pane: data URIs are not worth printing. */
135export const describeImage = (image: string) =>
136  image.startsWith('data:') ? '(data URI)' : image
137
types/index.d.ts 41 lines
1export type MeshyKind = 'image' | 'text' | 'multi'
2
3export type MeshyStatus =
4  | 'PENDING'
5  | 'IN_PROGRESS'
6  | 'DOWNLOADING'
7  | 'SUCCEEDED'
8  | 'FAILED'
9  | 'CANCELED'
10
11export type MeshyTask = {
12  /** The id meshy_generate returned; names the GLB file. */
13  id: string
14  kind: MeshyKind
15  /** The Meshy task being polled now (a text task's refine stage has its own). */
16  apiId: string
17  /** Text-to-3D only: which of the two stages is running. */
18  stage?: 'preview' | 'refine'
19  /** Text-to-3D only: start a refine (texturing) task once the preview succeeds. */
20  shouldRefine?: boolean
21  /** Refine request fields (enable_pbr, texture_prompt, ...). */
22  refineOptions?: { [field: string]: string | number | boolean }
23  status: MeshyStatus
24  progress: number
25  /** The prompt, or the image path or URL(s). */
26  source: string
27  /** The session directory the GLB is saved under. */
28  cwd: string
29  startedAt: number
30  finishedAt?: number
31  /** './meshy-models/<id>.glb' once downloaded. */
32  file?: string
33  error?: string
34}
35
36declare module 'claude-code' {
37  interface PluginState {
38    'meshy-tray': { tasks: MeshyTask[] }
39  }
40}
41