# nyx — the tools without a browser

`cli/` is the icosahedra.com tools on the command line, for batch work and
for agents. It is built the way `tests/` is built: it **imports each page's own
`*/lib/` modules** and reads the same wasm binaries out of the repo, so nothing
is copied and nothing can drift. Rebuild `font.wasm`, edit `dxt/lib/pack.js`,
and the next CLI run has it.

```sh
./nyx.sh --help                 # the tools
./nyx.sh <tool> --help          # that tool's flags and defaults
./nyx.sh sdf logo.png --chan 3 --size 256
```

## Running it in a browser

`api/nyx.js` is the same router with `fetch` and base64 where `cli/lib/runtime.js`
has `readFile` and `writeFile`. `/cli/` loads it, so an agent with a headless
browser and no shell needs no install at all:

```js
await page.goto('https://icosahedra.com/cli/');
const out = await page.evaluate(`nyx.sdf('${pngBase64}', { chan: 3, size: 256 })`);
// out.file is the PNG, base64; out.ok, out.exitCode, out.stdout, out.stderr and
// out.json (the --json summary, parsed) are what the shell would have given you
```

One call per tool, an option per flag, and `nyx.run` for anything the four miss:

```js
nyx.normals(depth, { all: true, convention: 'dx', smooth: 2 })
nyx.sdf(shape, { chan: 3, size: 256, spread: 8 })
nyx.font(ttf, { mode: 'msdf', em: 48, charset: 'latin' })
nyx.dxt({ a: albedo, b: rough }, { preset: 'ue-orm', psnr: true })
nyx.run('sdf in.png --chan 3', { 'in.png': png })
```

The flat calls hold no flag table of their own: an option becomes `--kebab-case`,
`true` becomes the flag, `false` the `--no-` form, and the tool rejects what it
does not know with the message the shell would have given. The only thing copied
is the four names and the file name each tool's input gets (`depth.png`,
`input.png`, `font.ttf`, `a.png`), which is why `{ 'logo.png': bytes }` is there
for when the name reaches the output — `nyx dxt` names its DDS after its source. They read the tool's spec for two
things: what an array means (`--eq` takes one comma-separated value, `--map`
takes the flag again — the one place a projection of the command line can be
wrong rather than merely unknown), and which options exist, so a wrong one comes
back naming the right ones. `nyx.spec(tool)` hands that table over as data,
which is the only documentation a caller driving the page can actually read.

Inputs are base64, a data: URL, a `Uint8Array` or an `ArrayBuffer`; outputs come
back as base64 unless you pass `{ binary: true }`. Any page or worker can import
it directly — `await import('https://icosahedra.com/api/nyx.js')` — from any
origin, since the wasm resolves against the module's own URL. The first call
fetches the six binaries (~900 KB) and keeps them (`preload()` does it early);
the work runs on the calling thread, so use a worker if there is a UI to keep
alive.

It lives outside `cli/` because that directory stays DOM-free and network-free
(`jsc` has to load all of it), and it is not in `manifest.txt` because an
install already has the files. `tests/scenarios/cli.js` runs a job through it
and compares the bytes with the shell route: that is `browser.parity`.

## Installing it from the site

The whole repo is served, so `cli/` and the wasm are live URLs: there is
nothing to package and no checkout needed. `cli/install.sh` fetches
`cli/manifest.txt` (the import graph, generated by `tests/manifest.js`) and
mirrors the ~37 files it lists, about 1 MB:

```sh
curl -fsSL https://icosahedra.com/cli/install.sh -o install.sh
sh install.sh ./nyx
./nyx/nyx.sh --help
```

`/llms.txt` gives agents both routes — the browser one first, because it needs
nothing installed, and this one second, because it is faster and scriptable —
rather than pointing them at the tool pages, whose only way out is a browser
download. The golden
suite fails when `manifest.txt` and the real import graph disagree, because a
stale manifest is a broken install rather than a cosmetic slip.

`nyx.sh` sits beside `serve.sh` and works from any directory, so a symlink on
`PATH` is the usual way to use it:

```sh
ln -s "$PWD/nyx.sh" /usr/local/bin/nyx
```

Paths you pass are resolved against the directory you are in; the repo root is
passed to the CLI separately so the wasm is found either way.

## The tools

| Tool | Page | Writes |
|---|---|---|
| `nyx normals <depth.png>` | `/normals/` | normal, height and cavity-AO PNGs, 8- or 16-bit |
| `nyx dxt -a <src.png> …` | `/dxt/` | a DDS (BC1/3/4/5/7, mips, sRGB tag) and the packed PNG |
| `nyx sdf <shape.png>` | `/sdf/` | a 1-, 2- or 3-channel distance field PNG |
| `nyx font <font.ttf>` | `/font/` | an SDF/MSDF/MTSDF atlas PNG, the layout JSON, and venus `.font` bytes |

Every tool takes `--json` (a machine-readable summary on stdout; `--json-summary`
in `nyx font`, where `--json` names the layout file) and `--quiet` (no progress
on stderr). Errors go to stderr. **Exit codes: 0 success, 1 usage error, 2
runtime failure.**

## Same output as the page

Each tool's defaults are the page's control values, so a default run writes what
the page's download button writes. That is not a claim in a README: the golden
suite runs each tool end to end and compares the bytes with the same job driven
straight off the page's modules (`tests/scenarios/cli.js`, the `*.parity` checks
— the SDF field, the normal and AO maps, the DDS and the font atlas are
identical, not merely close).

Three things are deliberately different:

- **The PNG container.** The pixels are the page's; the zlib stream is not,
  because nothing off a browser can reproduce a browser's. The CLI deflates with
  `cli/lib/zlib.js` and picks a row filter per row, which lands within a few
  percent of what a browser writes.
- **Resizing.** `nyx dxt --width/--height` resamples with `cli/lib/image.js`; the
  page hands that to the browser's canvas. A run that resizes nothing is exact.
- **AI depth.** `/normals/`'s "Generate depth" is an ONNX/WebGPU runtime in the
  tab. Feed the CLI a depth image instead.

## Input

PNG only, and non-interlaced: any bit depth (1/2/4/8/16), greyscale, RGB,
palette, with or without alpha, `tRNS` in all three forms. There is no canvas
here to decode JPEG or WebP, and no decoder for them is worth carrying. Colour
chunks (`gAMA`, `iCCP`, `sRGB`) are ignored, so a file that carries one can
differ from what a browser shows.

16-bit input keeps its precision in `nyx normals` (through `shared/png.js`
`decodePNG16`, as on the page); elsewhere it is reduced to 8 bits by
`round(v / 257)`.

## Layout

```
nyx.sh              the launcher: finds the repo, runs jsc (or node), maps the exit code
cli/main.js         the entry point
cli/lib/router.js   `nyx <tool> ...` dispatch and the top-level help
cli/lib/runtime.js  the host layer (args, file IO, stdout/stderr, exit) for jsc, node and the tests
cli/lib/args.js     one flag table per tool: the parser and --help cannot disagree
cli/lib/zlib.js     inflate and deflate, because jsc has no (De)CompressionStream
cli/lib/png-read.js the PNG reader (there is no canvas here)
cli/lib/png-filter.js  adaptive row filtering for the writers
cli/lib/pngio.js    PNG in and out, over shared/png.js
cli/lib/image.js    resampling for `nyx dxt --width/--height`
cli/normals.js cli/dxt.js cli/sdf.js cli/font.js    one file per tool
cli/skills/         a SKILL.md per tool, for agents
cli/manifest.txt    every file an install needs; generated, drift-checked by the golden suite
cli/install.sh      fetches the manifest and mirrors those files from the site
cli/lib/manifest.js the import-graph walk behind both
```

A tool never touches the filesystem itself: it takes an `io` object
(`readBinary`, `writeBinary`, `out`, `err`, `exit`), which is how
`tests/scenarios/cli.js` runs whole invocations in memory. `cli/` is otherwise
under the same rules as `*/lib/`: no DOM, no network.

## Runtimes

jsc is the tested path — it ships with macOS, it is what the golden tests use,
and it is the only JS runtime on the dev machine:

```sh
jsc -m cli/main.js -- sdf logo.png      # from the repo root, without the launcher
jsc -m tests/run.js -- cli              # the CLI's golden tests
```

`nyx.sh` falls back to `node` when jsc is missing. The code is plain ES modules
with no dependencies and no `package.json`, so Node needs module detection
(Node 22.7+ does it by itself; the launcher passes
`--experimental-detect-module` for older versions). **The Node path is not
tested here**, because Node is not installed on this machine.

Two jsc details leak through: a missing input file prints jsc's own
`Could not open file: …` line before the CLI's message, and `writeFile` cannot
create a directory, so `--out-dir` has to exist.

## Skills

`cli/skills/nyx-<tool>/SKILL.md` is one skill per tool, written against the real
flags. They are documentation until you install them:

```sh
ln -s "$PWD/cli/skills/nyx-sdf" ~/.claude/skills/nyx-sdf     # and the other three
```
