shadertown 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/README.md +13 -2
- package/cli/cli.mjs +17 -63
- package/cli/mcp.mjs +438 -0
- package/cli/shared.mjs +92 -0
- package/dist/generated/sources.d.ts +75 -45
- package/dist/generated/sources.d.ts.map +1 -1
- package/dist/generated/sources.js +75 -45
- package/dist/generated/sources.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +41 -16
- package/dist/react.js.map +1 -1
- package/dist/registry.d.ts +2565 -648
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1078 -136
- package/dist/registry.js.map +1 -1
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +22 -2
- package/dist/source.js.map +1 -1
- package/dist/types.d.ts +22 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,78 @@ deploys, not by this file.
|
|
|
6
6
|
This project follows [semantic versioning](https://semver.org). Before 1.0 the
|
|
7
7
|
minor number carries breaking changes, so pin it if that matters to you.
|
|
8
8
|
|
|
9
|
+
## 0.7.0 — 2026-09-01
|
|
10
|
+
|
|
11
|
+
Fifteen shaders that draw places instead of patterns, in two new families.
|
|
12
|
+
The `pixel` family is real pixel art — every cell a whole number of device
|
|
13
|
+
pixels, every colour one of a handful of stops, every move a whole cell on a
|
|
14
|
+
sprite-sheet clock. The `scene` family paints islands, water and skies at
|
|
15
|
+
hero quality: a marched ocean, rolling grassland, an atoll from above.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Five `pixel` shaders.** `pixel-sea` is a 16-bit seascape with a dithered
|
|
20
|
+
sky and the sun's reflection breaking into dashes; `pixel-isle` generates
|
|
21
|
+
one to three islands per seed, one hard biome per cell; `pixel-clouds`
|
|
22
|
+
marches terraced clouds past a hard sun; `pixel-fire` reproduces the Doom
|
|
23
|
+
fire's statistics without its feedback buffer; `pixel-rain` rains on a
|
|
24
|
+
sleeping skyline, one lit window at a time. All five share
|
|
25
|
+
`st_pixelSize()`, a new prelude helper that keeps every virtual cell a
|
|
26
|
+
whole number of device pixels.
|
|
27
|
+
- **Ten `scene` shaders.** Islands: `lagoon` (an atoll from above),
|
|
28
|
+
`archipelago` (a nautical chart on a slow drift), `island` (sea to the
|
|
29
|
+
horizon, land in the haze), `shore` (the swash zone from above), and
|
|
30
|
+
`caldera` (a volcano at night, lit by its own veins). Heroes: `meadow`
|
|
31
|
+
(a silhouette flower field at golden hour), `blossom` (one flower, drawn
|
|
32
|
+
large, with a true phyllotaxis seed head), `hills` (grassland marched as a
|
|
33
|
+
heightfield), `swell` (open ocean marched so waves hide waves), and
|
|
34
|
+
`cumulus` (fair-weather cloud shaded by one extra sample).
|
|
35
|
+
- **Colour presets.** A shader may declare `presets` — curated colour trios
|
|
36
|
+
with names. All fifteen new shaders ship four each. On the site, Randomise
|
|
37
|
+
picks between a shader's presets instead of rolling hues, and the picker
|
|
38
|
+
offers them as one-click starting points; every well stays individually
|
|
39
|
+
editable, and the share URL still carries plain colours.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **A canvas compiles when it is nearly in view.** `ShaderCanvas` used to
|
|
44
|
+
build its renderer the moment it mounted, so a page of cards paid for every
|
|
45
|
+
pipeline on load whether or not the reader ever scrolled that far. It now
|
|
46
|
+
waits until the canvas is within a screen of the viewport, and holds the
|
|
47
|
+
renderer once built — scrolling back over a card redraws it rather than
|
|
48
|
+
recompiling. `play` is unchanged and still decides when a built renderer
|
|
49
|
+
animates. A browser with no `IntersectionObserver` starts every canvas at
|
|
50
|
+
once, as before.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- **Lazy canvases start in the right play state.** A canvas with
|
|
55
|
+
`play="always"` no longer draws one frozen frame when it first comes near the
|
|
56
|
+
viewport. Hover, visible, and reduced-motion modes keep their existing
|
|
57
|
+
behaviour.
|
|
58
|
+
- **One request per source image, however many canvases read it.** A grid
|
|
59
|
+
whose shaders share a photograph fetched that photograph once per canvas,
|
|
60
|
+
all in flight together and none of them able to use the others' response.
|
|
61
|
+
The fetch is now shared per URL; each canvas still decodes its own bitmap.
|
|
62
|
+
|
|
63
|
+
## 0.6.0 — 2026-08-30
|
|
64
|
+
|
|
65
|
+
The CLI learns to talk to coding agents. `npx shadertown mcp` serves the
|
|
66
|
+
catalogue over the Model Context Protocol, so an agent can search shaders,
|
|
67
|
+
read every control with its range and meaning, and vendor tuned source into
|
|
68
|
+
the project it is working on — through the same registry route and the same
|
|
69
|
+
licence rules as `shadertown add`.
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
|
|
73
|
+
- **`shadertown mcp`.** A dependency-free MCP server over stdio with four
|
|
74
|
+
tools: `search_shaders`, `get_shader`, `install_shader`, and
|
|
75
|
+
`account_status`. Discovery and control schemas are answered locally from
|
|
76
|
+
the registry this package already ships; installing goes through
|
|
77
|
+
`/r/<slug>.json`, so Free shaders vendor for anyone and Professional
|
|
78
|
+
source asks for the paid plan exactly as the CLI does. Connect an agent
|
|
79
|
+
with `npx add-mcp "npx -y shadertown@latest mcp" -n shadertown`.
|
|
80
|
+
|
|
9
81
|
## 0.5.0 — 2026-08-30
|
|
10
82
|
|
|
11
83
|
Three shaders that remember: a `compute` kind whose state lives in GPU
|
package/README.md
CHANGED
|
@@ -27,10 +27,21 @@ repository, as files you own:
|
|
|
27
27
|
npx shadertown add aurora
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
## Give it to your coding agent
|
|
31
|
+
|
|
32
|
+
The CLI is also an MCP server. One command configures Claude Code, Cursor,
|
|
33
|
+
VS Code, Windsurf, Codex, and friends, and the agent can search the
|
|
34
|
+
catalogue, read every control, and vendor tuned source into the project on
|
|
35
|
+
its own:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx add-mcp "npx -y shadertown@latest mcp" -n shadertown
|
|
39
|
+
```
|
|
40
|
+
|
|
30
41
|
## Licensing
|
|
31
42
|
|
|
32
|
-
This package is MIT, and it draws all
|
|
33
|
-
readable source:
|
|
43
|
+
This package is MIT, and it draws all 60 shaders. What a licence buys is the
|
|
44
|
+
readable source: 15 shaders hand theirs to anyone, the other 45 need
|
|
34
45
|
[Professional](https://www.shadertown.com/pricing). Source you have already
|
|
35
46
|
vendored keeps working either way.
|
|
36
47
|
|
package/cli/cli.mjs
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile,
|
|
1
|
+
import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
3
|
import { spawn } from "node:child_process";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
};
|
|
4
|
+
import {
|
|
5
|
+
c,
|
|
6
|
+
CREDENTIALS_PATH,
|
|
7
|
+
DEFAULT_OUT,
|
|
8
|
+
DEFAULT_REGISTRY,
|
|
9
|
+
exists,
|
|
10
|
+
readProjectConfig,
|
|
11
|
+
readToken,
|
|
12
|
+
relative,
|
|
13
|
+
writeToken,
|
|
14
|
+
} from "./shared.mjs";
|
|
17
15
|
|
|
18
16
|
export async function run(argv) {
|
|
19
17
|
const [command, ...rest] = argv;
|
|
@@ -30,6 +28,10 @@ export async function run(argv) {
|
|
|
30
28
|
return logout();
|
|
31
29
|
case "whoami":
|
|
32
30
|
return whoami();
|
|
31
|
+
// Imported when asked for: the MCP server is the rare path, and `add`
|
|
32
|
+
// should not pay for it at startup.
|
|
33
|
+
case "mcp":
|
|
34
|
+
return (await import("./mcp.mjs")).serve();
|
|
33
35
|
case undefined:
|
|
34
36
|
case "help":
|
|
35
37
|
case "--help":
|
|
@@ -63,42 +65,6 @@ function parseFlags(args) {
|
|
|
63
65
|
return { flags, positional };
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
/** Reads shadertown.config.ts without executing it - only the fields we need. */
|
|
67
|
-
async function readProjectConfig(cwd) {
|
|
68
|
-
for (const file of ["shadertown.config.ts", "shadertown.config.js", "shadertown.config.mjs"]) {
|
|
69
|
-
try {
|
|
70
|
-
const source = await readFile(join(cwd, file), "utf8");
|
|
71
|
-
const pick = (key) => source.match(new RegExp(`${key}\\s*:\\s*["\'\`]([^"\'\`]+)`))?.[1];
|
|
72
|
-
return { outDir: pick("outDir"), registry: pick("registry"), license: pick("license") };
|
|
73
|
-
} catch {
|
|
74
|
-
// Try the next candidate.
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return {};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// --- credentials ----------------------------------------------------------
|
|
81
|
-
|
|
82
|
-
const CREDENTIALS_PATH = join(homedir(), ".shadertown", "credentials.json");
|
|
83
|
-
|
|
84
|
-
async function readToken() {
|
|
85
|
-
// An explicit environment variable wins, so CI needs no interactive login.
|
|
86
|
-
if (process.env.SHADERTOWN_TOKEN) return process.env.SHADERTOWN_TOKEN;
|
|
87
|
-
try {
|
|
88
|
-
const raw = await readFile(CREDENTIALS_PATH, "utf8");
|
|
89
|
-
return JSON.parse(raw).token ?? undefined;
|
|
90
|
-
} catch {
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function writeToken(token, licensee, registry) {
|
|
96
|
-
await mkdir(dirname(CREDENTIALS_PATH), { recursive: true });
|
|
97
|
-
await writeFile(CREDENTIALS_PATH, JSON.stringify({ token, licensee, registry }, null, 2));
|
|
98
|
-
// The token is a bearer credential; keep it off other accounts on the box.
|
|
99
|
-
await chmod(CREDENTIALS_PATH, 0o600);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
68
|
function openBrowser(url) {
|
|
103
69
|
const command =
|
|
104
70
|
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
@@ -198,15 +164,6 @@ async function fetchJson(url, key) {
|
|
|
198
164
|
return response.json();
|
|
199
165
|
}
|
|
200
166
|
|
|
201
|
-
async function exists(path) {
|
|
202
|
-
try {
|
|
203
|
-
await access(path);
|
|
204
|
-
return true;
|
|
205
|
-
} catch {
|
|
206
|
-
return false;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
167
|
async function add(args) {
|
|
211
168
|
const { flags, positional } = parseFlags(args);
|
|
212
169
|
if (positional.length === 0) {
|
|
@@ -302,10 +259,6 @@ export default defineShadertownConfig({
|
|
|
302
259
|
console.log(`\n ${c.green("add")} shadertown.config.ts\n`);
|
|
303
260
|
}
|
|
304
261
|
|
|
305
|
-
function relative(from, to) {
|
|
306
|
-
return to.startsWith(from) ? to.slice(from.length + 1) : to;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
262
|
function help() {
|
|
310
263
|
console.log(`
|
|
311
264
|
${c.bold("shadertown")} - vendor WebGPU shaders into your codebase
|
|
@@ -320,6 +273,7 @@ function help() {
|
|
|
320
273
|
add <slug...> Write a shader's WGSL and typed preset into your repo
|
|
321
274
|
list Print the catalogue
|
|
322
275
|
init Create shadertown.config.ts
|
|
276
|
+
mcp Serve the catalogue to a coding agent over MCP (stdio)
|
|
323
277
|
|
|
324
278
|
${c.dim("Options")}
|
|
325
279
|
--out <dir> Output directory ${c.dim(`(default: ${DEFAULT_OUT})`)}
|
package/cli/mcp.mjs
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { shaderNames, shaderRegistry, isShaderName, shaderKind } from "../dist/registry.js";
|
|
5
|
+
import { encodeParams } from "../dist/params.js";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_OUT,
|
|
8
|
+
DEFAULT_REGISTRY,
|
|
9
|
+
decodeTokenPayload,
|
|
10
|
+
exists,
|
|
11
|
+
readCredentials,
|
|
12
|
+
readProjectConfig,
|
|
13
|
+
readToken,
|
|
14
|
+
relative,
|
|
15
|
+
} from "./shared.mjs";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The MCP server: `npx shadertown mcp`, speaking JSON-RPC over stdio.
|
|
19
|
+
*
|
|
20
|
+
* It is a different door into the same building. Discovery and the control
|
|
21
|
+
* schemas are answered from the registry module this package already ships,
|
|
22
|
+
* so they need no network; installing goes through the same `/r/<slug>.json`
|
|
23
|
+
* route as `shadertown add`, so the paywall and the licence issuance cannot
|
|
24
|
+
* be different for an agent than for a person.
|
|
25
|
+
*
|
|
26
|
+
* Hand-rolled rather than pulling in an SDK: the protocol surface this server
|
|
27
|
+
* needs is four methods, and the CLI's one promise is that it stays
|
|
28
|
+
* dependency-free.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const PROTOCOL_VERSIONS = new Set(["2024-11-05", "2025-03-26", "2025-06-18"]);
|
|
32
|
+
const LATEST_PROTOCOL = "2025-06-18";
|
|
33
|
+
|
|
34
|
+
// stdout is the protocol channel, so anything human goes to stderr.
|
|
35
|
+
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
36
|
+
|
|
37
|
+
export async function serve() {
|
|
38
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
39
|
+
|
|
40
|
+
rl.on("line", (line) => {
|
|
41
|
+
if (!line.trim()) return;
|
|
42
|
+
let message;
|
|
43
|
+
try {
|
|
44
|
+
message = JSON.parse(line);
|
|
45
|
+
} catch {
|
|
46
|
+
send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
for (const single of Array.isArray(message) ? message : [message]) {
|
|
50
|
+
void handle(single);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Serve until the client hangs up.
|
|
55
|
+
await new Promise((done) => rl.on("close", done));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function handle(message) {
|
|
59
|
+
const { id, method, params } = message ?? {};
|
|
60
|
+
|
|
61
|
+
// Notifications get no reply, whatever they say.
|
|
62
|
+
if (id === undefined || id === null) return;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
switch (method) {
|
|
66
|
+
case "initialize": {
|
|
67
|
+
const requested = params?.protocolVersion;
|
|
68
|
+
return send({
|
|
69
|
+
jsonrpc: "2.0",
|
|
70
|
+
id,
|
|
71
|
+
result: {
|
|
72
|
+
protocolVersion: PROTOCOL_VERSIONS.has(requested) ? requested : LATEST_PROTOCOL,
|
|
73
|
+
capabilities: { tools: {} },
|
|
74
|
+
serverInfo: { name: "shadertown", version: await packageVersion() },
|
|
75
|
+
instructions: [
|
|
76
|
+
`shadertown is a catalogue of ${shaderNames.length} production WebGPU shaders: animated backgrounds and image effects with typed parameters.`,
|
|
77
|
+
"The flow: `search_shaders` to find candidates, `get_shader` for a shader's controls and usage, `install_shader` to vendor its source into this project.",
|
|
78
|
+
"Free-tier shaders install for anyone. Professional source needs the account this machine signed in with (`npx shadertown login`) to hold a paid plan.",
|
|
79
|
+
].join(" "),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
case "ping":
|
|
84
|
+
return send({ jsonrpc: "2.0", id, result: {} });
|
|
85
|
+
case "tools/list":
|
|
86
|
+
return send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
|
|
87
|
+
case "tools/call":
|
|
88
|
+
return send({ jsonrpc: "2.0", id, result: await callTool(params?.name, params?.arguments ?? {}) });
|
|
89
|
+
default:
|
|
90
|
+
return send({
|
|
91
|
+
jsonrpc: "2.0",
|
|
92
|
+
id,
|
|
93
|
+
error: { code: -32601, message: `Method not found: ${method}` },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// A tool that throws is a failed call, not a broken server.
|
|
98
|
+
send({
|
|
99
|
+
jsonrpc: "2.0",
|
|
100
|
+
id,
|
|
101
|
+
error: { code: -32603, message: error instanceof Error ? error.message : String(error) },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function packageVersion() {
|
|
107
|
+
const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
108
|
+
return pkg.version;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- tools -----------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
const TOOLS = [
|
|
114
|
+
{
|
|
115
|
+
name: "search_shaders",
|
|
116
|
+
description:
|
|
117
|
+
"Search the shadertown catalogue of WebGPU shaders (animated backgrounds and image effects). Matches name, tagline, description, family, and tags. Call with no query to list the whole catalogue. Returns slugs for use with get_shader and install_shader.",
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: "object",
|
|
120
|
+
properties: {
|
|
121
|
+
query: {
|
|
122
|
+
type: "string",
|
|
123
|
+
description:
|
|
124
|
+
"Free-text filter, e.g. \"gradient\", \"pointer\", \"dark hero\". Omit to list everything.",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "get_shader",
|
|
131
|
+
description:
|
|
132
|
+
"Everything about one shader: what it looks like, every tunable control with its range, default and meaning, the colours it takes, framework usage, and a browser URL where a human can tune it live. Call this before installing or customizing.",
|
|
133
|
+
inputSchema: {
|
|
134
|
+
type: "object",
|
|
135
|
+
properties: {
|
|
136
|
+
slug: { type: "string", description: "The shader's slug, from search_shaders." },
|
|
137
|
+
},
|
|
138
|
+
required: ["slug"],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: "install_shader",
|
|
143
|
+
description:
|
|
144
|
+
"Vendor a shader into this project: writes the complete WGSL, a typed preset pinned to the params you pass, a ready-to-paste example, a README, and the LICENSE. Free-tier shaders install for anyone; Professional ones need the signed-in account to hold a paid plan (the call explains how when it applies).",
|
|
145
|
+
inputSchema: {
|
|
146
|
+
type: "object",
|
|
147
|
+
properties: {
|
|
148
|
+
slug: { type: "string", description: "The shader's slug, from search_shaders." },
|
|
149
|
+
params: {
|
|
150
|
+
type: "object",
|
|
151
|
+
description:
|
|
152
|
+
"Tuned values to pin into the preset, keyed by control name from get_shader. Numbers for controls, hex strings for colorA/colorB/colorC. Omitted controls keep their defaults.",
|
|
153
|
+
additionalProperties: true,
|
|
154
|
+
},
|
|
155
|
+
directory: {
|
|
156
|
+
type: "string",
|
|
157
|
+
description: `Where to write, relative to the project root. Defaults to the shadertown.config outDir, or "${DEFAULT_OUT}".`,
|
|
158
|
+
},
|
|
159
|
+
force: {
|
|
160
|
+
type: "boolean",
|
|
161
|
+
description: "Overwrite files that already exist. Off by default.",
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
required: ["slug"],
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "account_status",
|
|
169
|
+
description:
|
|
170
|
+
"Who this machine is signed in to shadertown as, on which plan, and what that unlocks. Use it to explain a licence error or to check before installing a Professional shader.",
|
|
171
|
+
inputSchema: { type: "object", properties: {} },
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
async function callTool(name, args) {
|
|
176
|
+
switch (name) {
|
|
177
|
+
case "search_shaders":
|
|
178
|
+
return searchShaders(args);
|
|
179
|
+
case "get_shader":
|
|
180
|
+
return getShader(args);
|
|
181
|
+
case "install_shader":
|
|
182
|
+
return installShader(args);
|
|
183
|
+
case "account_status":
|
|
184
|
+
return accountStatus();
|
|
185
|
+
default:
|
|
186
|
+
return failure(`No tool named "${name}".`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const success = (text) => ({ content: [{ type: "text", text }] });
|
|
191
|
+
const failure = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
192
|
+
|
|
193
|
+
async function registryBase() {
|
|
194
|
+
const config = await readProjectConfig(process.cwd());
|
|
195
|
+
return config.registry ?? DEFAULT_REGISTRY;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Which shaders are free is a pricing decision, and pricing lives on the
|
|
200
|
+
* site, not in this package. Asked once per process; when the network is
|
|
201
|
+
* away, tiers are reported as unknown rather than guessed.
|
|
202
|
+
*/
|
|
203
|
+
let tierPromise;
|
|
204
|
+
function tierMap(registry) {
|
|
205
|
+
tierPromise ??= (async () => {
|
|
206
|
+
try {
|
|
207
|
+
const response = await fetch(new URL("/r/", registry));
|
|
208
|
+
if (!response.ok) return null;
|
|
209
|
+
const index = await response.json();
|
|
210
|
+
return new Map(index.items.map((item) => [item.slug, item.tier]));
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
})();
|
|
215
|
+
return tierPromise;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function searchShaders({ query }) {
|
|
219
|
+
const registry = await registryBase();
|
|
220
|
+
const tiers = await tierMap(registry);
|
|
221
|
+
|
|
222
|
+
const needle = (query ?? "").trim().toLowerCase();
|
|
223
|
+
const matches = shaderNames.filter((name) => {
|
|
224
|
+
if (!needle) return true;
|
|
225
|
+
const def = shaderRegistry[name];
|
|
226
|
+
const haystack = [name, def.name, def.tagline, def.description, def.family, ...def.tags]
|
|
227
|
+
.join(" ")
|
|
228
|
+
.toLowerCase();
|
|
229
|
+
return needle.split(/\s+/).every((word) => haystack.includes(word));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (matches.length === 0) {
|
|
233
|
+
return success(
|
|
234
|
+
`No shader matches "${query}". Families to try: ${[...new Set(shaderNames.map((n) => shaderRegistry[n].family))].join(", ")}. Call search_shaders without a query for the full catalogue.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const lines = matches.map((name) => {
|
|
239
|
+
const def = shaderRegistry[name];
|
|
240
|
+
const tier = tiers?.get(name);
|
|
241
|
+
const badge = tier === "free" ? " [free]" : tier === "professional" ? " [pro]" : "";
|
|
242
|
+
return `- ${name}${badge} — ${def.name}: ${def.tagline} (${def.family}, ${def.cost} cost; tags: ${def.tags.join(", ")})`;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const footer = tiers
|
|
246
|
+
? "\n[free] installs for anyone; [pro] source needs a paid plan (browsing and tuning are always free)."
|
|
247
|
+
: "\nTiers unavailable offline: some of these need a paid plan for their source.";
|
|
248
|
+
|
|
249
|
+
return success(
|
|
250
|
+
`${matches.length} of ${shaderNames.length} shaders${needle ? ` match "${query}"` : ""}:\n${lines.join("\n")}\n${footer}\nNext: get_shader for controls, install_shader to vendor one.`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function getShader({ slug }) {
|
|
255
|
+
if (!isShaderName(slug)) {
|
|
256
|
+
return failure(`No shader named "${slug}". Use search_shaders to find the right slug.`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const registry = await registryBase();
|
|
260
|
+
const def = shaderRegistry[slug];
|
|
261
|
+
const kind = shaderKind(slug);
|
|
262
|
+
const tiers = await tierMap(registry);
|
|
263
|
+
const tier = tiers?.get(slug);
|
|
264
|
+
|
|
265
|
+
const controls = Object.entries(def.controls).map(
|
|
266
|
+
([key, control]) =>
|
|
267
|
+
`- ${key} ("${control.label}"): number, ${control.min} to ${control.max}, default ${control.default}.${control.hint ? ` ${control.hint}` : ""}`,
|
|
268
|
+
);
|
|
269
|
+
const colors = Object.entries(def.colors).map(
|
|
270
|
+
([key, color]) =>
|
|
271
|
+
`- ${key} ("${color.label}"): hex color, default "${color.default}".${color.hint ? ` ${color.hint}` : ""}`,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const kindNote =
|
|
275
|
+
kind === "source"
|
|
276
|
+
? "Kind: source — it renders on top of an image or layer you give it."
|
|
277
|
+
: kind === "compute"
|
|
278
|
+
? "Kind: compute — a GPU simulation that carries state between frames."
|
|
279
|
+
: "Kind: generative — it draws its picture from the params alone.";
|
|
280
|
+
|
|
281
|
+
const tierNote =
|
|
282
|
+
tier === "professional"
|
|
283
|
+
? "Tier: Professional — install_shader needs the signed-in account to hold a paid plan."
|
|
284
|
+
: tier === "free"
|
|
285
|
+
? "Tier: Free — install_shader works without an account."
|
|
286
|
+
: "Tier: unknown (catalogue index unreachable).";
|
|
287
|
+
|
|
288
|
+
return success(
|
|
289
|
+
[
|
|
290
|
+
`# ${def.name} (${slug})`,
|
|
291
|
+
def.tagline,
|
|
292
|
+
"",
|
|
293
|
+
def.description,
|
|
294
|
+
"",
|
|
295
|
+
`Family: ${def.family}. Cost per frame: ${def.cost}. Tags: ${def.tags.join(", ")}.`,
|
|
296
|
+
kindNote,
|
|
297
|
+
tierNote,
|
|
298
|
+
"",
|
|
299
|
+
"## Controls (keys for the `params` argument and the runtime)",
|
|
300
|
+
...controls,
|
|
301
|
+
...colors,
|
|
302
|
+
"",
|
|
303
|
+
"## Using it",
|
|
304
|
+
`Tune it live in a browser: ${registry}/shaders/${slug} — the sliders write the same params, and the URL carries them.`,
|
|
305
|
+
`Install into this project: install_shader with slug "${slug}" and any tuned params.`,
|
|
306
|
+
`React (runtime from npm): <ShaderCanvas shader="${slug}" params={{ ... }} /> from "shadertown/react".`,
|
|
307
|
+
`Anything else: createShaderRenderer(canvas, { shader: "${slug}", params }) from "shadertown".`,
|
|
308
|
+
].join("\n"),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function installShader({ slug, params, directory, force }) {
|
|
313
|
+
if (!isShaderName(slug)) {
|
|
314
|
+
return failure(`No shader named "${slug}". Use search_shaders to find the right slug.`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const def = shaderRegistry[slug];
|
|
318
|
+
const valid = new Set([...Object.keys(def.controls), ...Object.keys(def.colors)]);
|
|
319
|
+
const cleaned = {};
|
|
320
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
321
|
+
if (!valid.has(key)) {
|
|
322
|
+
return failure(
|
|
323
|
+
`"${key}" is not a control on ${slug}. Valid keys: ${[...valid].join(", ")}. Call get_shader for ranges and meanings.`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
const coerced = key.startsWith("color") ? String(value) : Number(value);
|
|
327
|
+
if (typeof coerced === "number" && !Number.isFinite(coerced)) {
|
|
328
|
+
return failure(`"${key}" must be a number; got ${JSON.stringify(value)}. Call get_shader for its range.`);
|
|
329
|
+
}
|
|
330
|
+
cleaned[key] = coerced;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const cwd = process.cwd();
|
|
334
|
+
const config = await readProjectConfig(cwd);
|
|
335
|
+
const registry = config.registry ?? DEFAULT_REGISTRY;
|
|
336
|
+
const outDir = resolve(cwd, directory ?? config.outDir ?? DEFAULT_OUT);
|
|
337
|
+
const token = await readToken();
|
|
338
|
+
|
|
339
|
+
const url = new URL(`/r/${slug}.json`, registry);
|
|
340
|
+
for (const [key, value] of new URLSearchParams(encodeParams(slug, cleaned))) {
|
|
341
|
+
url.searchParams.set(key, value);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const response = await fetch(url, {
|
|
345
|
+
headers: token ? { authorization: `Bearer ${token}` } : undefined,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (response.status === 402) {
|
|
349
|
+
const body = await response.json().catch(() => ({}));
|
|
350
|
+
return failure(
|
|
351
|
+
[
|
|
352
|
+
body.message ?? `"${def.name}" is a Professional shader.`,
|
|
353
|
+
body.reason,
|
|
354
|
+
token
|
|
355
|
+
? "This machine is signed in, but the account holds no paid plan (or the token predates one — sign in again after upgrading)."
|
|
356
|
+
: "This machine is not signed in.",
|
|
357
|
+
"To unlock it: run `npx shadertown login` in a terminal on this machine, with an account that holds a Silver or Gold plan — then call install_shader again.",
|
|
358
|
+
body.upgrade ? `Plans: ${body.upgrade}` : undefined,
|
|
359
|
+
]
|
|
360
|
+
.filter(Boolean)
|
|
361
|
+
.join("\n"),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!response.ok) {
|
|
366
|
+
const body = await response.json().catch(() => ({}));
|
|
367
|
+
return failure(body.message ?? `${url} responded ${response.status}.`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const item = await response.json();
|
|
371
|
+
await mkdir(outDir, { recursive: true });
|
|
372
|
+
|
|
373
|
+
const written = [];
|
|
374
|
+
const skipped = [];
|
|
375
|
+
for (const file of item.files) {
|
|
376
|
+
const target = join(outDir, file.path);
|
|
377
|
+
if ((await exists(target)) && !force) {
|
|
378
|
+
skipped.push(relative(cwd, target));
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
await mkdir(dirname(target), { recursive: true });
|
|
382
|
+
await writeFile(target, file.content);
|
|
383
|
+
written.push(relative(cwd, target));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const tuned = encodeParams(slug, cleaned);
|
|
387
|
+
return success(
|
|
388
|
+
[
|
|
389
|
+
`Installed ${item.name} (${slug}).`,
|
|
390
|
+
written.length ? `Written:\n${written.map((f) => `- ${f}`).join("\n")}` : "Nothing written.",
|
|
391
|
+
skipped.length
|
|
392
|
+
? `Skipped (already exist — pass force: true to overwrite):\n${skipped.map((f) => `- ${f}`).join("\n")}`
|
|
393
|
+
: undefined,
|
|
394
|
+
`Next: import the preset from ${slug}/${slug}.ts and pass it to <ShaderCanvas preset={...} /> (React) or createShaderRenderer. ${slug}/README.md documents every control; ${slug}/example.tsx is ready to paste.`,
|
|
395
|
+
`Preview in a browser: ${registry}/shaders/${slug}${tuned ? `?${tuned}` : ""}`,
|
|
396
|
+
]
|
|
397
|
+
.filter(Boolean)
|
|
398
|
+
.join("\n\n"),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function accountStatus() {
|
|
403
|
+
const credentials = await readCredentials();
|
|
404
|
+
if (!credentials) {
|
|
405
|
+
return success(
|
|
406
|
+
[
|
|
407
|
+
"Not signed in. Free-tier shaders still install; Professional source answers 402.",
|
|
408
|
+
"To sign in: run `npx shadertown login` in a terminal on this machine (it opens a browser). A Silver or Gold plan on that account unlocks Professional source.",
|
|
409
|
+
"Plans: https://www.shadertown.com/pricing",
|
|
410
|
+
].join("\n"),
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const payload = decodeTokenPayload(credentials.token);
|
|
415
|
+
const lines = [
|
|
416
|
+
credentials.viaEnv
|
|
417
|
+
? "Signed in via the SHADERTOWN_TOKEN environment variable."
|
|
418
|
+
: `Signed in as ${credentials.licensee ?? payload?.licensee ?? "an account"} (registry: ${credentials.registry ?? DEFAULT_REGISTRY}).`,
|
|
419
|
+
];
|
|
420
|
+
|
|
421
|
+
if (payload) {
|
|
422
|
+
lines.push(
|
|
423
|
+
payload.plan === "professional"
|
|
424
|
+
? "Plan: paid — Professional shaders install from this machine."
|
|
425
|
+
: "Plan: free — Professional source will answer 402. Upgrading? Run `npx shadertown login` again afterwards; the plan is carried in the token.",
|
|
426
|
+
);
|
|
427
|
+
if (payload.exp) {
|
|
428
|
+
lines.push(
|
|
429
|
+
payload.exp < Date.now()
|
|
430
|
+
? "The token has expired. Run `npx shadertown login` again."
|
|
431
|
+
: `Token valid until ${new Date(payload.exp).toISOString().slice(0, 10)}.`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
lines.push("This reads the stored token; the registry has the final say on every install.");
|
|
437
|
+
return success(lines.join("\n"));
|
|
438
|
+
}
|