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/.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' } ] } ], 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..3b4060d --- /dev/null +++ b/plugins/meshy/skills/meshy/scripts/meshy.mjs @@ -0,0 +1,995 @@ +#!/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; + // 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 (idempotent && 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 = []; + // 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 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)) { + 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) }); + } + } + } + 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, state) { + 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); + if (state) state.taskId = id; // so SIGINT can emit a resumable task id + 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, 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, 1), + pollTimeoutMs: num('poll_timeout', DEFAULTS.pollTimeoutMs, 1), + pollIntervalMs: num('interval', DEFAULTS.pollIntervalMs, 1), + maxRetries: num('retries', DEFAULTS.maxRetries, 0), + }; +} + +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')) { + 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); } + 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') { 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') { 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) { + 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; +});