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 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 45 shaders. What a licence buys is the
33
- readable source: 3 shaders hand theirs to anyone, the other 15 need
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, access, chmod, rm } from "node:fs/promises";
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
- const DEFAULT_REGISTRY = process.env.SHADERTOWN_REGISTRY ?? "https://www.shadertown.com";
7
- const DEFAULT_OUT = "src/shaders";
8
-
9
- const esc = (code, text) => `\u001b[${code}m${text}\u001b[0m`;
10
- const c = {
11
- dim: (s) => esc(2, s),
12
- bold: (s) => esc(1, s),
13
- green: (s) => esc(32, s),
14
- cyan: (s) => esc(36, s),
15
- yellow: (s) => esc(33, s),
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
+ }