pi-sprite 1.0.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +7 -0
  4. package/README.md +353 -0
  5. package/examples/README.md +15 -0
  6. package/examples/custom-pets/wumpus-template/README.md +21 -0
  7. package/examples/custom-pets/wumpus-template/pet.json +14 -0
  8. package/examples/petdex-downloads/.gitkeep +0 -0
  9. package/extensions/index.ts +104 -0
  10. package/package.json +77 -0
  11. package/skills/pi-sprite-authoring/SKILL.md +330 -0
  12. package/skills/pi-sprite-authoring/assets/wumpus-template/pet.json +14 -0
  13. package/skills/pi-sprite-authoring/references/character-cohesion-review.md +65 -0
  14. package/skills/pi-sprite-authoring/references/gpt-image-sprite-workflow.md +181 -0
  15. package/skills/pi-sprite-authoring/references/petdex-reference-to-custom-pet.md +155 -0
  16. package/skills/pi-sprite-authoring/references/wumpus-sprite-prompts.md +54 -0
  17. package/skills/pi-sprite-authoring/scripts/assemble_sprite_strip.py +98 -0
  18. package/skills/pi-sprite-authoring/scripts/create-pet-template.mjs +51 -0
  19. package/skills/pi-sprite-authoring/scripts/create_motion_strip.py +110 -0
  20. package/skills/pi-sprite-authoring/scripts/download-petdex-examples.mjs +79 -0
  21. package/skills/pi-sprite-authoring/scripts/openai_sprite_image.py +223 -0
  22. package/skills/pi-sprite-authoring/scripts/remove_sprite_background.py +207 -0
  23. package/src/agent/session-entries.ts +28 -0
  24. package/src/agent/side-completion.ts +94 -0
  25. package/src/agent/side-session-text.ts +20 -0
  26. package/src/agent/side-session-types.ts +12 -0
  27. package/src/agent/side-session.ts +107 -0
  28. package/src/btw/completion.ts +15 -0
  29. package/src/btw/format.ts +30 -0
  30. package/src/btw/index.ts +306 -0
  31. package/src/btw/prompt.ts +35 -0
  32. package/src/btw/recap.ts +56 -0
  33. package/src/btw/session.ts +37 -0
  34. package/src/btw/thread-store.ts +37 -0
  35. package/src/context/index.ts +343 -0
  36. package/src/recap/conversation.ts +22 -0
  37. package/src/recap/direct.ts +15 -0
  38. package/src/recap/format.ts +25 -0
  39. package/src/recap/generation.ts +58 -0
  40. package/src/recap/index.ts +86 -0
  41. package/src/sprite/commands.ts +205 -0
  42. package/src/sprite/download.ts +75 -0
  43. package/src/sprite/kitty-placeholder.ts +441 -0
  44. package/src/sprite/live-status-format.ts +67 -0
  45. package/src/sprite/live-status.ts +48 -0
  46. package/src/sprite/loader.ts +134 -0
  47. package/src/sprite/manifest.ts +87 -0
  48. package/src/sprite/paths.ts +8 -0
  49. package/src/sprite/petdex.ts +133 -0
  50. package/src/sprite/renderer.ts +491 -0
  51. package/src/sprite/runtime.ts +524 -0
  52. package/src/sprite/turn-status-format.ts +112 -0
  53. package/src/sprite/turn-status.ts +88 -0
  54. package/src/ui/overlay.ts +308 -0
@@ -0,0 +1,155 @@
1
+ # Petdex Reference to Custom Pet
2
+
3
+ Use this workflow when the user wants a custom pet inspired by Boba, Petdex, Sprite Mart, or another existing sprite.
4
+
5
+ ## Contents
6
+
7
+ - Discover reference pets
8
+ - Reference-image binding
9
+ - Direction-card stage
10
+ - Character lock
11
+ - State set
12
+ - Character cohesion review
13
+ - Packaging loop
14
+
15
+ ## Discover reference pets
16
+
17
+ In Pi:
18
+
19
+ ```text
20
+ /pet gallery
21
+ /pet search boba
22
+ /pet preview <slug>
23
+ /pet install <slug>
24
+ ```
25
+
26
+ For local reference files without installing in the active session:
27
+
28
+ ```bash
29
+ node skills/pi-sprite-authoring/scripts/download-petdex-examples.mjs \
30
+ --query boba \
31
+ --limit 5 \
32
+ --out /tmp/pi-sprite-petdex-refs
33
+ ```
34
+
35
+ Downloaded Petdex files are local reference material. Do not commit or redistribute third-party assets unless their licenses are verified.
36
+
37
+ ## Reference-image binding
38
+
39
+ Before generating images, bind each reference to an explicit role and instruction. Do not pass vague instructions like "use this image".
40
+
41
+ Good binding:
42
+
43
+ ```json
44
+ {
45
+ "id": "boba-petdex",
46
+ "path": "/tmp/pi-sprite-petdex-refs/boba/spritesheet.webp",
47
+ "role": "style_reference",
48
+ "instruction": "Use for compact pixel-art scale, outline thickness, simple pose language, and terminal readability. Do not copy the exact character identity, costume, or sprite frames."
49
+ }
50
+ ```
51
+
52
+ Reference roles:
53
+
54
+ - `style_reference` — palette, outline, pixel density, pose simplicity.
55
+ - `scale_reference` — canvas size, silhouette size, padding.
56
+ - `character_reference` — user-owned character identity that should be preserved.
57
+ - `mood_reference` — emotional target only.
58
+ - `negative_reference` — what to avoid.
59
+
60
+ For unclear-license Petdex assets, use `style_reference` or `scale_reference`, not `character_reference`.
61
+
62
+ ## Direction-card stage
63
+
64
+ Turn the user brief and references into 3-5 direction cards before generating. Each card should be a concrete pet concept, not just a style adjective.
65
+
66
+ ```markdown
67
+ 1. **Sleepy Tapioca Familiar**
68
+ - Character: round boba-pearl creature with a tiny straw sprout
69
+ - Mood: cozy, drowsy, lightly mischievous
70
+ - Visual lock: cream/caramel/brown palette, dark chunky outline, dot eyes, centered 128x128 canvas
71
+ - Why it fits: round silhouette stays readable in terminal size
72
+ - Risk: could look like a generic boba mascot unless the straw/ears are distinctive
73
+ ```
74
+
75
+ Ask the user to choose, combine, or revise directions. If the user says to proceed autonomously, state the chosen direction and why.
76
+
77
+ ## Character lock
78
+
79
+ After direction selection, write a stable character lock and repeat it in every state prompt.
80
+
81
+ ```text
82
+ Canonical character lock:
83
+ A tiny original boba-tea terminal companion. Rounded milk-tea body, two large tapioca-pearl ear shapes, small straw sprout tilted left, sleepy dot eyes, tiny paws, cream/brown/caramel palette, bold dark pixel outline. Always centered on a transparent 128x128 canvas with 8px padding. No text, logo, cup branding, background, shadow, or watermark.
84
+ ```
85
+
86
+ ## State set
87
+
88
+ Do not generate all states independently from only a style reference. First generate 3-6 `idle` candidates, choose one canonical anchor, and then generate the remaining states as edits that preserve that anchor's character identity.
89
+
90
+ Generate or select one image for each `pi-sprite` state:
91
+
92
+ - `idle` — neutral/resting pose.
93
+ - `thinking` — pondering, curious, tilted, or looking upward; no question-mark text.
94
+ - `working` — focused/active/typing; keep props large and simple.
95
+ - `success` — celebratory/happy; no text.
96
+ - `error` — confused/worried but still cute; no text.
97
+
98
+ Prefer five separate state images for first-time custom pets. Use spritesheets only after the five-state identity works.
99
+
100
+ ## Character cohesion review
101
+
102
+ After generating state images, compare each state to the canonical anchor before packaging.
103
+
104
+ ```markdown
105
+ ## Character cohesion review
106
+
107
+ Canonical anchor: /tmp/boba-inspired/anchor/idle.png
108
+
109
+ | State | Identity | Silhouette | Face | Ears/props | Palette/outline | State readability | Verdict |
110
+ | --- | --- | --- | --- | --- | --- | --- | --- |
111
+ | thinking | same | same | minor drift | same | same | clear | accept |
112
+ | working | major drift | changed body | changed face | missing straw | same | clear | regenerate |
113
+ ```
114
+
115
+ Reject states with major drift in identity, silhouette, face, signature props, palette, or outline. Regeneration prompts should say exactly what to preserve from the anchor and only change pose/expression. Use `character-cohesion-review.md` for a reusable review prompt and table format.
116
+
117
+ If accepted images lack alpha, run `scripts/remove_sprite_background.py` and package the cleaned images, not the raw generated files.
118
+
119
+ ## Packaging loop
120
+
121
+ Create a pet template:
122
+
123
+ ```bash
124
+ node skills/pi-sprite-authoring/scripts/create-pet-template.mjs \
125
+ --id boba-inspired \
126
+ --name "Boba Inspired" \
127
+ --out /tmp/boba-inspired-pet
128
+ ```
129
+
130
+ Copy or rename selected outputs:
131
+
132
+ ```text
133
+ /tmp/boba-inspired-pet/idle.png
134
+ /tmp/boba-inspired-pet/thinking.png
135
+ /tmp/boba-inspired-pet/working.png
136
+ /tmp/boba-inspired-pet/success.png
137
+ /tmp/boba-inspired-pet/error.png
138
+ ```
139
+
140
+ Import and test:
141
+
142
+ ```text
143
+ /pet import /tmp/boba-inspired-pet
144
+ /pet choose boba-inspired
145
+ /pet size small
146
+ /pet label off
147
+ /pet show
148
+ ```
149
+
150
+ If native images look stale or stuck:
151
+
152
+ ```text
153
+ /pet clear-native
154
+ /pet show
155
+ ```
@@ -0,0 +1,54 @@
1
+ # Wumpus Sprite Prompts
2
+
3
+ Use these prompts with an image generator plus 2-5 reference sprites for scale and pixel-art treatment.
4
+
5
+ ## Single idle sprite
6
+
7
+ ```text
8
+ Create a transparent PNG pixel-art sprite of Wumpus, Discord's friendly mascot creature, for a terminal companion.
9
+
10
+ Canvas and style:
11
+ - 64x64 or 128x128 square canvas
12
+ - transparent background
13
+ - cute front-facing or slight 3/4 idle pose
14
+ - rounded body, small horns, friendly face
15
+ - bold dark outline
16
+ - limited Discord-inspired palette
17
+ - readable when scaled down to tiny terminal size
18
+ - no text, no logo, no background, no shadow
19
+ - leave 4-8px transparent padding around the character
20
+
21
+ Use attached reference sprites only for scale, outline thickness, pose simplicity, and pixel-art treatment. Do not copy their character design.
22
+ ```
23
+
24
+ ## Full pi-sprite state set
25
+
26
+ ```text
27
+ Create five matching transparent PNG pixel-art sprites of the same Wumpus character for a terminal companion.
28
+
29
+ Required files/states:
30
+ 1. idle — relaxed neutral smile
31
+ 2. thinking — curious head tilt or small question-mark mood, no text
32
+ 3. working — focused typing or tiny laptop pose
33
+ 4. success — happy celebratory sparkle, no text
34
+ 5. error — confused or worried, no text
35
+
36
+ Consistency requirements:
37
+ - same canvas size for every image, preferably 64x64 or 128x128
38
+ - same character proportions
39
+ - same outline thickness
40
+ - same color palette
41
+ - transparent background
42
+ - centered with consistent feet/baseline
43
+ - simple readable silhouette
44
+ ```
45
+
46
+ ## If the model struggles with pixel-perfect output
47
+
48
+ Ask for a larger clean pixel-art source, then downscale with nearest-neighbor:
49
+
50
+ ```text
51
+ Create this as crisp pixel art on a 512x512 transparent canvas, with large blocky pixels and no anti-aliased painterly texture. Keep the character centered and simple so it can be downscaled to 64x64.
52
+ ```
53
+
54
+ Then crop/downscale to 64x64 or 128x128 with nearest-neighbor in an image editor.
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python3
2
+ """Assemble normalized pi-sprite frames into a horizontal animation strip."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ def parse_args() -> argparse.Namespace:
14
+ parser = argparse.ArgumentParser(description="Assemble pi-sprite frame PNGs into a horizontal animation strip.")
15
+ parser.add_argument("--frame", type=Path, action="append", required=True, help="Input frame image path; repeat in animation order")
16
+ parser.add_argument("--output", type=Path, required=True, help="Output strip PNG path")
17
+ parser.add_argument("--metadata", type=Path, help="Optional metadata JSON path")
18
+ parser.add_argument("--frame-width", type=int, default=128, help="Frame width in pixels")
19
+ parser.add_argument("--frame-height", type=int, default=128, help="Frame height in pixels")
20
+ parser.add_argument("--fit", choices=["contain", "cover"], default="contain", help="Resize mode for each input frame")
21
+ parser.add_argument("--dry-run", action="store_true", help="Write metadata only; do not write the strip")
22
+ return parser.parse_args()
23
+
24
+
25
+ def process(args: argparse.Namespace) -> dict[str, Any]:
26
+ if args.frame_width <= 0 or args.frame_height <= 0:
27
+ raise ValueError("--frame-width and --frame-height must be positive")
28
+ if not args.frame:
29
+ raise ValueError("at least one --frame is required")
30
+ for frame in args.frame:
31
+ if not frame.exists():
32
+ raise ValueError(f"frame image missing: {frame}")
33
+ if not frame.is_file():
34
+ raise ValueError(f"frame image is not a file: {frame}")
35
+
36
+ from PIL import Image
37
+
38
+ normalized: list[Image.Image] = []
39
+ input_sizes: list[list[int]] = []
40
+ for frame in args.frame:
41
+ image = Image.open(frame).convert("RGBA")
42
+ input_sizes.append([image.width, image.height])
43
+ if args.fit == "contain":
44
+ image.thumbnail((args.frame_width, args.frame_height), Image.Resampling.LANCZOS)
45
+ canvas = Image.new("RGBA", (args.frame_width, args.frame_height), (0, 0, 0, 0))
46
+ canvas.alpha_composite(image, ((args.frame_width - image.width) // 2, (args.frame_height - image.height) // 2))
47
+ else:
48
+ scale = max(args.frame_width / image.width, args.frame_height / image.height)
49
+ resized = image.resize((round(image.width * scale), round(image.height * scale)), Image.Resampling.LANCZOS)
50
+ left = (resized.width - args.frame_width) // 2
51
+ top = (resized.height - args.frame_height) // 2
52
+ canvas = resized.crop((left, top, left + args.frame_width, top + args.frame_height))
53
+ normalized.append(canvas)
54
+
55
+ output_size = [args.frame_width * len(normalized), args.frame_height]
56
+ if not args.dry_run:
57
+ args.output.parent.mkdir(parents=True, exist_ok=True)
58
+ strip = Image.new("RGBA", tuple(output_size), (0, 0, 0, 0))
59
+ for index, frame in enumerate(normalized):
60
+ strip.alpha_composite(frame, (index * args.frame_width, 0))
61
+ strip.save(args.output, format="PNG")
62
+
63
+ metadata = {
64
+ "dry_run": args.dry_run,
65
+ "output": str(args.output),
66
+ "frames": [str(frame) for frame in args.frame],
67
+ "frame_count": len(args.frame),
68
+ "frame_width": args.frame_width,
69
+ "frame_height": args.frame_height,
70
+ "fit": args.fit,
71
+ "input_sizes": input_sizes,
72
+ "output_size": output_size,
73
+ "manifest_frame": {"width": args.frame_width, "height": args.frame_height},
74
+ }
75
+ if args.metadata:
76
+ args.metadata.parent.mkdir(parents=True, exist_ok=True)
77
+ args.metadata.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
78
+ return metadata
79
+
80
+
81
+ def main() -> int:
82
+ try:
83
+ args = parse_args()
84
+ metadata = process(args)
85
+ except Exception as exc:
86
+ print(f"ERROR: {exc}", file=sys.stderr)
87
+ return 2
88
+
89
+ print(f"output: {metadata['output']}")
90
+ print(f"dry_run: {metadata['dry_run']}")
91
+ print(f"frame_count: {metadata['frame_count']}")
92
+ print(f"frame_size: {metadata['frame_width']}x{metadata['frame_height']}")
93
+ print(f"output_size: {metadata['output_size']}")
94
+ return 0
95
+
96
+
97
+ if __name__ == "__main__":
98
+ raise SystemExit(main())
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ function arg(name, fallback = "") {
6
+ const index = process.argv.indexOf(`--${name}`);
7
+ return index >= 0 ? (process.argv[index + 1] ?? fallback) : fallback;
8
+ }
9
+
10
+ function normalizeId(value) {
11
+ return value
12
+ .trim()
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/gu, "-")
15
+ .replace(/^-+|-+$/gu, "")
16
+ .slice(0, 80);
17
+ }
18
+
19
+ const id = normalizeId(arg("id", "wumpus"));
20
+ const name = arg("name", "Wumpus").trim() || id;
21
+ const author = arg("author", "").trim();
22
+ const personality = arg("personality", "").trim();
23
+ const out = arg("out", `./${id}-sprite`);
24
+
25
+ if (!id) {
26
+ console.error("Missing valid --id");
27
+ process.exit(1);
28
+ }
29
+
30
+ mkdirSync(out, { recursive: true });
31
+ const manifest = {
32
+ id,
33
+ name,
34
+ ...(author ? { author } : {}),
35
+ description: `A tiny ${name} companion for pi-sprite.`,
36
+ ...(personality ? { personality } : {}),
37
+ sprites: {
38
+ idle: "idle.png",
39
+ thinking: "thinking.png",
40
+ working: "working.png",
41
+ success: "success.png",
42
+ error: "error.png",
43
+ },
44
+ };
45
+ writeFileSync(join(out, "pet.json"), `${JSON.stringify(manifest, null, 2)}\n`);
46
+ writeFileSync(
47
+ join(out, "README.md"),
48
+ `# ${name} pi-sprite pet\n\nAdd these transparent PNG/WebP files next to \`pet.json\`:\n\n- idle.png\n- thinking.png\n- working.png\n- success.png\n- error.png\n\nImport with:\n\n\`\`\`text\n/pet import ${out}\n/pet choose ${id}\n/pet show\n\`\`\`\n`,
49
+ );
50
+ console.log(`Created ${out}`);
51
+ console.log(`Next: add sprite images, then run /pet import ${out}`);
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """Create a simple motion-loop strip from one pi-sprite state image."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ PRESETS: dict[str, list[tuple[int, int]]] = {
13
+ "bob": [(0, 1), (0, -2), (0, -1), (0, 1)],
14
+ "thinking-bob": [(0, 1), (-1, -2), (0, -1), (1, 1)],
15
+ "working-tap": [(0, 1), (0, -1), (1, 0), (0, -1), (-1, 0), (0, 1)],
16
+ "success-bounce": [(0, 1), (0, -3), (0, -1), (0, -3), (0, 1)],
17
+ "error-droop": [(0, 0), (0, 2), (-1, 1), (0, 2)],
18
+ }
19
+
20
+
21
+ def parse_offset(value: str) -> tuple[int, int]:
22
+ try:
23
+ x_text, y_text = value.split(",", 1)
24
+ return int(x_text), int(y_text)
25
+ except ValueError as exc:
26
+ raise argparse.ArgumentTypeError("offsets must be X,Y pixel pairs, e.g. 0,-2") from exc
27
+
28
+
29
+ def parse_args() -> argparse.Namespace:
30
+ parser = argparse.ArgumentParser(description="Create a simple pi-sprite motion strip from a single frame image.")
31
+ parser.add_argument("--input", type=Path, required=True, help="Input state image")
32
+ parser.add_argument("--output", type=Path, required=True, help="Output strip PNG path")
33
+ parser.add_argument("--metadata", type=Path, help="Optional metadata JSON path")
34
+ parser.add_argument("--frame-width", type=int, default=128, help="Frame width in pixels")
35
+ parser.add_argument("--frame-height", type=int, default=128, help="Frame height in pixels")
36
+ parser.add_argument("--preset", choices=sorted(PRESETS), default="bob", help="Built-in motion preset")
37
+ parser.add_argument(
38
+ "--offset",
39
+ type=parse_offset,
40
+ action="append",
41
+ default=[],
42
+ help="Custom X,Y pixel offset; repeat to override --preset",
43
+ )
44
+ parser.add_argument("--dry-run", action="store_true", help="Write metadata only; do not write the strip")
45
+ return parser.parse_args()
46
+
47
+
48
+ def process(args: argparse.Namespace) -> dict[str, Any]:
49
+ if args.frame_width <= 0 or args.frame_height <= 0:
50
+ raise ValueError("--frame-width and --frame-height must be positive")
51
+ if not args.input.exists():
52
+ raise ValueError(f"input image missing: {args.input}")
53
+ if not args.input.is_file():
54
+ raise ValueError(f"input image is not a file: {args.input}")
55
+
56
+ from PIL import Image
57
+
58
+ offsets = args.offset or PRESETS[args.preset]
59
+ source = Image.open(args.input).convert("RGBA")
60
+ source.thumbnail((args.frame_width, args.frame_height), Image.Resampling.LANCZOS)
61
+ centered = Image.new("RGBA", (args.frame_width, args.frame_height), (0, 0, 0, 0))
62
+ centered.alpha_composite(source, ((args.frame_width - source.width) // 2, (args.frame_height - source.height) // 2))
63
+
64
+ output_size = [args.frame_width * len(offsets), args.frame_height]
65
+ if not args.dry_run:
66
+ args.output.parent.mkdir(parents=True, exist_ok=True)
67
+ strip = Image.new("RGBA", tuple(output_size), (0, 0, 0, 0))
68
+ for index, (dx, dy) in enumerate(offsets):
69
+ frame = Image.new("RGBA", (args.frame_width, args.frame_height), (0, 0, 0, 0))
70
+ frame.alpha_composite(centered, (dx, dy))
71
+ strip.alpha_composite(frame, (index * args.frame_width, 0))
72
+ strip.save(args.output, format="PNG")
73
+
74
+ metadata = {
75
+ "dry_run": args.dry_run,
76
+ "input": str(args.input),
77
+ "output": str(args.output),
78
+ "preset": args.preset,
79
+ "offsets": [[x, y] for x, y in offsets],
80
+ "frame_count": len(offsets),
81
+ "frame_width": args.frame_width,
82
+ "frame_height": args.frame_height,
83
+ "output_size": output_size,
84
+ "manifest_frame": {"width": args.frame_width, "height": args.frame_height},
85
+ }
86
+ if args.metadata:
87
+ args.metadata.parent.mkdir(parents=True, exist_ok=True)
88
+ args.metadata.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
89
+ return metadata
90
+
91
+
92
+ def main() -> int:
93
+ try:
94
+ args = parse_args()
95
+ metadata = process(args)
96
+ except Exception as exc:
97
+ print(f"ERROR: {exc}", file=sys.stderr)
98
+ return 2
99
+
100
+ print(f"input: {metadata['input']}")
101
+ print(f"output: {metadata['output']}")
102
+ print(f"dry_run: {metadata['dry_run']}")
103
+ print(f"preset: {metadata['preset']}")
104
+ print(f"frame_count: {metadata['frame_count']}")
105
+ print(f"output_size: {metadata['output_size']}")
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ raise SystemExit(main())
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { extname, join } from "node:path";
4
+
5
+ function arg(name, fallback = "") {
6
+ const index = process.argv.indexOf(`--${name}`);
7
+ return index >= 0 ? (process.argv[index + 1] ?? fallback) : fallback;
8
+ }
9
+
10
+ const manifestUrl = arg(
11
+ "manifest",
12
+ process.env.PI_SPRITE_PETDEX_MANIFEST_URL || "https://petdex.crafter.run/api/manifest",
13
+ );
14
+ const out = arg("out", "examples/petdex-downloads");
15
+ const query = arg("query", "").toLowerCase();
16
+ const limit = Math.max(1, Math.min(50, Number.parseInt(arg("limit", "12"), 10) || 12));
17
+ function safeSlug(value) {
18
+ const slug = String(value ?? "")
19
+ .trim()
20
+ .toLowerCase();
21
+ return /^[a-z0-9][a-z0-9-]{0,79}$/u.test(slug) ? slug : "";
22
+ }
23
+
24
+ const slugs = arg("slugs", "")
25
+ .split(",")
26
+ .map((slug) => safeSlug(slug))
27
+ .filter(Boolean);
28
+
29
+ async function download(url) {
30
+ const res = await fetch(url, { headers: { accept: "*/*", "user-agent": "pi-sprite-authoring" } });
31
+ if (!res.ok) throw new Error(`download failed for ${url} (${res.status})`);
32
+ return Buffer.from(await res.arrayBuffer());
33
+ }
34
+
35
+ function spriteNameFromUrl(url) {
36
+ const ext = extname(new URL(url).pathname).toLowerCase();
37
+ return ext === ".png" ? "spritesheet.png" : "spritesheet.webp";
38
+ }
39
+
40
+ const manifestRes = await fetch(manifestUrl, {
41
+ headers: { accept: "application/json", "user-agent": "pi-sprite-authoring" },
42
+ });
43
+ if (!manifestRes.ok) throw new Error(`manifest fetch failed (${manifestRes.status})`);
44
+ const manifest = await manifestRes.json();
45
+ const pets = (Array.isArray(manifest.pets) ? manifest.pets : [])
46
+ .map((pet) => ({ ...pet, safeSlug: safeSlug(pet.slug) }))
47
+ .filter((pet) => pet.safeSlug && pet.displayName && pet.petJsonUrl && pet.spritesheetUrl)
48
+ .filter((pet) => !slugs.length || slugs.includes(pet.safeSlug))
49
+ .filter((pet) => !query || `${pet.safeSlug} ${pet.displayName} ${pet.kind ?? ""}`.toLowerCase().includes(query))
50
+ .slice(0, limit);
51
+
52
+ mkdirSync(out, { recursive: true });
53
+ const provenance = [
54
+ "# Petdex example downloads",
55
+ "",
56
+ `Downloaded from: ${manifestUrl}`,
57
+ `Downloaded at: ${new Date().toISOString()}`,
58
+ "",
59
+ "These files are local reference material. Do not commit or redistribute third-party sprite assets unless their licenses are verified.",
60
+ "",
61
+ "## Assets",
62
+ ];
63
+
64
+ for (const pet of pets) {
65
+ const dir = join(out, pet.safeSlug);
66
+ mkdirSync(dir, { recursive: true });
67
+ const [petJsonBytes, spriteBytes] = await Promise.all([download(pet.petJsonUrl), download(pet.spritesheetUrl)]);
68
+ const spriteName = spriteNameFromUrl(pet.spritesheetUrl);
69
+ writeFileSync(join(dir, "pet.json"), petJsonBytes);
70
+ writeFileSync(join(dir, spriteName), spriteBytes);
71
+ writeFileSync(
72
+ join(dir, "PROVENANCE.md"),
73
+ `# ${pet.displayName}\n\n- slug: ${pet.safeSlug}\n- kind: ${pet.kind ?? "unknown"}\n- submittedBy: ${pet.submittedBy ?? "unknown"}\n- petJsonUrl: ${pet.petJsonUrl}\n- spritesheetUrl: ${pet.spritesheetUrl}\n\nLicense was not verified by this script. Use as temporary reference only unless separately cleared.\n`,
74
+ );
75
+ provenance.push(`- ${pet.safeSlug} — ${pet.displayName} (${pet.kind ?? "unknown"})`);
76
+ }
77
+
78
+ writeFileSync(join(out, "THIRD_PARTY_ASSETS.md"), `${provenance.join("\n")}\n`);
79
+ console.log(`Downloaded ${pets.length} Petdex example(s) into ${out}`);