shadertown 0.4.0 → 0.6.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,7 +6,57 @@ 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.4.0 — 2026-08-30
9
+ ## 0.6.0 — 2026-08-30
10
+
11
+ The CLI learns to talk to coding agents. `npx shadertown mcp` serves the
12
+ catalogue over the Model Context Protocol, so an agent can search shaders,
13
+ read every control with its range and meaning, and vendor tuned source into
14
+ the project it is working on — through the same registry route and the same
15
+ licence rules as `shadertown add`.
16
+
17
+ ### Added
18
+
19
+ - **`shadertown mcp`.** A dependency-free MCP server over stdio with four
20
+ tools: `search_shaders`, `get_shader`, `install_shader`, and
21
+ `account_status`. Discovery and control schemas are answered locally from
22
+ the registry this package already ships; installing goes through
23
+ `/r/<slug>.json`, so Free shaders vendor for anyone and Professional
24
+ source asks for the paid plan exactly as the CLI does. Connect an agent
25
+ with `npx add-mcp "npx -y shadertown@latest mcp" -n shadertown`.
26
+
27
+ ## 0.5.0 — 2026-08-30
28
+
29
+ Three shaders that remember: a `compute` kind whose state lives in GPU
30
+ buffers and steps forward before every draw, so what you see is a
31
+ simulation answering the pointer, not a picture pretending to.
32
+
33
+ ### Added
34
+
35
+ - **`particles`.** 131,072 embers riding a curl-noise wind, deposited into a
36
+ long exposure that never quite forgets — trails are history and colour is
37
+ speed. The pointer is a vortex; push its charge below zero and the swarm
38
+ scatters instead.
39
+ - **`fluid`.** The incompressible Navier–Stokes equations, solved for real:
40
+ semi-Lagrangian advection, twenty warm-started pressure sweeps, vorticity
41
+ confinement, and dye on a grid finer than the velocity that carries it.
42
+ Drag to splat force and ink together; idle, it throws plumes of its own.
43
+ - **`cloth`.** A verlet sheet of two and a half thousand points relaxing
44
+ twelve distance constraints in parallel, hung from its pins in a two-speed
45
+ wind. With no vertex pipeline in the runtime it rasterises itself through
46
+ an atomic depth buffer, and the pointer carries an invisible sphere the
47
+ folds drape around.
48
+ - The `compute` shader kind behind them. The runtime allocates each shader's
49
+ storage buffers, dispatches its `@compute` entry points ahead of the draw,
50
+ and rebinds what the fragment stage reads — through `ShaderCanvas` or
51
+ `createShaderRenderer` nothing changes on your side. Forty-five shaders in
52
+ total.
53
+
54
+ ### Changed
55
+
56
+ - Grain now defaults to 0 across the whole catalogue. It was a texture
57
+ everything arrived wearing; it is now an effect you reach for.
58
+
59
+
10
60
 
11
61
  Ten shaders built around input: six that answer the pointer, four made for
12
62
  text, and a `{ text }` source so the text ones have something to answer.
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 42 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 45 shaders. What a licence buys is the
44
+ readable source: 13 shaders hand theirs to anyone, the other 32 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
+ }