From 99b88c8723b29aea5f463b6de4a30903ae91c9bc Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 28 Jun 2026 19:34:51 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20Add=20meshy=20plugin=20for=20Me?= =?UTF-8?q?shy=20AI=203D=20generation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-dependency Node (ESM, native fetch) CLI client wrapped in a model-invoked skill, covering the full Meshy REST API (text/image-to-3D, multi-image, refine, retexture, remesh, rig, animate, image generation, convert/resize/uv-unwrap, 3D-printing prep, plus balance/status/list/download) through one generic create→poll→download engine. Chosen over the official MCP server to avoid loading its tool schemas into context every session. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude-plugin/marketplace.json | 10 + .gitignore | 3 + docs/plugin-catalog/index.md | 1 + docs/plugin-catalog/meshy-plugin.md | 96 ++ plugins/meshy/.claude-plugin/plugin.json | 11 + plugins/meshy/skills/meshy/SKILL.md | 66 ++ .../meshy/skills/meshy/docs/api-reference.md | 148 +++ plugins/meshy/skills/meshy/scripts/meshy.mjs | 966 ++++++++++++++++++ 8 files changed, 1301 insertions(+) create mode 100644 docs/plugin-catalog/meshy-plugin.md create mode 100644 plugins/meshy/.claude-plugin/plugin.json create mode 100644 plugins/meshy/skills/meshy/SKILL.md create mode 100644 plugins/meshy/skills/meshy/docs/api-reference.md create mode 100644 plugins/meshy/skills/meshy/scripts/meshy.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c5c9a3d..c046be6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -37,6 +37,16 @@ "email": "flopspm@gmail.com" }, "keywords": ["review", "coderabbit", "github", "pr", "pull-request", "bot-comments"] + }, + { + "name": "meshy", + "source": "./plugins/meshy", + "description": "Generate 3D models, textures, rigs and animations from text or images via the Meshy AI API", + "author": { + "name": "Flop", + "email": "flopspm@gmail.com" + }, + "keywords": ["meshy", "3d", "ai", "text-to-3d", "image-to-3d", "generative", "model-generation"] } ] } diff --git a/.gitignore b/.gitignore index 5e75d23..6bf3e13 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ dev-debug.log # Task files # tasks.json # tasks/ + +# Local Meshy plugin test artifacts +.tmp-meshy-test/ diff --git a/docs/plugin-catalog/index.md b/docs/plugin-catalog/index.md index c4514ee..302fd05 100644 --- a/docs/plugin-catalog/index.md +++ b/docs/plugin-catalog/index.md @@ -7,6 +7,7 @@ This catalog provides detailed documentation for all available plugins in the Fl - [Docs Plugin](docs-plugin.md) - Generate and keep documentation in sync with your codebase - [Git Plugin](git-plugin.md) - Smart git workflow commands with intelligent conflict resolution - [Resolve CodeRabbit Plugin](resolve-coderabbit-plugin.md) - Walk through CodeRabbit inline PR comments, verify, fix, and batch-resolve +- [Meshy Plugin](meshy-plugin.md) - Generate 3D models, textures, rigs and animations from text or images via the Meshy AI API ## Extras diff --git a/docs/plugin-catalog/meshy-plugin.md b/docs/plugin-catalog/meshy-plugin.md new file mode 100644 index 0000000..fd4dd5f --- /dev/null +++ b/docs/plugin-catalog/meshy-plugin.md @@ -0,0 +1,96 @@ +# Meshy Plugin + +**Name:** `meshy` + +**Description:** Generate 3D models, textures, rigs and animations from text or images via the Meshy AI API + +**Author:** Flop (flopspm@gmail.com) + +**Version:** 0.1.0 + +**Keywords:** meshy, 3d, ai, text-to-3d, image-to-3d, generative, model-generation + +The Meshy plugin gives Claude Code access to the full [Meshy AI](https://www.meshy.ai) 3D generation API through a **single zero-dependency Node script** (`meshy.mjs`) wrapped in a model-invoked skill. It deliberately avoids the official MCP server: an MCP server loads all of its tool schemas into context on every session, while a skill loads only its one-line description until it's actually needed — far cheaper for an API you reach for occasionally. The script runs identically on macOS, Linux, and Windows (it only needs Node), with no `npm install` and no build step. + +## Installation + +```bash +claude plugin install meshy@flugins +``` + +**Important:** After installing the plugin, restart Claude Code to activate skills. + +**Tip:** Enable auto-update via `/plugin` → **Installed** → select the plugin → enable auto-update. + +## Requirements + +- **Node.js 18+** on `PATH` (`node --version`) — the script uses native `fetch` and streams; no other runtime or dependency is required. +- A **Meshy API key** in the `MESHY_AI_API_KEY` environment variable (or `MESHY_API_KEY`, or passed via `--api-key`). Create one at [meshy.ai/settings/api](https://www.meshy.ai/settings/api). Keys look like `msy_...`. +- Credits on your Meshy account for paid operations. A free, no-cost **test key** (`msy_dummy_api_key_for_test_mode_12345678`) returns sample assets without spending credits — handy for dry-runs. + +## Features + +### Skills + +- [Meshy 3D Generation](#meshy-3d-generation) - Generate 3D models, textures, rigs, animations and images from text or photos, check credit balance, and download finished assets + +### Usage + +Ask in natural language — *"make a 3D model of a red ceramic mug"*, *"turn this photo into a 3D model"*, *"rig this character"*, *"how many Meshy credits do I have?"* — and the skill activates automatically. It picks the right endpoint, runs the bundled CLI (which handles the asynchronous create → poll → download lifecycle internally), parses the single JSON object the script prints, and reports where the files were saved and how many credits were spent. For long jobs it can fire-and-forget with `--no-wait` and resume later with `status` / `download`. + +### Configuration + +No plugin-specific configuration. The skill reads the API key from the environment (`MESHY_AI_API_KEY` / `MESHY_API_KEY`) or a `--api-key` flag, and the script ships sensible defaults (20-minute poll timeout, 5-second poll interval, automatic retry with backoff on rate limits). The full endpoint and parameter reference lives in `skills/meshy/docs/api-reference.md` and is loaded on demand rather than into every session. + +--- + +## Meshy 3D Generation + +**Skill:** `meshy` +**Type:** Model-invoked (automatic) / user-invocable + +Drives the entire Meshy REST API from one generic client. The script exposes every endpoint behind a uniform `create → poll → download` engine and prints exactly one machine-readable JSON object to STDOUT (progress goes to STDERR), so Claude never has to scrape human text or write its own polling loop. + +### How it Activates + +The skill activates whenever you mention 3D generation or Meshy. Examples: + +- "generate a 3D model of a low-poly tree" +- "turn this image into a 3D mesh" +- "re-texture this model to look like weathered bronze" +- "rig and animate this character" +- "check my Meshy balance" + +You can also invoke it explicitly from the `/` menu. + +### Capabilities + +| Area | Commands | +|------|----------| +| 3D generation | `text-to-3d` (+ `refine`), `image-to-3d`, `multi-image-to-3d` | +| Post-processing | `retexture`, `remesh`, `rig`, `animate`, `convert`, `resize`, `uv-unwrap` | +| Image generation | `text-to-image`, `image-to-image` | +| 3D printing | `analyze-printability` (free), `repair-printability`, `multi-color-print` | +| Experimental | `creative-lab` (keychain / fridge-magnet / figure / lamp, prototype → build) | +| Account & tasks | `balance`, `status `, `list `, `download ` | + +### Key Behaviors + +- **Asynchronous handled for you.** Every generation `POST` returns a task id; the script polls until the task reaches `SUCCEEDED`/`FAILED`/`CANCELED`, streaming progress to STDERR. Use `--no-wait` to return the id immediately and resume later. +- **Downloads with `--out`.** Result URLs are short-lived signed links (models are retained for only a few days). Pass `--out ` to stream assets to disk immediately; `--formats glb,fbx` selects model formats (default `glb`), and `--textures` / `--thumbnail` / `--all` pull extras. If a URL has expired, `download` re-fetches the task once for fresh links. +- **Local images, no manual base64.** Pass a local file to `--image` / `--images` / `--texture-image-url` and the CLI encodes it into a data URI for you. +- **Whole API reachable.** Common parameters have typed flags; anything exotic is reachable via `--param key=value` or `--json '{...}'`. +- **Clear failure signals.** Exit codes distinguish auth (`3`), insufficient credits (`4`), rate-limit (`5`), task-failed (`7`), timeout (`8`), and network (`9`), and the JSON `error.kind` mirrors them. + +### Credits (approximate) + +Preview text-to-3D ≈ 5 (meshy-5) to 20 (meshy-6 / low-poly); refine ≈ 10; image-to-3D ≈ 20–30; remesh / rig ≈ 5; animate ≈ 3; convert / resize ≈ 1; `analyze-printability` is free. Run `balance` before a large job; an exit code `4` means you're out of credits. + +### What the Skill Will NOT Do + +- Never spends credits on a non-trivial job without confirming with you first +- Never writes its own polling loop — the script owns the lifecycle +- Never hand-rolls base64 for images — local paths are encoded by the CLI +- Never requires an MCP server or any npm dependency + +See the [SKILL.md reference](https://github.com/Flopsstuff/flugins/blob/main/plugins/meshy/skills/meshy/SKILL.md) and the bundled [`docs/api-reference.md`](https://github.com/Flopsstuff/flugins/blob/main/plugins/meshy/skills/meshy/docs/api-reference.md) for the full command and parameter specification. diff --git a/plugins/meshy/.claude-plugin/plugin.json b/plugins/meshy/.claude-plugin/plugin.json new file mode 100644 index 0000000..c1e7f55 --- /dev/null +++ b/plugins/meshy/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "meshy", + "version": "0.1.0", + "description": "Generate 3D models, textures, rigs and animations from text or images via the Meshy AI API — a zero-dependency Node client wrapped in a skill", + "homepage": "https://github.com/Flopsstuff/flugins", + "author": { + "name": "Flop", + "email": "flopspm@gmail.com" + }, + "keywords": ["meshy", "3d", "ai", "text-to-3d", "image-to-3d", "generative", "model-generation"] +} diff --git a/plugins/meshy/skills/meshy/SKILL.md b/plugins/meshy/skills/meshy/SKILL.md new file mode 100644 index 0000000..c96d2cb --- /dev/null +++ b/plugins/meshy/skills/meshy/SKILL.md @@ -0,0 +1,66 @@ +--- +name: meshy +description: Generate 3D assets from text or images via the Meshy AI API — text-to-3D and image-to-3D models, multi-image-to-3D, re-texturing, remeshing, auto-rigging, animation, 2D image generation, format conversion/resize/UV-unwrap, and 3D-printing prep. Use this skill whenever the user wants to create / generate / make a 3D model, mesh, or texture, turn a prompt or photo into 3D, rig or animate a character, check their Meshy credit balance, or otherwise mentions Meshy. Drives a bundled zero-dependency Node CLI; no MCP server needed. +disable-model-invocation: false +user-invocable: true +allowed-tools: >- + Read AskUserQuestion + Bash(node ${CLAUDE_SKILL_DIR}/scripts/*) +--- + +Generate 3D assets with the **Meshy AI** REST API through a bundled, zero-dependency Node CLI (`meshy.mjs`). The script handles auth, the async create→poll→download lifecycle, retries, and asset downloads; you just choose a command, run it, and parse its JSON output. + +## Requirements (check before first use) + +- **Node 18+** must be on PATH (`node --version`). If missing, tell the user to install Node — the script cannot run without it. +- An API key in **`$MESHY_AI_API_KEY`** (or `$MESHY_API_KEY`, or pass `--api-key`). Keys look like `msy_...` and are created at https://www.meshy.ai/settings/api. There is a free, no-cost **test key** `msy_dummy_api_key_for_test_mode_12345678` that returns sample assets without spending credits — use it for dry-runs. + +## How to run it + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/meshy.mjs" [options] +``` + +**Output contract — rely on this, don't re-read files:** +- **STDOUT** is exactly one JSON object. Parse it; never scrape the human text. +- **STDERR** carries progress lines (`[text-to-3d IN_PROGRESS 42% • 30s]`) and warnings. +- **Exit codes:** `0` ok · `2` usage · `3` auth (no/invalid key) · `4` insufficient credits · `5` rate-limit · `6` not-found · `7` task failed · `8` poll timeout · `9` network · `130` interrupted. + +The script **polls internally** until the task finishes — do **not** write your own polling loop. For very long jobs you may pass `--no-wait` (returns the `task_id` immediately) and later resume with `status` / `download`. + +## Core workflow + +1. **Pre-flight:** confirm `node` exists and a key is set. Optionally run `node "${CLAUDE_SKILL_DIR}/scripts/meshy.mjs" balance` to show remaining credits before spending. +2. **Pick the command** from the user's intent (see table below; full parameters in `docs/api-reference.md`). +3. **Run it.** Generation commands cost credits — for anything beyond a trivial request, briefly confirm with the user first, and prefer the **test key** when validating an approach. +4. **Always pass `--out `** when the user wants the asset saved — result URLs are signed and **expire within ~days**. Without `--out` the JSON still contains the URLs but nothing is downloaded. +5. **Parse the JSON**, then report: the `status`, where files were written (`downloads.written[].path`), `credits_consumed`, and any `downloads.skipped` / `downloads.errors`. On a non-zero exit, read `error.kind` / `error.message` and explain it (e.g. `credits` → out of credits, `auth` → key problem). If a long job was interrupted or timed out, the `task_id` in the output lets you resume with `download --out `. + +## Commands (most common) + +| Intent | Command | +|---|---| +| Text → 3D model | `text-to-3d --prompt "a red ceramic mug"` | +| Higher-quality textured pass | `refine --preview-task-id --enable-pbr` | +| Image → 3D model | `image-to-3d --image ./photo.png` (local file is auto-base64'd) or `--image-url ` | +| Several images → 3D | `multi-image-to-3d --images a.png,b.png,c.png` | +| Re-texture a model | `retexture --input-task-id --text-style-prompt "weathered bronze"` | +| Remesh (topology/polycount) | `remesh --input-task-id --topology quad --target-polycount 20000` | +| Auto-rig a character | `rig --input-task-id ` | +| Animate a rigged model | `animate --rig-task-id --action ` | +| Text → 2D image | `text-to-image --ai-model nano-banana --prompt "..."` | +| Convert / resize / UV-unwrap | `convert --input-task-id --target-formats glb,fbx` · `resize` · `uv-unwrap` | +| Credit balance | `balance` | +| Task snapshot / resume | `status ` (add `--wait` to block) | +| Download a finished task | `download --out [--all]` | +| Full help / catalog | `help` · `help --json` | + +Useful options: `--out `, `--formats glb,fbx` (default `glb`), `--textures`/`--thumbnail`/`--all`, `--no-wait`, `--ai-model meshy-5|meshy-6|latest`, `--pretty`. Any API field not exposed as a flag is reachable via `--param key=value` or `--json '{...}'`. + +## Notes & gotchas + +- **Credits:** preview text-to-3d ≈ 5 (meshy-5) or up to 20 (meshy-6/lowpoly); refine ≈ 10; image-to-3d 20–30; rig/animate/remesh small. `analyze-printability` is free. Check `balance` if unsure; an exit code `4` means out of credits. +- **`meshy-5`** is the cheapest model for a quick text-to-3d preview; default `ai_model` is `latest`. +- **Don't hand-roll base64** for images — pass a local path to `--image` / `--images` / `--texture-image-url` and the CLI encodes it. +- **Creative Lab** (`creative-lab --product --stage prototype|build`) is experimental and thinly documented; rely on `--param`/`--json` for its body. +- For the complete endpoint/parameter reference, read **`docs/api-reference.md`** in this skill directory (`${CLAUDE_SKILL_DIR}/docs/api-reference.md`) — load it on demand, not every run. diff --git a/plugins/meshy/skills/meshy/docs/api-reference.md b/plugins/meshy/skills/meshy/docs/api-reference.md new file mode 100644 index 0000000..98d1a1a --- /dev/null +++ b/plugins/meshy/skills/meshy/docs/api-reference.md @@ -0,0 +1,148 @@ +# Meshy API reference (`meshy.mjs`) + +Full endpoint and parameter reference for the bundled CLI. Load this on demand; the +lean workflow lives in `SKILL.md`. Run `node meshy.mjs help` for a quick command list. + +- **Base URL:** `https://api.meshy.ai/openapi/` (override with `--base-url`) +- **Auth:** `--api-key`, else `$MESHY_AI_API_KEY`, else `$MESHY_API_KEY`. Keys are `msy_...`. +- **Test key (free, no credits):** `msy_dummy_api_key_for_test_mode_12345678` +- **Async lifecycle:** every generation `POST` returns a `task_id`; the CLI polls + `GET /` until `status` is terminal, then optionally downloads assets. +- **Status enum:** `PENDING` → `IN_PROGRESS` → `SUCCEEDED` | `FAILED` | `CANCELED`; `progress` is 0–100. +- **Asset URLs are short-lived signed URLs** (models retained ≤ ~3 days). Always `--out ` to save promptly; `download` re-fetches the task once if a URL has expired. + +## Global options + +| Option | Meaning | Default | +|---|---|---| +| `--api-key ` | override env key | — | +| `--base-url ` | API base | `https://api.meshy.ai/openapi/` | +| `--out ` | download finished assets to `` | — (no download) | +| `--formats glb,fbx` | model formats to download | `glb` | +| `--textures` / `--thumbnail` / `--all` | also download texture maps / thumbnail / everything | off | +| `--no-wait` | create the task, print its id, exit (no polling) | waits | +| `--wait` | (for `status`) poll until terminal | off | +| `--poll-timeout ` | total wait before giving up | `1200000` (20 min) | +| `--interval ` | poll interval | `5000` | +| `--request-timeout ` | per-HTTP-request timeout | `60000` | +| `--retries ` | retries for 429 / transient network (exp. backoff) | `5` | +| `--json '{...}'` | raw request body (merged **under** typed flags) | — | +| `--param key=value` | set any API field; value parsed as JSON if possible, else string | — | +| `--pretty` | pretty-print the JSON output | off | +| `--quiet` / `--verbose` | suppress / expand STDERR progress | normal | + +**Body-merge precedence:** command presets < `--json` < typed flags < `--param`. +Flag names accept dashes or underscores (`--target-polycount` = `--target_polycount`). +Booleans support `--flag`, `--flag=false`, and `--no-flag`. Unknown flags are a usage +error (exit 2) — use `--param`/`--json` for fields without a dedicated flag. + +## Output JSON (STDOUT, exactly one object) + +Success (generate / status): +```json +{ "ok": true, "command": "text-to-3d", "type": "text-to-3d", "task_id": "...", + "status": "SUCCEEDED", "waited": true, "task": { /* full API task object */ }, + "downloads": { "out_dir": "...", "written": [{"asset":"model","format":"glb","path":"...","bytes":123}], + "skipped": [{"asset":"model","format":"usdz","reason":"not available"}], "errors": [] }, + "credits_consumed": 5, "meta": { "elapsed_ms": 65000, "api_base": "..." } } +``` +`balance` → `{ "ok": true, "command": "balance", "balance": 835 }`. +`list` → `{ "ok": true, "command": "list", "type": "...", "page_num":1, "page_size":10, "count": N, "tasks": [...] }`. +Failure → `{ "ok": false, "command": "...", "error": { "kind", "message", "status", "code", "doc_url", "task_id", "retryable" }, "meta": {...} }`. + +## Exit codes + +`0` ok · `1` generic · `2` usage · `3` auth · `4` credits · `5` rate-limit · +`6` not-found · `7` task-failed · `8` timeout · `9` network · `130` interrupted. + +## Endpoints + +All paths are relative to the base URL. Create = `POST`, retrieve/list = `GET`. +`(id|url)` means supply exactly one of `--input-task-id` or `--model-url`. + +### `text-to-3d` — `v2/text-to-3d` (alias `refine`) +Preview: `--prompt` (≤600, required), `--ai-model meshy-5|meshy-6|latest`, +`--should-remesh`, `--model-type standard|lowpoly`, `--topology quad|triangle`, +`--target-polycount 100..300000`, `--pose-mode a-pose|t-pose|""`, `--target-formats`, +`--moderation`, `--auto-size`, `--alpha-thumbnail`, `--origin-at bottom|center`, +`--decimation-mode 1..4`, `--seed`. +Refine (`--mode refine` or the `refine` alias): `--preview-task-id` (required), +`--enable-pbr`, `--hd-texture`, `--texture-prompt`, `--texture-image-url`, `--remove-lighting`. +→ `model_urls {glb,fbx,obj,mtl,usdz,stl}`, `texture_urls[]`, `thumbnail_url`. Preview ≈ 5–20 credits, refine ≈ 10. + +### `image-to-3d` — `v1/image-to-3d` +`--image-url ` or `--image ` (auto-base64) or `--input-task-id`; +`--should-texture`, `--enable-pbr`, `--hd-texture`, `--ai-model`, `--should-remesh`, +`--target-polycount`, `--target-formats`, `--image-enhancement`, `--remove-lighting`, +`--texture-prompt`, `--texture-image-url`, `--pose-mode`, `--auto-size`. ≈ 20–30 credits. + +### `multi-image-to-3d` — `v1/multi-image-to-3d` +`--images a.png,b.png` (1–4 local/URL) or `--image-urls` or `--input-task-id`; plus the +`image-to-3d` texture/mesh flags. → also `thumbnail_urls{front,right,back,left}`. + +### `retexture` — `v1/retexture` +`(id|url)` + one of `--text-style-prompt` / `--image-style-url`; `--enable-original-uv`, +`--enable-pbr`, `--hd-texture`, `--ai-model`, `--remove-lighting`, `--target-formats`, +`--alpha-thumbnail`. ≈ 10 credits. + +### `remesh` — `v1/remesh` +`(id|url)`, `--target-formats` (glb,fbx,obj,usdz,blend,stl,3mf), `--topology quad|triangle`, +`--target-polycount 100..300000`, `--origin-at`. ≈ 5 credits. + +### `rigging` (alias `rig`) — `v1/rigging` +`(id|url)`, `--character-height` / `--height-meters`, `--texture-image-url`. +→ `rigged_character_glb_url`. ≈ 5 credits. + +### `animation` (alias `animate`) — `v1/animation` +`--rig-task-id` (required), `--action` / `--animation-id`. +→ `animated_model_glb_url`, `video_url`. ≈ 3 credits. + +### `text-to-image` — `v1/text-to-image` +`--ai-model nano-banana|nano-banana-2|nano-banana-pro|gpt-image-2` (required), +`--prompt` (required), `--aspect-ratio`, `--generate-multi-view`, `--pose-mode`. +→ `image_urls[]`. ≈ 3–9 credits. + +### `image-to-image` — `v1/image-to-image` +`--ai-model` + `--prompt` + one of `--image-url`/`--image`/`--input-task-id`, +`--aspect-ratio`. → `image_urls[]`. ≈ 3–12 credits. + +### `convert` — `v1/convert` +`(id|url)` + `--target-formats` (required). ≈ 1 credit. + +### `resize` — `v1/resize` +`(id|url)` + one of `--resize-height` / `--resize-longest-side` / `--auto-size`; +`--origin-at bottom|center`. ≈ 1 credit. + +### `uv-unwrap` — `v1/uv-unwrap` +`(id|url)` — **`.glb` only**. → `model_urls.glb`, `thumbnail_url`. ≈ 5 credits. + +### `analyze-printability` — `v1/analyze-printability` +`(id|url)`. Returns watertightness / volume / hole / non-manifold metrics in the task +object. **Free** (0 credits). + +### `repair-printability` — `v1/repair-printability` +`(id|url)`. → repaired `model_urls`. ≈ 10 credits. + +### `multi-color-print` — `v1/multi-color-print` +`(id|url)` (textured model). → multi-color 3MF. ≈ 10 credits. Color-palette options +(1–16 colors) via `--param` / `--json` (thinly documented). + +### `creative-lab` — `creative-lab//v1/` (EXPERIMENTAL) +`--product keychain|fridge-magnet|figure|lamp`, `--stage prototype|build`. +Prototype: `--prompt` or `--image-url`/`--image`. Build: `--input-task-id` (the prototype +task). Prototype ≈ 6 credits, build ≈ 30. Response schema is thinly documented — use +`--param`/`--json` for product-specific options and inspect the raw `task` in the output. + +### Account & task management +- `balance` — `GET v1/balance` → `{ "balance": N }`. +- `status ` — task snapshot; add `--wait` to poll to terminal. +- `list ` — `GET ` with `--page-num` (1), `--page-size` (≤50, default 10), + `--sort-by +created_at|-created_at` (default `-created_at`). +- `download --out ` — re-fetch a finished task and download its assets + (always fresh signed URLs). Combine with `--formats` / `--all`. + +## Rate limits + +Pro 20 req/s & 10 queued tasks; Studio 20 & 20; Enterprise 100 & 50+. A `429` +(`RateLimitExceeded` / `NoMoreConcurrentTasks`) is retried automatically with backoff; +after `--retries` are exhausted it surfaces as exit `5`. diff --git a/plugins/meshy/skills/meshy/scripts/meshy.mjs b/plugins/meshy/skills/meshy/scripts/meshy.mjs new file mode 100644 index 0000000..2e13240 --- /dev/null +++ b/plugins/meshy/skills/meshy/scripts/meshy.mjs @@ -0,0 +1,966 @@ +#!/usr/bin/env node +// meshy.mjs — zero-dependency Node (ESM) CLI client for the Meshy AI REST API. +// +// Wraps every public Meshy endpoint behind a single generic async engine: +// create task (POST) -> poll until terminal (GET /:id) -> optionally download assets. +// +// Output contract: +// STDOUT — exactly ONE machine-readable JSON object (so the calling skill parses +// it without re-reading files). Emitted once, at the very end. +// STDERR — all human progress, warnings and stack traces. +// +// Auth: --api-key, else $MESHY_AI_API_KEY, else $MESHY_API_KEY. Keys look like `msy_...`. +// Free no-cost test key: msy_dummy_api_key_for_test_mode_12345678 +// +// Exit codes: 0 ok · 2 usage · 3 auth · 4 credits · 5 rate-limit · 6 not-found +// 7 task-failed · 8 timeout · 9 network · 1 generic · 130 interrupted +// +// Requires Node 18+ (native fetch / Readable.fromWeb). +// +// Examples: +// node meshy.mjs balance +// node meshy.mjs text-to-3d --prompt "a red ceramic mug" --out ./out --formats glb,fbx +// node meshy.mjs text-to-3d --prompt "a cube" --no-wait +// node meshy.mjs status text-to-3d +// node meshy.mjs download image-to-3d --out ./out --all +// node meshy.mjs help --json + +import { createWriteStream } from 'node:fs'; +import { readFile, mkdir, rename, stat } from 'node:fs/promises'; +import { pipeline } from 'node:stream/promises'; +import { Readable } from 'node:stream'; +import path from 'node:path'; +import process from 'node:process'; + +// ── Section 3: constants ─────────────────────────────────────────────────── +const VERSION = '0.1.0'; +const TEST_KEY = 'msy_dummy_api_key_for_test_mode_12345678'; + +const DEFAULTS = { + baseUrl: 'https://api.meshy.ai/openapi/', + requestTimeoutMs: 60_000, + pollTimeoutMs: 1_200_000, // 20 min + pollIntervalMs: 5_000, + maxRetries: 5, + formats: ['glb'], +}; + +const EXIT = { + OK: 0, GENERIC: 1, USAGE: 2, AUTH: 3, CREDITS: 4, RATELIMIT: 5, + NOTFOUND: 6, TASK_FAILED: 7, TIMEOUT: 8, NETWORK: 9, INTERRUPTED: 130, +}; + +const TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'CANCELED']); + +// ── Section 4: registry (type -> endpoint + params + requirements) ───────── +// `params` keys are the snake_case API field names. The CLI accepts both +// `--target_polycount` and `--target-polycount` (dashes normalize to underscores). +// `requires`: each entry is a field name (must be present) or an array = OR-group. + +const AI_MODELS = ['meshy-5', 'meshy-6', 'latest']; +const FORMATS = ['glb', 'fbx', 'obj', 'usdz', 'blend', 'stl', '3mf', 'mtl', 'gltf']; + +// shared param fragments +const meshParams = { + ai_model: { type: 'string', enum: AI_MODELS }, + should_remesh: { type: 'boolean' }, + model_type: { type: 'string', enum: ['standard', 'lowpoly'] }, + topology: { type: 'string', enum: ['quad', 'triangle'] }, + target_polycount: { type: 'number', min: 100, max: 300000 }, + target_formats: { type: 'array' }, + pose_mode: { type: 'string', enum: ['a-pose', 't-pose', ''] }, + moderation: { type: 'boolean' }, + auto_size: { type: 'boolean' }, + alpha_thumbnail: { type: 'boolean' }, +}; +const textureParams = { + enable_pbr: { type: 'boolean' }, + hd_texture: { type: 'boolean' }, + texture_prompt: { type: 'string', maxLen: 600 }, + texture_image_url: { type: 'string' }, + remove_lighting: { type: 'boolean' }, +}; + +const TYPES = { + 'text-to-3d': { + path: 'v2/text-to-3d', + params: { + mode: { type: 'string', enum: ['preview', 'refine'] }, + prompt: { type: 'string', maxLen: 600 }, + seed: { type: 'number' }, + decimation_mode: { type: 'number', min: 1, max: 4 }, + origin_at: { type: 'string', enum: ['bottom', 'center'] }, + preview_task_id: { type: 'string' }, + ...meshParams, ...textureParams, + }, + validate(body) { + if (body.mode === 'refine') { + if (!body.preview_task_id) throw usage('refine requires --preview-task-id'); + } else { + if (!body.mode) body.mode = 'preview'; + if (!body.prompt) throw usage('text-to-3d (preview) requires --prompt'); + } + }, + }, + 'image-to-3d': { + path: 'v1/image-to-3d', + params: { + image_url: { type: 'string' }, + image: { type: 'string' }, // local path alias -> image_url (base64 data URI) + input_task_id: { type: 'string' }, + should_texture: { type: 'boolean' }, + image_enhancement: { type: 'boolean' }, + ...meshParams, ...textureParams, + }, + requires: [['image_url', 'image', 'input_task_id']], + }, + 'multi-image-to-3d': { + path: 'v1/multi-image-to-3d', + params: { + image_urls: { type: 'array' }, + images: { type: 'array' }, // local paths alias -> image_urls + input_task_id: { type: 'string' }, + should_texture: { type: 'boolean' }, + ...meshParams, ...textureParams, + }, + requires: [['image_urls', 'images', 'input_task_id']], + }, + retexture: { + path: 'v1/retexture', + params: { + input_task_id: { type: 'string' }, + model_url: { type: 'string' }, + text_style_prompt: { type: 'string', maxLen: 600 }, + image_style_url: { type: 'string' }, + enable_original_uv: { type: 'boolean' }, + ai_model: { type: 'string', enum: AI_MODELS }, + target_formats: { type: 'array' }, + alpha_thumbnail: { type: 'boolean' }, + ...textureParams, + }, + requires: [['input_task_id', 'model_url'], ['text_style_prompt', 'image_style_url']], + }, + remesh: { + path: 'v1/remesh', + params: { + input_task_id: { type: 'string' }, + model_url: { type: 'string' }, + target_formats: { type: 'array' }, + topology: { type: 'string', enum: ['quad', 'triangle'] }, + target_polycount: { type: 'number', min: 100, max: 300000 }, + origin_at: { type: 'string', enum: ['bottom', 'center'] }, + }, + requires: [['input_task_id', 'model_url']], + }, + rigging: { + path: 'v1/rigging', + params: { + input_task_id: { type: 'string' }, + model_url: { type: 'string' }, + character_height: { type: 'number' }, + height_meters: { type: 'number' }, + texture_image_url: { type: 'string' }, + }, + requires: [['input_task_id', 'model_url']], + }, + animation: { + path: 'v1/animation', + params: { + rig_task_id: { type: 'string' }, + action: { type: 'string' }, + animation_id: { type: 'string' }, + }, + requires: ['rig_task_id'], + }, + 'text-to-image': { + path: 'v1/text-to-image', + params: { + ai_model: { type: 'string', enum: ['nano-banana', 'nano-banana-2', 'nano-banana-pro', 'gpt-image-2'] }, + prompt: { type: 'string' }, + aspect_ratio: { type: 'string' }, + generate_multi_view: { type: 'boolean' }, + pose_mode: { type: 'string', enum: ['a-pose', 't-pose'] }, + }, + requires: ['ai_model', 'prompt'], + }, + 'image-to-image': { + path: 'v1/image-to-image', + params: { + ai_model: { type: 'string', enum: ['nano-banana', 'nano-banana-2', 'nano-banana-pro', 'gpt-image-2'] }, + prompt: { type: 'string' }, + image_url: { type: 'string' }, + image: { type: 'string' }, + input_task_id: { type: 'string' }, + aspect_ratio: { type: 'string' }, + }, + requires: ['ai_model', 'prompt', ['image_url', 'image', 'input_task_id']], + }, + convert: { + path: 'v1/convert', + params: { + input_task_id: { type: 'string' }, + model_url: { type: 'string' }, + target_formats: { type: 'array' }, + }, + requires: [['input_task_id', 'model_url'], 'target_formats'], + }, + resize: { + path: 'v1/resize', + params: { + input_task_id: { type: 'string' }, + model_url: { type: 'string' }, + resize_height: { type: 'number' }, + resize_longest_side: { type: 'number' }, + auto_size: { type: 'boolean' }, + origin_at: { type: 'string', enum: ['bottom', 'center'] }, + }, + requires: [['input_task_id', 'model_url'], ['resize_height', 'resize_longest_side', 'auto_size']], + }, + 'uv-unwrap': { + path: 'v1/uv-unwrap', + params: { + input_task_id: { type: 'string' }, + model_url: { type: 'string' }, // .glb only + }, + requires: [['input_task_id', 'model_url']], + }, + 'analyze-printability': { + path: 'v1/analyze-printability', + params: { + input_task_id: { type: 'string' }, + model_url: { type: 'string' }, + }, + requires: [['input_task_id', 'model_url']], + }, + 'repair-printability': { + path: 'v1/repair-printability', + params: { + input_task_id: { type: 'string' }, + model_url: { type: 'string' }, + }, + requires: [['input_task_id', 'model_url']], + }, + 'multi-color-print': { + path: 'v1/multi-color-print', + params: { + input_task_id: { type: 'string' }, + model_url: { type: 'string' }, + }, + requires: [['input_task_id', 'model_url']], + }, + // Creative Lab uses product-scoped URLs: creative-lab//v1/. + // EXPERIMENTAL: pass product via --product and stage via --stage; thinly documented. + 'creative-lab': { + creativeLab: true, + params: { + prompt: { type: 'string', maxLen: 600 }, + image_url: { type: 'string' }, + image: { type: 'string' }, + input_task_id: { type: 'string' }, + }, + }, +}; + +// command name -> { type, presets } : ergonomic aliases routed through the engine +const COMMAND_ALIASES = { + refine: { type: 'text-to-3d', presets: { mode: 'refine' } }, + rig: { type: 'rigging' }, + animate: { type: 'animation' }, +}; + +// control flags (everything not a body param) +const CONTROL = new Set([ + 'api_key', 'base_url', 'request_timeout', 'poll_timeout', 'interval', 'retries', + 'no_wait', 'wait', 'json', 'param', 'out', 'formats', 'textures', 'thumbnail', + 'all', 'pretty', 'quiet', 'verbose', 'product', 'stage', + 'page_num', 'page_size', 'sort_by', 'help', 'version', +]); +const CONTROL_BOOLEANS = new Set([ + 'no_wait', 'wait', 'textures', 'thumbnail', 'all', 'pretty', 'quiet', 'verbose', 'help', 'version', +]); + +// union of every boolean flag (control + registry params) — used by the arg parser +const ALL_BOOLEANS = new Set(CONTROL_BOOLEANS); +for (const t of Object.values(TYPES)) { + for (const [k, s] of Object.entries(t.params || {})) if (s.type === 'boolean') ALL_BOOLEANS.add(k); +} + +const GENERATE_COMMANDS = new Set([...Object.keys(TYPES), ...Object.keys(COMMAND_ALIASES)]); + +// ── Section 5: error types ───────────────────────────────────────────────── +class ApiError extends Error { + constructor(status, message, { code = null, docUrl = null, body = null } = {}) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + this.docUrl = docUrl; + this.body = body; + } +} +class CliError extends Error { + constructor(kind, message, extra = {}) { + super(message); + this.name = 'CliError'; + this.kind = kind; + this.taskId = extra.taskId || null; + this.status = extra.status ?? null; + this.code = extra.code ?? null; + this.docUrl = extra.docUrl ?? null; + } +} +const usage = (msg) => new CliError('usage', msg); + +const KIND_EXIT = { + usage: EXIT.USAGE, auth: EXIT.AUTH, credits: EXIT.CREDITS, 'rate-limit': EXIT.RATELIMIT, + 'not-found': EXIT.NOTFOUND, 'task-failed': EXIT.TASK_FAILED, timeout: EXIT.TIMEOUT, + network: EXIT.NETWORK, interrupted: EXIT.INTERRUPTED, generic: EXIT.GENERIC, +}; +const RETRYABLE_KINDS = new Set(['rate-limit', 'timeout', 'network']); + +function classifyApiError(e) { + const map = { 401: 'auth', 402: 'credits', 404: 'not-found', 400: 'usage', 429: 'rate-limit' }; + const kind = map[e.status] || (e.status >= 500 ? 'network' : 'generic'); + return new CliError(kind, e.message, { status: e.status, code: e.code, docUrl: e.docUrl }); +} + +// ── Section 6: tiny no-dependency arg parser ─────────────────────────────── +function parseArgs(argv) { + const positionals = []; + const opts = new Map(); + const setOpt = (k, v) => { + if (opts.has(k)) { + const prev = opts.get(k); + opts.set(k, Array.isArray(prev) ? [...prev, v] : [prev, v]); + } else opts.set(k, v); + }; + + let noMoreFlags = false; + for (let i = 0; i < argv.length; i++) { + const tok = argv[i]; + if (noMoreFlags) { positionals.push(tok); continue; } + if (tok === '--') { noMoreFlags = true; continue; } + if (tok === '-h') { setOpt('help', true); continue; } + + if (tok.startsWith('--')) { + let name = tok.slice(2); + let val; + const eq = name.indexOf('='); + if (eq >= 0) { val = name.slice(eq + 1); name = name.slice(0, eq); } + const norm = name.replace(/-/g, '_'); + + // negation: --no- sets that boolean to false (but not the literal control flag no_wait) + if (val === undefined && norm.startsWith('no_') && !CONTROL.has(norm)) { + const base = norm.slice(3); + if (ALL_BOOLEANS.has(base)) { setOpt(base, false); continue; } + } + if (val !== undefined) { setOpt(norm, val); continue; } + if (ALL_BOOLEANS.has(norm)) { setOpt(norm, true); continue; } + + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) throw usage(`--${name} expects a value`); + setOpt(norm, next); + i++; + continue; + } + if (tok.startsWith('-') && tok.length > 1 && !/^-\d/.test(tok)) throw usage(`unknown option ${tok}`); + positionals.push(tok); + } + + const command = positionals.shift(); + return { command, positionals, opts }; +} + +const lastOf = (v) => (Array.isArray(v) ? v[v.length - 1] : v); +const splitList = (v) => (Array.isArray(v) ? v : [v]).flatMap((s) => String(s).split(',')).map((s) => s.trim()).filter(Boolean); + +function coerce(value, spec, name) { + const flag = '--' + name.replace(/_/g, '-'); + switch (spec.type) { + case 'number': { + const n = Number(lastOf(value)); + if (!Number.isFinite(n)) throw usage(`${flag} expects a number`); + if (spec.min != null && n < spec.min) throw usage(`${flag} must be >= ${spec.min}`); + if (spec.max != null && n > spec.max) throw usage(`${flag} must be <= ${spec.max}`); + return n; + } + case 'boolean': { + const v = lastOf(value); + if (v === true || v === 'true' || v === '') return true; + if (v === false || v === 'false') return false; + throw usage(`${flag} expects true/false`); + } + case 'array': + return splitList(value); + default: { + const s = String(lastOf(value)); + if (spec.maxLen && s.length > spec.maxLen) throw usage(`${flag} exceeds ${spec.maxLen} characters (${s.length})`); + if (spec.enum && !spec.enum.includes(s)) { + throw usage(`${flag} must be one of: ${spec.enum.map((x) => (x === '' ? '""' : x)).join(', ')}`); + } + return s; + } + } +} + +function parseParamValue(s) { + try { return JSON.parse(s); } catch { return s; } +} + +// Body-merge precedence: presets < --json < typed flags < --param +function buildBody(type, presets, opts) { + const spec = TYPES[type].params || {}; + const body = { ...(presets || {}) }; + + if (opts.has('json')) { + let parsed; + try { parsed = JSON.parse(lastOf(opts.get('json'))); } catch (e) { throw usage(`--json is not valid JSON: ${e.message}`); } + if (parsed && typeof parsed === 'object') Object.assign(body, parsed); + else throw usage('--json must be a JSON object'); + } + + for (const [k, v] of opts) { + if (CONTROL.has(k)) continue; + const ps = spec[k]; + if (!ps) { + throw usage(`unknown option --${k.replace(/_/g, '-')} for "${type}"; pass arbitrary API fields with --param ${k}=VALUE or --json '{...}'`); + } + body[k] = coerce(v, ps, k); + } + + if (opts.has('param')) { + for (const p of (Array.isArray(opts.get('param')) ? opts.get('param') : [opts.get('param')])) { + const idx = String(p).indexOf('='); + if (idx < 0) throw usage(`--param expects key=value, got "${p}"`); + body[String(p).slice(0, idx)] = parseParamValue(String(p).slice(idx + 1)); + } + } + return body; +} + +// ── Section 7: output ────────────────────────────────────────────────────── +let LOG_QUIET = false; +function logErr(...args) { if (!LOG_QUIET) process.stderr.write(args.join(' ') + '\n'); } +function emit(obj, pretty) { process.stdout.write(JSON.stringify(obj, null, pretty ? 2 : 0) + '\n'); } + +function emitError(err, command, pretty, startedAt) { + const cli = err instanceof CliError ? err + : err instanceof ApiError ? classifyApiError(err) + : new CliError('generic', err?.message || String(err)); + const envelope = { + ok: false, + command: command || null, + error: { + kind: cli.kind, + message: cli.message, + status: cli.status ?? null, + code: cli.code ?? null, + doc_url: cli.docUrl ?? null, + task_id: cli.taskId ?? null, + retryable: RETRYABLE_KINDS.has(cli.kind), + }, + meta: { elapsed_ms: startedAt ? Date.now() - startedAt : null }, + }; + emit(envelope, pretty); + logErr(`error[${cli.kind}]: ${cli.message}${cli.taskId ? ` (task ${cli.taskId})` : ''}`); + if (process.env.MESHY_DEBUG && err?.stack) logErr(err.stack); + return KIND_EXIT[cli.kind] ?? EXIT.GENERIC; +} + +// ── Section 8: HTTP core ─────────────────────────────────────────────────── +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function backoffDelay(attempt, retryAfterHeader) { + if (retryAfterHeader) { + const secs = Number(retryAfterHeader); + if (Number.isFinite(secs)) return Math.min(secs * 1000, 30_000); + } + const base = 500 * 2 ** (attempt - 1); + return Math.min(base, 15_000) + Math.floor(((attempt * 137) % 250)); // small deterministic jitter +} + +function buildUrl(baseUrl, p, query) { + const base = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'; + let u = base + p.replace(/^\//, ''); + if (query) { + const qs = Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null && v !== '') + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + if (qs) u += (u.includes('?') ? '&' : '?') + qs; + } + return u; +} + +async function request(method, p, { body, query, ctx, auth = true } = {}) { + const url = buildUrl(ctx.baseUrl, p, query); + let attempt = 0; + for (;;) { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), ctx.requestTimeoutMs); + try { + const headers = { Accept: 'application/json' }; + if (auth) headers.Authorization = `Bearer ${ctx.apiKey}`; + let payload; + if (body !== undefined) { headers['Content-Type'] = 'application/json'; payload = JSON.stringify(body); } + + const res = await fetch(url, { method, headers, body: payload, signal: ac.signal }); + const text = await res.text(); + let data; + try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } + + if (res.ok) return { status: res.status, headers: res.headers, data }; + + const message = data?.message || data?.error || res.statusText || `HTTP ${res.status}`; + if (res.status === 429 && attempt < ctx.maxRetries) { + attempt++; + logErr(`rate-limited (429), retry ${attempt}/${ctx.maxRetries}…`); + await sleep(backoffDelay(attempt, res.headers.get('retry-after'))); + continue; + } + throw new ApiError(res.status, message, { code: data?.code ?? null, docUrl: data?.doc_url ?? null, body: data }); + } catch (e) { + if (e instanceof ApiError) throw e; + const transient = e.name === 'AbortError' || ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED'].includes(e.code); + if (transient && attempt < ctx.maxRetries) { + attempt++; + logErr(`network error (${e.code || e.name}), retry ${attempt}/${ctx.maxRetries}…`); + await sleep(backoffDelay(attempt)); + continue; + } + throw new CliError('network', `request failed: ${e.code || e.name || e.message}`); + } finally { + clearTimeout(timer); + } + } +} + +// ── Section 9: input helpers ─────────────────────────────────────────────── +const LOCAL_IMAGE_FIELDS = ['image_url', 'texture_image_url', 'image_style_url']; + +async function toDataUri(v) { + if (!v || typeof v !== 'string') return v; + if (/^https?:\/\//i.test(v) || /^data:/i.test(v)) return v; + let buf; + try { buf = await readFile(v); } catch (e) { + if (e.code === 'ENOENT') throw usage(`image file not found: ${v}`); + throw e; + } + const ext = (v.split('.').pop() || '').toLowerCase(); + const mime = ext === 'png' ? 'image/png' + : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' + : ext === 'webp' ? 'image/webp' : 'application/octet-stream'; + if (buf.length > 5 * 1024 * 1024) logErr(`warning: ${v} is ${(buf.length / 1048576).toFixed(1)}MB; the API may reject large images`); + return `data:${mime};base64,${buf.toString('base64')}`; +} + +async function resolveImageFields(body) { + if (body.image && !body.image_url) body.image_url = body.image; + delete body.image; + if (body.images && !body.image_urls) body.image_urls = Array.isArray(body.images) ? body.images : [body.images]; + delete body.images; + for (const f of LOCAL_IMAGE_FIELDS) if (typeof body[f] === 'string') body[f] = await toDataUri(body[f]); + if (Array.isArray(body.image_urls)) body.image_urls = await Promise.all(body.image_urls.map(toDataUri)); +} + +// ── Section 10: engine ───────────────────────────────────────────────────── +function basePath(type, opts) { + if (TYPES[type]?.creativeLab) { + const product = opts.get('product') && lastOf(opts.get('product')); + const stage = (opts.get('stage') && lastOf(opts.get('stage'))) || 'prototype'; + if (!product) throw usage('creative-lab requires --product (e.g. keychain|fridge-magnet|figure|lamp)'); + if (!['prototype', 'build'].includes(stage)) throw usage('creative-lab --stage must be prototype or build'); + return `creative-lab/${product}/v1/${stage}`; + } + if (!TYPES[type]) throw usage(`unknown type "${type}"`); + return TYPES[type].path; +} + +async function getBalance(ctx) { + const { data } = await request('GET', 'v1/balance', { ctx }); + return data.balance; +} +async function createTask(base, body, ctx) { + const { data } = await request('POST', base, { body, ctx }); + const id = data.result || data.id || data.task_id; + if (!id) throw new CliError('generic', `create returned no task id: ${JSON.stringify(data).slice(0, 200)}`); + return id; +} +async function getTask(base, id, ctx) { + const { data } = await request('GET', `${base}/${id}`, { ctx }); + return data; +} +async function listTasks(base, query, ctx) { + const { data } = await request('GET', base, { ctx, query }); + if (Array.isArray(data)) return data; + return data.result || data.data || data.tasks || []; +} + +async function pollTask(base, id, ctx, onProgress) { + const deadline = Date.now() + ctx.pollTimeoutMs; + for (;;) { + const task = await getTask(base, id, ctx); + onProgress?.(task); + if (TERMINAL.has(task.status)) { + if (task.status === 'SUCCEEDED') return task; + const te = task.task_error || {}; + throw new CliError('task-failed', te.message || `task ${task.status}`, { + taskId: id, code: te.code ?? null, docUrl: te.doc_url ?? null, + }); + } + if (Date.now() > deadline) { + throw new CliError('timeout', `poll timed out after ${Math.round(ctx.pollTimeoutMs / 1000)}s (last status: ${task.status})`, { taskId: id }); + } + await sleep(ctx.pollIntervalMs); + } +} + +// ── Section 11: download ─────────────────────────────────────────────────── +function extFromUrl(url, fallback) { + try { + const clean = String(url).split('?')[0]; + const ext = clean.split('.').pop(); + return ext && ext.length <= 5 ? ext.toLowerCase() : fallback; + } catch { return fallback; } +} +function sanitizeName(s) { + return String(s).replace(/[^A-Za-z0-9._-]/g, '_').replace(/_+/g, '_').slice(0, 120); +} + +function collectUrls(task, sel) { + const items = []; + const idShort = sanitizeName((task.id || 'model').slice(0, 12)); + + if (task.model_urls && typeof task.model_urls === 'object') { + const want = sel.all ? Object.keys(task.model_urls) : sel.formats; + for (const fmt of want) { + if (!sel.all && fmt.startsWith('pre_remeshed')) continue; + const url = task.model_urls[fmt]; + if (url) items.push({ asset: 'model', format: fmt, url, name: `${idShort}.${fmt}` }); + else items.push({ asset: 'model', format: fmt, url: null, skip: 'not available' }); + } + } + + for (const [k, label] of [ + ['rigged_character_glb_url', 'rigged.glb'], + ['animated_model_glb_url', 'animated.glb'], + ['video_url', 'video.mp4'], + ]) { + if (task[k]) items.push({ asset: label.split('.')[0], format: label.split('.').pop(), url: task[k], name: `${idShort}-${label}` }); + } + + if (Array.isArray(task.image_urls)) { + task.image_urls.forEach((url, i) => { + if (url) { const ext = extFromUrl(url, 'png'); items.push({ asset: 'image', format: ext, url, name: `${idShort}-image-${i}.${ext}` }); } + }); + } + + if (sel.textures && Array.isArray(task.texture_urls)) { + task.texture_urls.forEach((tex, i) => { + for (const [map, url] of Object.entries(tex || {})) { + if (url) { const ext = extFromUrl(url, 'png'); items.push({ asset: 'texture', format: ext, url, name: `${idShort}-tex${i}-${map}.${ext}` }); } + } + }); + } + + if (sel.thumbnail && task.thumbnail_url) { + const ext = extFromUrl(task.thumbnail_url, 'png'); + items.push({ asset: 'thumbnail', format: ext, url: task.thumbnail_url, name: `${idShort}-thumb.${ext}` }); + } + return items; +} + +async function ensureDir(dir) { await mkdir(dir, { recursive: true }); } + +async function downloadOne(url, dest, ctx) { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), Math.max(ctx.requestTimeoutMs, 120_000)); + try { + const res = await fetch(url, { signal: ac.signal }); + if (!res.ok) { const e = new Error(`download HTTP ${res.status}`); e.status = res.status; throw e; } + const tmp = dest + '.part'; + await pipeline(Readable.fromWeb(res.body), createWriteStream(tmp)); + await rename(tmp, dest); + const st = await stat(dest); + return { bytes: st.size }; + } finally { clearTimeout(timer); } +} + +async function downloadAssets(base, task, sel, ctx) { + const outDir = sel.outDir; + await ensureDir(outDir); + const written = [], skipped = [], errors = []; + let refreshed = false; + + for (const it of collectUrls(task, sel)) { + if (!it.url) { skipped.push({ asset: it.asset, format: it.format, reason: it.skip || 'not available' }); continue; } + const dest = path.join(outDir, sanitizeName(it.name)); + try { + const { bytes } = await downloadOne(it.url, dest, ctx); + written.push({ asset: it.asset, format: it.format, path: dest, bytes }); + } catch (e) { + if ([401, 403, 410].includes(e.status) && !refreshed) { + refreshed = true; + logErr('a download URL expired; re-fetching task for fresh URLs…'); + try { + const fresh = await getTask(base, task.id, ctx); + const match = collectUrls(fresh, sel).find((x) => x.name === it.name && x.url); + if (match) { const { bytes } = await downloadOne(match.url, dest, ctx); written.push({ asset: it.asset, format: it.format, path: dest, bytes }); continue; } + } catch { /* fall through to error */ } + errors.push({ asset: it.asset, format: it.format, reason: `expired (${e.status})` }); + } else { + errors.push({ asset: it.asset, format: it.format, reason: e.message || String(e) }); + } + } + } + return { out_dir: outDir, written, skipped, errors }; +} + +function downloadSelection(opts) { + return { + outDir: opts.has('out') ? lastOf(opts.get('out')) : null, + formats: opts.has('formats') ? splitList(opts.get('formats')) : DEFAULTS.formats, + textures: opts.has('textures') || opts.has('all'), + thumbnail: opts.has('thumbnail') || opts.has('all'), + all: opts.has('all'), + }; +} + +// ── Section 12: command handlers ─────────────────────────────────────────── +function progressLogger(label) { + let lastPct = -1; + const started = Date.now(); + return (task) => { + const pct = task.progress ?? 0; + if (task.status === 'IN_PROGRESS' && pct === lastPct) return; + lastPct = pct; + const secs = Math.round((Date.now() - started) / 1000); + logErr(`[${label} ${String(task.id || '').slice(0, 8)} ${task.status} ${pct}% • ${secs}s]`); + }; +} + +async function cmdGenerate(command, opts, ctx, startedAt) { + const alias = COMMAND_ALIASES[command]; + const type = alias ? alias.type : command; + const presets = alias ? alias.presets : undefined; + + const base = basePath(type, opts); + const body = buildBody(type, presets, opts); + await resolveImageFields(body); + checkRequired(type, body); + TYPES[type].validate?.(body); + + const id = await createTask(base, body, ctx); + logErr(`created ${type} task ${id}`); + + if (opts.has('no_wait')) { + emit({ ok: true, command, type, task_id: id, status: 'PENDING', waited: false, meta: meta(ctx, startedAt) }, opts.has('pretty')); + return EXIT.OK; + } + + const task = await pollTask(base, id, ctx, progressLogger(type)); + + const sel = downloadSelection(opts); + let downloads; + if (sel.outDir) downloads = await downloadAssets(base, task, sel, ctx); + else if (hasDownloadableAssets(task)) logErr('tip: pass --out to download assets (URLs expire within ~days)'); + + emit({ + ok: true, command, type, task_id: id, status: task.status, waited: true, + task, downloads, credits_consumed: task.consumed_credits ?? null, meta: meta(ctx, startedAt), + }, opts.has('pretty')); + return EXIT.OK; +} + +function hasDownloadableAssets(task) { + return !!(task.model_urls && Object.values(task.model_urls).some(Boolean)) + || (Array.isArray(task.image_urls) && task.image_urls.length) + || task.rigged_character_glb_url || task.animated_model_glb_url || task.video_url; +} + +function checkRequired(type, body) { + for (const r of TYPES[type].requires || []) { + if (Array.isArray(r)) { + if (!r.some((k) => body[k] != null && body[k] !== '' && !(Array.isArray(body[k]) && body[k].length === 0))) { + throw usage(`${type} requires one of: ${r.map((k) => '--' + k.replace(/_/g, '-')).join(', ')}`); + } + } else if (body[r] == null || body[r] === '') { + throw usage(`${type} requires --${r.replace(/_/g, '-')}`); + } + } +} + +function resolveTypeArg(token) { + if (!token) throw usage('expected a argument (e.g. text-to-3d)'); + if (COMMAND_ALIASES[token]) return COMMAND_ALIASES[token].type; + if (TYPES[token]) return token; + throw usage(`unknown type "${token}"; see "help" for the list`); +} + +async function cmdStatus(positionals, opts, ctx, startedAt) { + const type = resolveTypeArg(positionals[0]); + const id = positionals[1]; + if (!id) throw usage('status requires: status '); + const base = basePath(type, opts); + const task = opts.has('wait') ? await pollTask(base, id, ctx, progressLogger(type)) : await getTask(base, id, ctx); + emit({ ok: true, command: 'status', type, task_id: id, status: task.status, task, meta: meta(ctx, startedAt) }, opts.has('pretty')); + return EXIT.OK; +} + +async function cmdList(positionals, opts, ctx, startedAt) { + const type = resolveTypeArg(positionals[0]); + const base = basePath(type, opts); + const query = { + page_num: opts.has('page_num') ? Number(lastOf(opts.get('page_num'))) : 1, + page_size: opts.has('page_size') ? Number(lastOf(opts.get('page_size'))) : 10, + sort_by: opts.has('sort_by') ? lastOf(opts.get('sort_by')) : '-created_at', + }; + const tasks = await listTasks(base, query, ctx); + emit({ ok: true, command: 'list', type, ...query, count: tasks.length, tasks, meta: meta(ctx, startedAt) }, opts.has('pretty')); + return EXIT.OK; +} + +async function cmdDownload(positionals, opts, ctx, startedAt) { + const type = resolveTypeArg(positionals[0]); + const id = positionals[1]; + if (!id) throw usage('download requires: download --out '); + const sel = downloadSelection(opts); + if (!sel.outDir) throw usage('download requires --out '); + const base = basePath(type, opts); + let task = await getTask(base, id, ctx); + if (!TERMINAL.has(task.status)) task = await pollTask(base, id, ctx, progressLogger(type)); + if (task.status !== 'SUCCEEDED') throw new CliError('task-failed', `task is ${task.status}`, { taskId: id }); + const downloads = await downloadAssets(base, task, sel, ctx); + emit({ ok: true, command: 'download', type, task_id: id, status: task.status, downloads, meta: meta(ctx, startedAt) }, opts.has('pretty')); + return EXIT.OK; +} + +async function cmdBalance(opts, ctx, startedAt) { + const balance = await getBalance(ctx); + emit({ ok: true, command: 'balance', balance, meta: meta(ctx, startedAt) }, opts.has('pretty')); + return EXIT.OK; +} + +const meta = (ctx, startedAt) => ({ elapsed_ms: Date.now() - startedAt, api_base: ctx.baseUrl }); + +// ── help / version ───────────────────────────────────────────────────────── +const COMMAND_CATALOG = [ + { name: 'balance', summary: 'show remaining credits' }, + { name: 'text-to-3d', summary: 'generate a 3D model from a text prompt (mode preview|refine)' }, + { name: 'refine', summary: 'alias: text-to-3d refine (needs --preview-task-id)' }, + { name: 'image-to-3d', summary: 'generate a 3D model from one image (--image-url|--image)' }, + { name: 'multi-image-to-3d', summary: 'generate a 3D model from 1-4 images (--images)' }, + { name: 'retexture', summary: 're-texture an existing model' }, + { name: 'remesh', summary: 'remesh an existing model (topology/polycount/formats)' }, + { name: 'rig', summary: 'alias: rigging — auto-rig a character model' }, + { name: 'animate', summary: 'alias: animation — animate a rigged model' }, + { name: 'text-to-image', summary: 'generate 2D image(s) from a text prompt' }, + { name: 'image-to-image', summary: 'transform an image from a prompt' }, + { name: 'convert', summary: 'convert a model to other formats (--target-formats)' }, + { name: 'resize', summary: 'resize a model (--resize-height/--resize-longest-side/--auto-size)' }, + { name: 'uv-unwrap', summary: 'generate a UV layout for a .glb model' }, + { name: 'analyze-printability', summary: 'analyze a model for 3D printing (free)' }, + { name: 'repair-printability', summary: 'repair a model for 3D printing' }, + { name: 'multi-color-print', summary: 'convert to multi-color 3MF' }, + { name: 'creative-lab', summary: 'EXPERIMENTAL: --product

--stage prototype|build' }, + { name: 'status ', summary: 'fetch a task snapshot (--wait to poll)' }, + { name: 'list ', summary: 'list tasks (--page-num/--page-size/--sort-by)' }, + { name: 'download ', summary: 'download a finished task’s assets (--out required)' }, + { name: 'help [--json]', summary: 'show this help' }, +]; + +function printHelp(wantJson) { + if (wantJson) { + emit({ ok: true, command: 'help', version: VERSION, commands: COMMAND_CATALOG, types: Object.keys(TYPES) }, true); + return; + } + const lines = [ + `meshy.mjs v${VERSION} — Meshy AI REST client`, + '', + 'Usage: node meshy.mjs [options]', + '', + 'Commands:', + ...COMMAND_CATALOG.map((c) => ` ${c.name.padEnd(26)} ${c.summary}`), + '', + 'Common options:', + ' --out

download finished assets to ', + ' --formats glb,fbx model formats to download (default: glb)', + ' --textures --thumbnail also download textures / thumbnail (or --all)', + ' --no-wait create the task and return its id immediately', + ' --wait (status) poll until the task finishes', + ' --json \'{...}\' raw request body (merged under typed flags)', + ' --param key=value set any API field (value parsed as JSON if possible)', + ' --poll-timeout total wait (default 1200000) · --interval (5000)', + ' --api-key override $MESHY_AI_API_KEY / $MESHY_API_KEY', + ' --pretty pretty-print the JSON output', + '', + 'STDOUT is a single JSON object; progress goes to STDERR. Exit codes in the header.', + ]; + process.stdout.write(lines.join('\n') + '\n'); +} + +// ── Section 13: context + dispatch ───────────────────────────────────────── +function buildContext(opts) { + const apiKey = (opts.has('api_key') && lastOf(opts.get('api_key'))) || process.env.MESHY_AI_API_KEY || process.env.MESHY_API_KEY || ''; + const num = (k, d) => (opts.has(k) ? Number(lastOf(opts.get(k))) : d); + return { + apiKey, + baseUrl: (opts.has('base_url') && lastOf(opts.get('base_url'))) || DEFAULTS.baseUrl, + requestTimeoutMs: num('request_timeout', DEFAULTS.requestTimeoutMs), + pollTimeoutMs: num('poll_timeout', DEFAULTS.pollTimeoutMs), + pollIntervalMs: num('interval', DEFAULTS.pollIntervalMs), + maxRetries: num('retries', DEFAULTS.maxRetries), + }; +} + +function requireKey(ctx) { + if (!ctx.apiKey) throw new CliError('auth', 'no API key: set $MESHY_AI_API_KEY (or $MESHY_API_KEY), or pass --api-key'); + if (!/^msy_/.test(ctx.apiKey)) logErr('warning: API key does not start with "msy_"'); +} + +async function main(argv) { + const startedAt = Date.now(); + + // Help/version are handled from raw argv so value-flags like --json need no value here. + if (argv.length === 0 || argv[0] === 'help' || argv.includes('--help') || argv.includes('-h')) { + printHelp(argv.includes('--json')); + return EXIT.OK; + } + if (argv[0] === 'version' || argv.includes('--version')) { process.stdout.write(VERSION + '\n'); return EXIT.OK; } + + let parsed; + try { parsed = parseArgs(argv); } catch (e) { return emitError(e, null, argv.includes('--pretty'), startedAt); } + const { command, positionals, opts } = parsed; + + LOG_QUIET = opts.has('quiet'); + const pretty = opts.has('pretty'); + + let installInterrupt; + try { + const ctx = buildContext(opts); + requireKey(ctx); + + // expose current task id to SIGINT handler for resumable abort + const state = { taskId: null }; + installInterrupt = () => process.once('SIGINT', () => { + process.exitCode = emitError(new CliError('interrupted', 'interrupted', { taskId: state.taskId }), command, pretty, startedAt); + process.exit(process.exitCode); + }); + installInterrupt(); + + if (command === 'balance') return await cmdBalance(opts, ctx, startedAt); + if (command === 'status') return await cmdStatus(positionals, opts, ctx, startedAt); + if (command === 'list') return await cmdList(positionals, opts, ctx, startedAt); + if (command === 'download') return await cmdDownload(positionals, opts, ctx, startedAt); + if (GENERATE_COMMANDS.has(command)) return await cmdGenerate(command, opts, ctx, startedAt); + + throw usage(`unknown command "${command}"; run "help" for the list`); + } catch (e) { + return emitError(e, command, pretty, startedAt); + } +} + +main(process.argv.slice(2)).then((code) => { process.exitCode = code ?? EXIT.OK; }).catch((e) => { + process.stderr.write(`fatal: ${e?.stack || e}\n`); + process.exitCode = EXIT.GENERIC; +}); From 431797a9f328a8ecbd6ecadb93b1488592cb16a4 Mon Sep 17 00:00:00 2001 From: Daedalus Date: Sun, 28 Jun 2026 20:27:19 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=90=9B=20Don't=20retry=20non-idempote?= =?UTF-8?q?nt=20POST=20on=20network=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit request() retried any transient network error or timeout regardless of HTTP method. A createTask POST that reached the server but lost its response would be re-sent, creating a second task and double-charging credits on Meshy's paid API. Gate network/timeout retries to idempotent methods (GET/HEAD); 429 retries stay enabled for all methods since a 429 means the request was not processed. Co-Authored-By: Daedalus Co-Authored-By: Claude Opus 4.8 --- plugins/meshy/skills/meshy/scripts/meshy.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/meshy/skills/meshy/scripts/meshy.mjs b/plugins/meshy/skills/meshy/scripts/meshy.mjs index 2e13240..54bebe2 100644 --- a/plugins/meshy/skills/meshy/scripts/meshy.mjs +++ b/plugins/meshy/skills/meshy/scripts/meshy.mjs @@ -521,8 +521,12 @@ async function request(method, p, { body, query, ctx, auth = true } = {}) { throw new ApiError(res.status, message, { code: data?.code ?? null, docUrl: data?.doc_url ?? null, body: data }); } catch (e) { if (e instanceof ApiError) throw e; + // Only retry network errors/timeouts for idempotent methods. A POST that + // reached the server but lost its response would otherwise be re-sent, + // creating a second task and charging credits twice on the paid API. + const idempotent = method === 'GET' || method === 'HEAD'; const transient = e.name === 'AbortError' || ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED'].includes(e.code); - if (transient && attempt < ctx.maxRetries) { + if (idempotent && transient && attempt < ctx.maxRetries) { attempt++; logErr(`network error (${e.code || e.name}), retry ${attempt}/${ctx.maxRetries}…`); await sleep(backoffDelay(attempt)); From 4fb7a3133467c96993f8b1e3e8b71ea0cfc8ca07 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 28 Jun 2026 20:31:01 +0200 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=90=9B=20meshy:=20refresh=20expired?= =?UTF-8?q?=20download=20URLs=20for=20all=20remaining=20assets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #8: downloadAssets() only re-fetched fresh signed URLs for the first asset that hit 401/403/410; a one-shot 'refreshed' flag then forced every later asset to keep using its stale URL, so they failed even though signed URLs expire together. Build a name->fresh-URL map once on first expiry and reuse it for all remaining assets. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/meshy/skills/meshy/scripts/meshy.mjs | 35 ++++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/plugins/meshy/skills/meshy/scripts/meshy.mjs b/plugins/meshy/skills/meshy/scripts/meshy.mjs index 54bebe2..a745f5c 100644 --- a/plugins/meshy/skills/meshy/scripts/meshy.mjs +++ b/plugins/meshy/skills/meshy/scripts/meshy.mjs @@ -694,23 +694,38 @@ async function downloadAssets(base, task, sel, ctx) { const outDir = sel.outDir; await ensureDir(outDir); const written = [], skipped = [], errors = []; - let refreshed = false; + // Signed asset URLs typically expire together, so on the first 401/403/410 we + // re-fetch the task once and reuse the fresh URLs for EVERY remaining asset + // (not just the one that failed). null until the first expiry triggers a refresh. + let refreshedUrls = null; - for (const it of collectUrls(task, sel)) { + for (const original of collectUrls(task, sel)) { + const it = refreshedUrls?.get(original.name) ?? original; if (!it.url) { skipped.push({ asset: it.asset, format: it.format, reason: it.skip || 'not available' }); continue; } const dest = path.join(outDir, sanitizeName(it.name)); try { const { bytes } = await downloadOne(it.url, dest, ctx); written.push({ asset: it.asset, format: it.format, path: dest, bytes }); } catch (e) { - if ([401, 403, 410].includes(e.status) && !refreshed) { - refreshed = true; - logErr('a download URL expired; re-fetching task for fresh URLs…'); - try { - const fresh = await getTask(base, task.id, ctx); - const match = collectUrls(fresh, sel).find((x) => x.name === it.name && x.url); - if (match) { const { bytes } = await downloadOne(match.url, dest, ctx); written.push({ asset: it.asset, format: it.format, path: dest, bytes }); continue; } - } catch { /* fall through to error */ } + if ([401, 403, 410].includes(e.status)) { + if (!refreshedUrls) { + logErr('a download URL expired; re-fetching task for fresh URLs…'); + try { + const fresh = await getTask(base, task.id, ctx); + refreshedUrls = new Map(collectUrls(fresh, sel).filter((x) => x.url).map((x) => [x.name, x])); + } catch { /* fall through to error */ } + } + const match = refreshedUrls?.get(original.name); + if (match?.url) { + try { + const { bytes } = await downloadOne(match.url, dest, ctx); + written.push({ asset: it.asset, format: it.format, path: dest, bytes }); + continue; + } catch (e2) { + errors.push({ asset: it.asset, format: it.format, reason: `expired, refetch failed (${e2.status || e2.message})` }); + continue; + } + } errors.push({ asset: it.asset, format: it.format, reason: `expired (${e.status})` }); } else { errors.push({ asset: it.asset, format: it.format, reason: e.message || String(e) }); From 2ad90d9dd9572d0a3aa8023c253853a2c62cb4d1 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 28 Jun 2026 20:31:58 +0200 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=90=9B=20meshy:=20populate=20interrup?= =?UTF-8?q?t=20state=20with=20the=20current=20task=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #8: state.taskId was initialized but never assigned, so a Ctrl-C during generation emitted task_id:null and broke the documented resumable-abort path. Thread the state object into cmdGenerate and set state.taskId right after createTask; also set it from the id argument for status/download so an interrupt there is resumable too. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/meshy/skills/meshy/scripts/meshy.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/meshy/skills/meshy/scripts/meshy.mjs b/plugins/meshy/skills/meshy/scripts/meshy.mjs index a745f5c..30be21b 100644 --- a/plugins/meshy/skills/meshy/scripts/meshy.mjs +++ b/plugins/meshy/skills/meshy/scripts/meshy.mjs @@ -758,7 +758,7 @@ function progressLogger(label) { }; } -async function cmdGenerate(command, opts, ctx, startedAt) { +async function cmdGenerate(command, opts, ctx, startedAt, state) { const alias = COMMAND_ALIASES[command]; const type = alias ? alias.type : command; const presets = alias ? alias.presets : undefined; @@ -770,6 +770,7 @@ async function cmdGenerate(command, opts, ctx, startedAt) { TYPES[type].validate?.(body); const id = await createTask(base, body, ctx); + if (state) state.taskId = id; // so SIGINT can emit a resumable task id logErr(`created ${type} task ${id}`); if (opts.has('no_wait')) { @@ -968,10 +969,10 @@ async function main(argv) { installInterrupt(); if (command === 'balance') return await cmdBalance(opts, ctx, startedAt); - if (command === 'status') return await cmdStatus(positionals, opts, ctx, startedAt); + if (command === 'status') { state.taskId = positionals[1] || null; return await cmdStatus(positionals, opts, ctx, startedAt); } if (command === 'list') return await cmdList(positionals, opts, ctx, startedAt); - if (command === 'download') return await cmdDownload(positionals, opts, ctx, startedAt); - if (GENERATE_COMMANDS.has(command)) return await cmdGenerate(command, opts, ctx, startedAt); + if (command === 'download') { state.taskId = positionals[1] || null; return await cmdDownload(positionals, opts, ctx, startedAt); } + if (GENERATE_COMMANDS.has(command)) return await cmdGenerate(command, opts, ctx, startedAt, state); throw usage(`unknown command "${command}"; run "help" for the list`); } catch (e) { From 715cfe65de2d22a67db90bc4667c82a8d74a8f34 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 28 Jun 2026 20:32:28 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=90=9B=20meshy:=20emit=20version=20as?= =?UTF-8?q?=20JSON=20to=20honor=20the=20STDOUT=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #8: the version path wrote a bare string to STDOUT, breaking the 'STDOUT is one parseable JSON object' contract. Emit version via emit() like every other command. help is intentionally left human-readable (a documented exception for the interactive entry point); 'help --json' already provides the machine-readable catalog. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/meshy/skills/meshy/scripts/meshy.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/meshy/skills/meshy/scripts/meshy.mjs b/plugins/meshy/skills/meshy/scripts/meshy.mjs index 30be21b..39245ab 100644 --- a/plugins/meshy/skills/meshy/scripts/meshy.mjs +++ b/plugins/meshy/skills/meshy/scripts/meshy.mjs @@ -946,7 +946,10 @@ async function main(argv) { printHelp(argv.includes('--json')); return EXIT.OK; } - if (argv[0] === 'version' || argv.includes('--version')) { process.stdout.write(VERSION + '\n'); return EXIT.OK; } + if (argv[0] === 'version' || argv.includes('--version')) { + emit({ ok: true, command: 'version', version: VERSION, meta: { elapsed_ms: Date.now() - startedAt } }, argv.includes('--pretty')); + return EXIT.OK; + } let parsed; try { parsed = parseArgs(argv); } catch (e) { return emitError(e, null, argv.includes('--pretty'), startedAt); } From f78973a5a8d9f577733a415ea7b44bb6036b73e0 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 28 Jun 2026 20:33:10 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=90=9B=20meshy:=20validate=20numeric?= =?UTF-8?q?=20control=20flags=20before=20building=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #8: buildContext() ran --poll-timeout/--interval/--request-timeout/--retries through a bare Number(), so a non-numeric value became NaN and silently broke behavior (a NaN poll deadline never times out; a NaN interval busy-loops). Reject non-finite or below-minimum values with a usage error (exit 2) before the context is built. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/meshy/skills/meshy/scripts/meshy.mjs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/plugins/meshy/skills/meshy/scripts/meshy.mjs b/plugins/meshy/skills/meshy/scripts/meshy.mjs index 39245ab..3b4060d 100644 --- a/plugins/meshy/skills/meshy/scripts/meshy.mjs +++ b/plugins/meshy/skills/meshy/scripts/meshy.mjs @@ -922,14 +922,20 @@ function printHelp(wantJson) { // ── Section 13: context + dispatch ───────────────────────────────────────── function buildContext(opts) { const apiKey = (opts.has('api_key') && lastOf(opts.get('api_key'))) || process.env.MESHY_AI_API_KEY || process.env.MESHY_API_KEY || ''; - const num = (k, d) => (opts.has(k) ? Number(lastOf(opts.get(k))) : d); + const num = (k, d, min = 0) => { + if (!opts.has(k)) return d; + const n = Number(lastOf(opts.get(k))); + if (!Number.isFinite(n)) throw usage(`--${k.replace(/_/g, '-')} expects a number`); + if (n < min) throw usage(`--${k.replace(/_/g, '-')} must be >= ${min}`); + return n; + }; return { apiKey, baseUrl: (opts.has('base_url') && lastOf(opts.get('base_url'))) || DEFAULTS.baseUrl, - requestTimeoutMs: num('request_timeout', DEFAULTS.requestTimeoutMs), - pollTimeoutMs: num('poll_timeout', DEFAULTS.pollTimeoutMs), - pollIntervalMs: num('interval', DEFAULTS.pollIntervalMs), - maxRetries: num('retries', DEFAULTS.maxRetries), + requestTimeoutMs: num('request_timeout', DEFAULTS.requestTimeoutMs, 1), + pollTimeoutMs: num('poll_timeout', DEFAULTS.pollTimeoutMs, 1), + pollIntervalMs: num('interval', DEFAULTS.pollIntervalMs, 1), + maxRetries: num('retries', DEFAULTS.maxRetries, 0), }; } From 8bdd272d24a2d5d30d95a5fc92e68d1d286b3c5d Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 28 Jun 2026 20:46:34 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=93=9D=20meshy:=20add=20plugin=20page?= =?UTF-8?q?=20to=20VitePress=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register /plugin-catalog/meshy-plugin in the VitePress sidebar (config.mts) so the Meshy page appears in the Plugin Catalog nav. config.mts only exists on the VitePress main, so this lands now that the branch is rebased onto it. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/.vitepress/config.mts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 18d2e6f..1577b70 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -31,7 +31,8 @@ export default defineConfig({ { text: 'Overview', link: '/plugin-catalog/' }, { text: 'Docs Plugin', link: '/plugin-catalog/docs-plugin' }, { text: 'Git Plugin', link: '/plugin-catalog/git-plugin' }, - { text: 'Resolve CodeRabbit Plugin', link: '/plugin-catalog/resolve-coderabbit-plugin' } + { text: 'Resolve CodeRabbit Plugin', link: '/plugin-catalog/resolve-coderabbit-plugin' }, + { text: 'Meshy Plugin', link: '/plugin-catalog/meshy-plugin' } ] } ],