Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,18 @@ permissions:
contents: read

jobs:
# Build on the minimum supported LTS runtime. The smoke matrix also covers
# the current release line so forward-compatibility regressions surface early.
# Build and test on the minimum supported LTS runtime — the floor declared in
# package.json#engines, not the newest thing that happens to work. The smoke
# matrix also covers the current release line so forward-compatibility
# regressions surface early.
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: 24
node-version: 22

# The enterprise Actions policy only permits GitHub-owned Marketplace
# actions. Corepack reads the pinned pnpm version from package.json.
Expand Down Expand Up @@ -74,7 +76,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node: [24, 26]
node: [22, 24, 26]
steps:
- uses: actions/download-artifact@v8
with:
Expand Down
2 changes: 1 addition & 1 deletion .node-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
24
22
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The flag surface is deliberately curated rather than a 1:1 mirror of the API: de

## Install

Requires Node 24+. No Python, no other runtime.
Requires Node 22.12+ (the active LTS line). No Python, no other runtime.

```bash
npm i -g meshy-cli # installs `meshy-cli` and `meshy`
Expand All @@ -23,7 +23,22 @@ meshy doctor # local diagnosis: versions, credential sources, base U
The same build is also published under the scoped alias
[`@meshy-ai/cli`](https://www.npmjs.com/package/@meshy-ai/cli)
(`npm i -g @meshy-ai/cli`) — identical contents, pick whichever name you
remember; don't install both.
remember.

**Install one, not both.** The two packages declare the same `meshy` and
`meshy-cli` binaries, and npm refuses to relink a binary owned by the other
package, so installing the second one fails with `EEXIST: file already exists`.
To switch, uninstall the one you have first:

```bash
npm uninstall -g @meshy-ai/cli && npm i -g meshy-cli
```

Versions 0.2.0–0.3.1 declared `engines.node: >=24` by mistake. npm silently
resolves an install to the newest version whose `engines` your runtime
satisfies, so `npm i -g meshy-cli` on Node 22 quietly installed **0.1.3** with
no warning. If `meshy --version` reports 0.1.x, that is why — reinstall now
that the floor is correct.

### Development

Expand Down Expand Up @@ -466,10 +481,11 @@ message before any task is created. GLB-only fields (`uv-unwrap`, `rigging`
| `--base-url-v1 <url>` / `--base-url-v2 <url>` | Override endpoints (staging/proxy) |
| `--base-url-creative-lab <url>` | Override the Creative Lab base (default: `<v1 origin>/openapi/creative-lab`) |
| `--output-schema legacy\|v1` | Stdout data model (existing commands default to `legacy`; new commands are `v1`) |
| `--format json\|pretty\|ndjson` | Stdout rendering (default `json`) |
| `--format json\|pretty\|ndjson` | Stdout rendering. Defaults to `pretty` when stdout is a terminal and `json` everywhere else — piped, redirected, or spawned as a subprocess, which is every agent, script and CI run. `--json` is shorthand for `--format json`. `-o <file>` keeps writing JSON unless `--format` is explicit |
| `-o, --output <path>` | Download artifacts to a file/directory (task commands); output file for `mesh prepare-print` |
| `--workspace <dir>` | Confine every written file to this directory: `download`, `-o` on task verbs and `make` (report-only tasks included), `--save-json`, `--project`/project folders and the history index (skipped with `index_dirty` when its root would fall outside), `mesh prepare-print` outputs and their copied materials — checked on real paths before anything, even a directory, is created. The boundary is frozen when the command starts (real path and directory identity): a workspace or project replaced by a symlink while a request is in flight is refused, never followed |
| `--no-update-check` | Skip the background npm version check in this process |
| `NO_COLOR` / `FORCE_COLOR` (env) | Colour is on only when the stream is a terminal. `NO_COLOR` turns it off, `FORCE_COLOR` forces it on (`FORCE_COLOR=0` off), `TERM=dumb` disables it. `json` and `ndjson` are never coloured, and neither is anything written to a file |
| `-v, --verbose` | Debug logging to stderr |
| `--log-level <level>` | `debug \| info \| warn \| error \| silent` |

Expand Down
98 changes: 98 additions & 0 deletions docs/skill-parity/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -734,3 +734,101 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu
by content type. `tests/live-verification.test.ts` (L02) covers both products;
the same tasks re-downloaded live produce `lamp.stl`/`base.stl` (byte-identical
to the mis-named files) and `model.obj.zip`.

## D-061 The supported Node floor is 22.12, and it is one number, not three

- `engines.node` was raised from `>=20` to `>=24` in `7edf056` ("chore: upgrade
Node and dependencies") as a side effect of a dependency bump, not because any
code needed Node 24. Audited on 2026-09-22: the only runtime gate in the tree
was `REQUIRED_NODE_MAJOR = 24` in `doctor.ts`; `@types/node@^22` typechecks
clean and the full suite is 563/563 on Node 22.22.0. The real floor is the
strictest dependency, `commander@15` at `>=22.12.0`.
- The overstated floor was not a warning, it was a silent downgrade. npm resolves
an unpinned install to the newest version whose `engines` the current runtime
satisfies, so `npm i -g meshy-cli` on Node 22 installed **0.1.3** — the last
version declaring `>=20` — with no warning at all. Reproduced against the live
registry. Users then read `meshy --version` as 0.1.3 and reported the CLI as
stale; agents read `engines` and reported it as incompatible.
- `engines.node`, `.node-version`, the CI `check` job and `doctor`'s floor are now
all 22 / 22.12.0, and `tests/version.test.ts` pins `engines.node` to the value
`doctor` enforces so the two cannot drift again. The smoke matrix is
`[22, 24, 26]`: the floor, the current release line, and the next one.
- Reviewer check: `engines.node` must equal the strictest `engines.node` among
`dependencies` — raise it only when a dependency or a used API forces it, and
publish a release at the same time, because every version left behind the floor
is what npm will hand to users below it.

## D-062 Everything user-facing that names the npm package reads it from package.json

- The same tree is published twice, as `meshy-cli` and — after `npm pkg set name`
in `release.yml` — as `@meshy-ai/cli`. Both declare the same `meshy` and
`meshy-cli` bins, and npm refuses to relink a bin owned by another package.
- The update notifier hardcoded `npm i -g meshy-cli@latest`, so an `@meshy-ai/cli`
user who followed its advice got `EEXIST: file already exists` on
`<prefix>/bin/meshy-cli` and no upgrade. Observed 2026-09-22.
- `version.ts` now exports `PACKAGE_NAME` alongside `VERSION` from the same
package.json read, and the notifier derives `REGISTRY_URL`, `UPDATE_COMMAND`
and its message from it — the alias checks and upgrades itself. Both scoped URL
forms (`@meshy-ai/cli/latest` and `@meshy-ai%2Fcli/latest`) return 200.
- Not fixed in code, because it cannot be: installing both packages still
collides. README says install one, and how to switch. Retiring the alias is a
publishing decision, not a code change.

## D-063 `--format` follows the destination: pretty on a TTY, json everywhere else

- Reported 2026-09-22: `meshy balance` typed at a terminal answers with
`{ "balance": 2357 }` spread over three lines. Surveyed the CLIs on the same machine — `gh release
list`, `npm view`, `kubectl config get-contexts`, `docker` all render a human
shape by default and keep the machine shape behind `--json` / `-o json` /
`--format`. `aws` is the counterexample, and it is configurable. A CLI whose
default face is raw JSON braces is the outlier, not the norm.
- The fix is the default, not the contract. `--format` untyped now resolves to
`pretty` when `process.stdout.isTTY` and `json` otherwise. Every agent, script,
pipe, redirect, `$(...)` and CI run reaches the CLI through something that is
not a TTY, so the bytes they read are unchanged; `--format json` / `--json`
still force it, and SKILL.md already told agents to pass it.
- Two traps this had to clear before it was safe:
- commander carried `.default("json")` on the option, which made "not typed"
indistinguishable from `--format json`. The default is gone from the option
and lives in `parseOutputFormat`; `runtime.ts`'s duplicate `normalizeFormat`
was deleted rather than taught the same rule twice.
- legacy `-o <file>` renders through `emit()` with the same format, so a
TTY-derived `pretty` would have silently landed in a file every caller reads
back as JSON. `GlobalFlags.formatExplicit` records whether `--format` was
actually typed; an untyped format writes JSON to a file whatever the
terminal would have shown. `--save-json` was never affected — it has its own
writer. Both branches are covered in `tests/output.test.ts`.
- Side effect, and the point: the update notifier's two channels finally
separate. `attachUpdateNotice` already skipped `pretty`, so a human now gets
one stderr line instead of a `_notice` blob inside their output *and* the line;
a pipe still carries `_notice` in the JSON.
- Ceiling: `renderPretty` is a recursive `key: value` dump. Right for `balance`
and `doctor`, thin for a 30-field task. Reach for a real table renderer when
someone complains about a specific command, not before.
- Reviewer check: anything that writes to a file or is consumed by a machine
must not read `flags.format` without also honouring `flags.formatExplicit`.

## D-064 Colour is a property of the stream, and the machine formats never have it

- Follows D-063: once `pretty` is what a person actually sees, the output should
look like the CLIs it sits next to. Added in `src/internal/color.ts`, ~50 lines
and no dependency — four SGR codes do not justify one.
- The decision table, in order: `FORCE_COLOR` (on, unless `0`), then `NO_COLOR`
(off), then `TERM=dumb` (off), then whether the stream is a TTY. Both env vars
are the cross-ecosystem conventions and users expect them to work here too.
- Painted against the stream the text is going to, never a global flag:
- stdout, `pretty` only — `meshy doctor` in a terminal;
- stderr for the `error:` / `hint:` lines and the update hint, so
`meshy ... | jq` still shows a red error while `2> log` stays clean;
- `json` / `ndjson` take the painter and ignore it, pinned by a test — this is
the one that would silently corrupt every agent reading stdout;
- anything written to a file renders unpainted, because `--format pretty -o
notes.txt` must not put control codes on disk. `render()` therefore defaults
to the plain painter and only the two stdout call sites opt in.
- Palette, deliberately small: keys dim, `null` dim, and whole-value state words
(`ok`/`SUCCEEDED`/`true` green, `FAILED`/`error`/`false` red,
`PENDING`/`skipped`/`IN_PROGRESS` yellow). Matched on the entire value, case
insensitively, so a prompt reading "a failed robot" is never repainted.
- Reviewer check: a new writer must pass the painter for *its own* destination.
`painterFor(process.stdout)` in something that writes to stderr or a file is
the bug this table exists to prevent.
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "meshy-cli",
"version": "0.3.1",
"version": "0.3.2",
"description": "Official command-line interface for the Meshy AI API — text-to-3D, image-to-3D, text-to-motion, remesh, UV unwrap, rigging, animate, retexture, 2D images, multi-color print, Creative Lab, balance — plus selective downloads, project folders, face checks, OBJ print preparation and slicer launch.",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -40,7 +40,7 @@
"skills"
],
"engines": {
"node": ">=24"
"node": ">=22.12.0"
},
"scripts": {
"build": "tsc",
Expand All @@ -59,7 +59,7 @@
"zod": "^4.5.2"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/node": "^22.20.4",
"tsx": "^4.23.12",
"typescript": "^7.0.2"
}
Expand Down
24 changes: 12 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions skills/meshy-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name: meshy-cli
description: "Generate 3D models, motion clips, 2D images and Creative Lab print products with the Meshy API through the meshy-cli command — text-to-3D, image-to-3D, text-to-motion, remesh, UV unwrap, rigging, animation, retexture, printability, selective downloads, project folders, face checks, OBJ print preparation and slicer launch. Use for any Meshy asset or 3D-printing request."
license: MIT
compatibility: Requires meshy-cli on PATH (Node 24+, no Python) and a stored credential, MESHY_API_KEY or --api-key-file for API commands; network access to api.meshy.ai. Local helpers work offline.
compatibility: Requires meshy-cli on PATH (Node 22.12+, no Python) and a stored credential, MESHY_API_KEY or --api-key-file for API commands; network access to api.meshy.ai. Local helpers work offline.
metadata:
version: "1.1.0"
cli-help: "meshy --help"
Expand All @@ -22,7 +22,12 @@ network; `meshy doctor --check-api` makes one free balance call.

## Always ask for the stable envelope

Add `--output-schema v1 --format json` to every command you parse. stdout is then
Add `--output-schema v1 --format json` to every command you parse. `--format` is
not optional politeness: untyped, it renders `pretty` for a human at a terminal
and `json` everywhere else. You will almost always be on the `json` side — a
subprocess pipe is not a TTY — but say it and the shape is yours regardless of
how you are spawned. `json` and `ndjson` never carry terminal colour codes, so
you never need to strip escapes. stdout is then
exactly one JSON object with six keys — `schema_version, command, ok, result,
error, warnings` — and nothing else; progress goes to stderr. `ok` is whether the
CLI operation completed; `result.task.status` is the server's task state (a `get`
Expand Down
64 changes: 64 additions & 0 deletions src/internal/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* ANSI colour for human output.
*
* Colour is a property of the destination, not of the command: it is on only
* when the stream is a terminal, and the environment can always veto. The two
* conventions every CLI is expected to honour are NO_COLOR (https://no-color.org)
* and FORCE_COLOR; `TERM=dumb` is the third, for terminals that cannot render
* escapes at all.
*
* `json` and `ndjson` output never passes through here — a machine reading
* stdout must never receive an escape sequence. Only `pretty` and the human
* prose on stderr are painted.
*
* No dependency: four SGR codes do not justify one.
*/

export type Style = "dim" | "bold" | "red" | "green" | "yellow" | "cyan";

const CODES: Record<Style, string> = {
dim: "2",
bold: "1",
red: "31",
green: "32",
yellow: "33",
cyan: "36",
};

export interface ColorEnv {
NO_COLOR?: string;
FORCE_COLOR?: string;
TERM?: string;
[key: string]: string | undefined;
}

/**
* FORCE_COLOR wins (except when set to "0"), then NO_COLOR, then a dumb
* terminal, then whether the stream is actually a TTY.
*/
export function colorEnabled(
stream: { isTTY?: boolean } = process.stdout,
env: ColorEnv = process.env,
): boolean {
const force = env["FORCE_COLOR"];
if (force !== undefined && force !== "" && force !== "0") return true;
if (force === "0") return false;
if (env["NO_COLOR"] !== undefined && env["NO_COLOR"] !== "") return false;
if (env["TERM"] === "dumb") return false;
return Boolean(stream.isTTY);
}

/** A painter that applies a style, or the identity when colour is off. */
export type Painter = (text: string, style: Style) => string;

export const plain: Painter = (text) => text;

export const painted: Painter = (text, style) =>
text === "" ? text : `\u001b[${CODES[style]}m${text}\u001b[0m`;

export function painterFor(
stream: { isTTY?: boolean } = process.stdout,
env: ColorEnv = process.env,
): Painter {
return colorEnabled(stream, env) ? painted : plain;
}
7 changes: 6 additions & 1 deletion src/internal/command-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ export async function emitResult(
await emitEnvelope(okEnvelope(opened.command, v1Result, opts.warnings ?? []), format);
return;
}
emit(legacyValue, { format, file: opts.legacyFile });
// Legacy `-o <file>` writes the payload to disk instead of stdout. The TTY
// default describes a terminal, not a file, so an untyped --format must not
// leak `pretty` into what callers have always read back as JSON.
const fileFormat =
opts.legacyFile && !opened.flags.formatExplicit && opts.format === undefined ? "json" : format;
emit(legacyValue, { format: fileFormat, file: opts.legacyFile });
}

/** v1: `-o` is reserved for assets; JSON goes through --save-json. */
Expand Down
Loading
Loading