spritegen-cli 0.1.0__py3-none-any.whl

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.
spritegen/sheet.py ADDED
@@ -0,0 +1,370 @@
1
+ """Close a board of animation frames into one row of a sprite sheet.
2
+
3
+ Absorbed from the `board_to_row.py` this grew out of. Three decisions in it are load-bearing,
4
+ and each is a way the animation breaks if it is done the other way:
5
+
6
+ 1. **One grid and one palette over the whole board at once, never per frame.** Frame
7
+ by frame, each cell lands on its own grid and its own palette, and the animation
8
+ flickers.
9
+ 2. **Cells are cut by the declared grid, not by islands of alpha.** The board arrived
10
+ with equal cells; finding the figures by alpha would lose where each one sits inside
11
+ its cell, and where it sits *is* the movement.
12
+ 3. **One transform for the whole set.** Scale and offset come from the box that holds
13
+ every frame and apply equally to all of them. Anchoring each frame on its own would
14
+ push the rising frame back down and the walk would stand still.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ #: The default sheet cell, in pixels. A project with another one passes `--cell`.
22
+ CELL = 166
23
+
24
+ #: The GIF's flat backdrop. GIF has no partial alpha, and index transparency leaves a
25
+ #: fringe around the figure, so the preview gets a solid ground instead.
26
+ GIF_BACKDROP = (32, 32, 32, 255)
27
+
28
+ #: The art directions a row can be closed in, and the subdirectory each one goes in.
29
+ #: Pixel art is one of them and not the destination of every sprite — plenty of sprite
30
+ #: art is a crisp raster with an anti-aliased edge, and `as-is` covers the case where
31
+ #: the art arrived in the direction it was wanted in. See `board_to_row`.
32
+ ART_DIRECTIONS = ("as-is", "sharp", "pixel-art")
33
+
34
+ #: Below this the alpha is background smear, above it the alpha is the figure. Sprite
35
+ #: art of this kind has a real anti-aliased edge — in the sheet this was measured
36
+ #: against, 16% of a cell is partial alpha — so this is a floor and a ceiling rather
37
+ #: than a threshold that would make the edge binary.
38
+ ALPHA_FLOOR = 16
39
+ ALPHA_CEIL = 240
40
+
41
+
42
+ def parse_grid(text: str) -> tuple[int, int]:
43
+ """`3x2` to `(3, 2)`."""
44
+ try:
45
+ cols, rows = (int(value) for value in text.lower().split("x", 1))
46
+ except ValueError:
47
+ raise ValueError(f"a grid is COLSxROWS, e.g. 3x2; got {text!r}") from None
48
+ if cols < 1 or rows < 1:
49
+ raise ValueError(f"a grid needs positive sides; got {text!r}")
50
+ return cols, rows
51
+
52
+
53
+ def pixelate_board(image, colors: int, seed: int | None = None):
54
+ """Recover the native grid of the whole board. Returns the image and what it found.
55
+
56
+ Seeded for the reason `imaging.to_pixelart` is: pixelfixer reconstructs by k-means on
57
+ OpenCV's global RNG, so without this two runs of one board differ.
58
+ """
59
+ from .imaging import PIXELART_SEED, require_pixelfixer
60
+
61
+ require_pixelfixer()
62
+
63
+ import cv2
64
+ import numpy as np
65
+ from PIL import Image
66
+ from pixelfixer import detect
67
+ from pixelfixer.reconstruct import reconstruct
68
+
69
+ cv2.setRNGSeed(PIXELART_SEED if seed is None else seed)
70
+ rgba = np.array(image.convert("RGBA"))
71
+ found = detect(rgba)
72
+ out = Image.fromarray(
73
+ reconstruct(rgba, found["step_x"], found["step_y"], found["cols"], found["rows"])
74
+ )
75
+ if colors:
76
+ out = quantise(out, colors)
77
+ return out, found
78
+
79
+
80
+ def resize_rgba(image, size: tuple[int, int], filter_):
81
+ """Resize without letting the transparent pixels darken the edge.
82
+
83
+ A smooth filter weights a neighbour by distance and nothing else, and a fully
84
+ transparent pixel still carries an RGB — usually black, because that is what the
85
+ matte left behind it. Averaging that in is where the dark fringe around a shrunk
86
+ sprite comes from. Premultiplying makes a pixel's weight its own alpha, which is
87
+ what the average should have been all along.
88
+
89
+ `NEAREST` picks one pixel rather than averaging any, so it takes neither the fringe
90
+ nor this treatment, and goes straight through. That is also what keeps the integer
91
+ upscale path byte for byte what it was.
92
+ """
93
+ from PIL import Image
94
+
95
+ if filter_ == Image.NEAREST:
96
+ return image.resize(size, filter_)
97
+
98
+ import numpy as np
99
+
100
+ source = np.asarray(image.convert("RGBA"), dtype=np.float32)
101
+ weight = source[:, :, 3:4] / 255.0
102
+ premultiplied = np.concatenate([source[:, :, :3] * weight, source[:, :, 3:4]], axis=2)
103
+ small = Image.fromarray(premultiplied.round().astype(np.uint8), "RGBA").resize(size, filter_)
104
+
105
+ out = np.asarray(small, dtype=np.float32)
106
+ alpha = out[:, :, 3:4] / 255.0
107
+ straight = np.divide(
108
+ out[:, :, :3], alpha, out=np.zeros_like(out[:, :, :3]), where=alpha > 0
109
+ )
110
+ merged = np.concatenate([straight, out[:, :, 3:4]], axis=2)
111
+ return Image.fromarray(merged.round().clip(0, 255).astype(np.uint8), "RGBA")
112
+
113
+
114
+ def fit_scale(span_w: int, span_h: int, cell_size: int, fit: bool = False) -> int | float:
115
+ """The largest scale at which the figure still fits the cell.
116
+
117
+ Integer while an integer fits, because a sprite enlarged by a whole factor keeps its
118
+ edges and one enlarged by 1.5 does not. Below one there is no such choice to make:
119
+ the art is bigger than the cell and the only question is by how much it comes down,
120
+ so the fraction is exact and the caller reduces with a smooth filter.
121
+
122
+ **`fit` is off by default, and that is not timidity.** Reducing is the half that was
123
+ missing — a sharp anchor generated at 1024 px overflows a 166 cell and `max(1, ...)`
124
+ left it there. But `board_to_row.py`'s output on a board that overflows is a parity
125
+ baseline, and quietly reducing where that script did not would break the one promise
126
+ the port makes. So the new behaviour arrives where the new route asks for it, and
127
+ the old route comes out byte for byte what it was.
128
+ """
129
+ integer = min(cell_size // span_w, cell_size // span_h)
130
+ if integer >= 1:
131
+ return integer
132
+ return min(cell_size / span_w, cell_size / span_h) if fit else 1
133
+
134
+
135
+ def harden_alpha(image, floor: int = ALPHA_FLOOR, ceil: int = ALPHA_CEIL):
136
+ """Cut the smear off the alpha and snap the nearly-solid to solid.
137
+
138
+ Reducing a matted figure leaves a long faint tail where the matte's own uncertainty
139
+ was, and at cell size that tail reads as a halo. What it must not do is make the
140
+ edge binary: sprite art of this kind is anti-aliased, and a figure with a hard
141
+ outline does not sit beside one that has none.
142
+
143
+ So the band between `floor` and `ceil` is kept and stretched over the full range,
144
+ everything under `floor` goes to nothing, everything over `ceil` goes to solid.
145
+ """
146
+ import numpy as np
147
+ from PIL import Image
148
+
149
+ if floor >= ceil:
150
+ raise ValueError(f"the alpha floor {floor} is not below the ceiling {ceil}")
151
+
152
+ rgba = np.asarray(image.convert("RGBA")).copy()
153
+ alpha = rgba[:, :, 3].astype(np.float32)
154
+ stretched = (alpha - floor) * (255.0 / (ceil - floor))
155
+ rgba[:, :, 3] = stretched.clip(0, 255).round().astype(np.uint8)
156
+ return Image.fromarray(rgba, "RGBA")
157
+
158
+
159
+ def quantise(image, colors: int):
160
+ """One palette over whatever it is given, alpha kept out of the vote.
161
+
162
+ No dither, for the reason `imaging.to_pixelart` gives: it scatters half-tone noise
163
+ across areas the reduction just made flat. Called on the assembled row rather than
164
+ on a cell, so all six frames share the palette and the sprite does not shift colour
165
+ as it walks.
166
+ """
167
+ from PIL import Image
168
+
169
+ quantized = image.convert("RGB").quantize(colors=colors, dither=Image.Dither.NONE)
170
+ merged = quantized.convert("RGBA")
171
+ merged.putalpha(image.getchannel("A"))
172
+ return merged
173
+
174
+
175
+ def cut_cells(image, cols: int, rows: int, frames: int) -> list:
176
+ """Divide by the declared grid. The division remainder stays in the last cell."""
177
+ width, height = image.size
178
+ cell_w, cell_h = width / cols, height / rows
179
+ cells = []
180
+ for index in range(frames):
181
+ col, row = index % cols, index // cols
182
+ box = (
183
+ round(col * cell_w),
184
+ round(row * cell_h),
185
+ round((col + 1) * cell_w),
186
+ round((row + 1) * cell_h),
187
+ )
188
+ cells.append(image.crop(box))
189
+ return cells
190
+
191
+
192
+ def union_bounds(cells: list) -> tuple[int, int, int, int]:
193
+ """The box holding the figure in every frame, in cell coordinates."""
194
+ boxes = [cell.getbbox() for cell in cells]
195
+ if any(box is None for box in boxes):
196
+ raise ValueError("a frame came out empty: check the grid and the frame count")
197
+ return (
198
+ min(box[0] for box in boxes),
199
+ min(box[1] for box in boxes),
200
+ max(box[2] for box in boxes),
201
+ max(box[3] for box in boxes),
202
+ )
203
+
204
+
205
+ def build_row(
206
+ cells: list, scale: int | float | None, cell_size: int = CELL, fit: bool = False
207
+ ) -> tuple[list, dict]:
208
+ """Place every frame into a sheet cell under one shared transform.
209
+
210
+ The transform runs both ways. Enlarging is by a whole factor and by nearest
211
+ neighbour, which is what a sprite drawn small needs. Reducing is by the exact
212
+ fraction that fits and by a smooth filter, which is what a sharp anchor generated
213
+ at 1024 px needs — and it is the same one transform for every frame either way,
214
+ because scale and offset come from the box holding the whole set.
215
+
216
+ Reducing happens only under `fit`, or under a `scale` under one given outright. See
217
+ `fit_scale` for why the default leaves an overflow where it stands.
218
+ """
219
+ from PIL import Image
220
+
221
+ left, top, right, bottom = union_bounds(cells)
222
+ span_w, span_h = right - left, bottom - top
223
+ if scale is None:
224
+ scale = fit_scale(span_w, span_h, cell_size, fit)
225
+
226
+ shrinking = scale < 1
227
+ filter_ = Image.LANCZOS if shrinking else Image.NEAREST
228
+ scaled_w = max(1, round(span_w * scale))
229
+ scaled_h = max(1, round(span_h * scale))
230
+ # Centred across, feet on the cell floor: the anchor the client draws from.
231
+ off_x = (cell_size - scaled_w) // 2
232
+ off_y = cell_size - scaled_h
233
+
234
+ placed = []
235
+ for cell in cells:
236
+ crop = cell.crop((left, top, right, bottom))
237
+ sized = resize_rgba(crop, (scaled_w, scaled_h), filter_)
238
+ target = Image.new("RGBA", (cell_size, cell_size), (0, 0, 0, 0))
239
+ target.paste(sized, (off_x, off_y), sized)
240
+ placed.append(target)
241
+
242
+ return placed, {
243
+ "union": f"{span_w}x{span_h}",
244
+ "scale": int(scale) if float(scale).is_integer() else round(float(scale), 4),
245
+ "placed": f"{scaled_w}x{scaled_h}",
246
+ "offset": f"{off_x},{off_y}",
247
+ "overflow": scaled_w > cell_size or scaled_h > cell_size,
248
+ }
249
+
250
+
251
+ def write_gif(placed: list, path: Path, fps: int = 7) -> Path:
252
+ """A looping preview of the row, on a flat backdrop."""
253
+ from PIL import Image
254
+
255
+ flat = []
256
+ for frame in placed:
257
+ back = Image.new("RGBA", frame.size, GIF_BACKDROP)
258
+ back.paste(frame, (0, 0), frame)
259
+ flat.append(back.convert("P", palette=Image.Palette.ADAPTIVE))
260
+ path.parent.mkdir(parents=True, exist_ok=True)
261
+ flat[0].save(path, save_all=True, append_images=flat[1:], duration=round(1000 / fps), loop=0)
262
+ return path
263
+
264
+
265
+ def board_to_row(
266
+ source: Path,
267
+ out: Path,
268
+ *,
269
+ grid: tuple[int, int],
270
+ frames: int | None = None,
271
+ cell: int = CELL,
272
+ scale: int | float | None = None,
273
+ fit: bool = False,
274
+ art: str = "as-is",
275
+ colors: int = 0,
276
+ alpha_floor: int = ALPHA_FLOOR,
277
+ alpha_ceil: int = ALPHA_CEIL,
278
+ chroma: str | None = None,
279
+ tol: float = 60.0,
280
+ feather: float = 24.0,
281
+ chroma_mode: str = "flood",
282
+ gif: Path | None = None,
283
+ fps: int = 7,
284
+ ) -> dict:
285
+ """A whole board through to one row of the sheet. Returns what it measured.
286
+
287
+ `chroma` cuts the key off the whole board at once, before anything is divided. The
288
+ same tolerance and the same flood then apply to all six cells, so every edge comes
289
+ out alike; done cell by cell the contour alpha varies between frames and the
290
+ silhouette flickers.
291
+
292
+ `art` is the direction the row is closed in. The three are not settings of one
293
+ thing, and none of them is the default the others fall back to:
294
+
295
+ - **`pixel-art`** recovers a native grid the art already has, through `pixelfixer`,
296
+ and rewrites the board on it before anything is divided. It needs blocks to find:
297
+ on a smooth render the vote returns nonsense, which is why the report carries
298
+ `native` — that number is how you check a grid was there at all.
299
+ - **`sharp`** assumes there is no grid, because plenty of sprite art has none. The
300
+ reduction into the cell *is* the resolution decision, so all this adds afterwards
301
+ is the alpha treatment and, where asked, one palette — over the assembled row, so
302
+ the frames cannot drift apart.
303
+ - **`as-is`** does neither, for art that already arrived in the direction it was
304
+ wanted in.
305
+
306
+ A sprite can be closed in more than one of them, and each goes in its own directory
307
+ under `sheet/`. Which one a project ships is the project's decision, not this
308
+ function's.
309
+ """
310
+ from PIL import Image
311
+
312
+ cols, rows = grid
313
+ frames = frames or cols * rows
314
+ if frames > cols * rows:
315
+ raise ValueError(f"{frames} frames do not fit a {cols}x{rows} grid")
316
+ if art not in ART_DIRECTIONS:
317
+ raise ValueError(f"art is one of {', '.join(ART_DIRECTIONS)}; got {art!r}")
318
+ if colors and art == "as-is":
319
+ raise ValueError("colors needs an art direction to quantise into")
320
+
321
+ cut_report = None
322
+ if chroma:
323
+ from .imaging import cut_chroma, parse_chroma
324
+
325
+ out.parent.mkdir(parents=True, exist_ok=True)
326
+ cut = out.with_name(f"{out.stem}.cut.png")
327
+ cut.write_bytes(source.read_bytes())
328
+ cut_report = cut_chroma(cut, parse_chroma(chroma), tol, feather, True, 2, chroma_mode)
329
+ source = cut
330
+
331
+ board = Image.open(source).convert("RGBA")
332
+ native = None
333
+ if art == "pixel-art":
334
+ board, found = pixelate_board(board, colors)
335
+ native = f"{found['cols']}x{found['rows']}"
336
+
337
+ cells = cut_cells(board, cols, rows, frames)
338
+ # `sharp` lands the art on the cell, so a figure larger than the cell is the
339
+ # ordinary case there rather than a mistake, and it comes down without being asked.
340
+ placed, report = build_row(cells, scale, cell, fit or art == "sharp")
341
+
342
+ strip = Image.new("RGBA", (cell * len(placed), cell), (0, 0, 0, 0))
343
+ for index, frame in enumerate(placed):
344
+ strip.paste(frame, (index * cell, 0), frame)
345
+
346
+ if art == "sharp":
347
+ # On the whole strip and not on a cell: one palette and one alpha decision for
348
+ # every frame, which is the same reason `detect` runs before the board is cut.
349
+ strip = harden_alpha(strip, alpha_floor, alpha_ceil)
350
+ if colors:
351
+ strip = quantise(strip, colors)
352
+ placed = [
353
+ strip.crop((index * cell, 0, (index + 1) * cell, cell))
354
+ for index in range(len(placed))
355
+ ]
356
+
357
+ out.parent.mkdir(parents=True, exist_ok=True)
358
+ strip.save(out)
359
+
360
+ if gif:
361
+ write_gif(placed, gif, fps)
362
+
363
+ return {
364
+ **report,
365
+ "art": art,
366
+ "native": native,
367
+ "frames": len(placed),
368
+ "cell": cell,
369
+ "chroma": cut_report,
370
+ }
@@ -0,0 +1,303 @@
1
+ """The Claude skill this CLI writes for itself — R5.1, R5.2.
2
+
3
+ One file goes out, `.claude/skills/spritegen/SKILL.md`, and it is the whole skill: how to
4
+ run the tool, the flow a character goes through, the prompt skeletons, and every option of
5
+ every stage. A skill is read by an agent that has the file and nothing else, so splitting
6
+ it across a reference tree buys nothing here and costs a reader who has to guess which
7
+ part holds the answer.
8
+
9
+ The file is half authored and half generated. The prose is shipped beside this module in
10
+ `files/SKILL.md`, which carries marker lines; each marker is replaced with a table read
11
+ out of `stages.STAGES` and `endpoints.MOTION`.
12
+
13
+ That split is the whole point of the command. A skill describing the stages in prose is a
14
+ second copy of the registry, and the copy is the one that goes stale: the CLI gains an
15
+ option, the skill keeps offering the old one, and whoever reads it spends money on a call
16
+ the parser rejects. What the registry knows is generated. What it cannot know — why the
17
+ anchor faces south, why `motion` is preferred over `video` — is prose, and stays prose.
18
+
19
+ A marker the source lost is an error rather than a silent omission: the skill would
20
+ otherwise ship describing no stages at all.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import sys
27
+ from importlib import resources
28
+ from pathlib import Path
29
+
30
+ from .. import endpoints
31
+ from .. import stages as registry
32
+
33
+ #: Where the skill goes, under whichever `.claude` was found.
34
+ SKILL_DIR = Path(".claude") / "skills" / "spritegen"
35
+
36
+ #: The one file. `files/SKILL.md` beside this module is its source.
37
+ SKILL_FILE = "SKILL.md"
38
+
39
+ _STAMP = (
40
+ "<!-- Generated by `spritegen init`. Edit the source in the spritegen\n"
41
+ " repository, at src/spritegen/skill/files/SKILL.md, not this copy:\n"
42
+ " `init --force` rewrites it. -->"
43
+ )
44
+
45
+
46
+ def _stamp(text: str) -> str:
47
+ """`text` with the generated-by note in it, under the frontmatter — R5.5.
48
+
49
+ Injected rather than authored into the source, so it cannot be edited away by
50
+ accident. A YAML block has to stay the first thing in the file, so the note goes
51
+ beneath it.
52
+ """
53
+ if text.startswith("---\n"):
54
+ end = text.find("\n---\n", 4)
55
+ if end != -1:
56
+ cut = end + len("\n---\n")
57
+ return f"{text[:cut]}\n{_STAMP}\n{text[cut:]}"
58
+ return f"{_STAMP}\n\n{text}"
59
+
60
+
61
+ def _source() -> str:
62
+ """The authored half, read out of the package rather than off a path.
63
+
64
+ It travels inside the wheel, so an installed spritegen writes the same skill as one
65
+ run from a checkout.
66
+ """
67
+ return resources.files(__package__).joinpath("files", SKILL_FILE).read_text(encoding="utf-8")
68
+
69
+
70
+ def _takes(option: registry.Option) -> str:
71
+ """What one option accepts, as a reader needs it rather than as argparse sees it."""
72
+ if option.action == "store_true":
73
+ return "flag"
74
+ if option.choices:
75
+ return " / ".join(f"`{value}`" for value in option.choices)
76
+ if option.action == "append":
77
+ return f"`{option.kind}`, repeatable"
78
+ return f"`{option.kind}`"
79
+
80
+
81
+ def _default(option: registry.Option) -> str:
82
+ """That option's default. A `0` is kept rather than shown as absent: several of them
83
+ mean *the whole of it* — every cell of the row, the largest scale that fits."""
84
+ if option.action == "store_true":
85
+ return "off"
86
+ if option.default is None or option.default == "":
87
+ return "-"
88
+ return f"`{option.default}`"
89
+
90
+
91
+ def stage_table() -> str:
92
+ """The pipeline as one table — what each stage needs, writes, and whether it spends."""
93
+ rows = ["| Stage | Needs | Writes | Spends |", "|---|---|---|---|"]
94
+ for stage in registry.STAGES:
95
+ needs = " or ".join(f"`{name}`" for name in stage.requires) if stage.requires else "-"
96
+ spends = "yes" if stage.paid else "no"
97
+ rows.append(f"| `{stage.name}` | {needs} | `{stage.produces}/` | {spends} |")
98
+ return "\n".join(rows)
99
+
100
+
101
+ def option_table(stage: registry.Stage) -> str:
102
+ """Every option that stage declares."""
103
+ if not stage.options:
104
+ return "No options of its own."
105
+ rows = ["| Option | Takes | Default | What it does |", "|---|---|---|---|"]
106
+ for option in stage.options:
107
+ flags = ", ".join(f"`{flag}`" for flag in option.flags)
108
+ help_text = option.help.replace("|", r"\|")
109
+ rows.append(f"| {flags} | {_takes(option)} | {_default(option)} | {help_text} |")
110
+ return "\n".join(rows)
111
+
112
+
113
+ def stage_sections() -> str:
114
+ """One section per stage: what it needs, what it writes, what it costs, its options."""
115
+ parts = []
116
+ for stage in registry.STAGES:
117
+ needs = " or ".join(f"`{name}`" for name in stage.requires) if stage.requires else "nothing"
118
+ cost = "spends money" if stage.paid else "free, and free to re-run"
119
+ parts += [
120
+ f"## `spritegen {stage.name} <asset>`",
121
+ "",
122
+ f"{stage.summary[:1].upper()}{stage.summary[1:]}. Needs {needs}, "
123
+ f"writes `{stage.produces}/`, {cost}.",
124
+ "",
125
+ option_table(stage),
126
+ "",
127
+ ]
128
+ parts.append(
129
+ "Every stage also takes `--force`, to rewrite output it already produced, and every"
130
+ "\npaid one takes `--dry-run`."
131
+ )
132
+ return "\n".join(parts)
133
+
134
+
135
+ def endpoint_table() -> str:
136
+ """The motion endpoints and what each one takes through `--set`.
137
+
138
+ Generated for the same reason the stages are: `--set` is checked against this schema
139
+ before anything is spent, so a skill quoting a stale copy of it walks the operator
140
+ into a rejection they pay a round trip to find out about.
141
+ """
142
+ lines = [
143
+ "## The `motion` endpoints",
144
+ "",
145
+ "`--set NAME=VALUE` is checked against the chosen endpoint before the call is built,"
146
+ " so\nan option this table does not list is refused rather than paid for.",
147
+ "",
148
+ ]
149
+ for name, endpoint in endpoints.MOTION.items():
150
+ default = " (the default)" if name == endpoints.DEFAULT else ""
151
+ lines += [f"**`--endpoint {name}`**{default} — `{endpoint.id}`", "", endpoint.summary, ""]
152
+ if not endpoint.options:
153
+ lines += ["Takes no option of its own.", ""]
154
+ continue
155
+ lines += ["| `--set` | Accepts | Sent unless overridden |", "|---|---|---|"]
156
+ for option, allowed in sorted(endpoint.options.items()):
157
+ takes = " / ".join(f"`{value}`" for value in allowed) if allowed else "any"
158
+ sent = endpoint.defaults.get(option)
159
+ lines.append(f"| `{option}` | {takes} | {'-' if sent is None else f'`{sent}`'} |")
160
+ lines.append("")
161
+ return "\n".join(lines).rstrip("\n")
162
+
163
+
164
+ _FREE = """## The commands that spend nothing
165
+
166
+ | Command | What it does |
167
+ |---|---|
168
+ | `spritegen new <asset>` | open an asset directory; refuses one that already exists |
169
+ | `spritegen status` | every asset, the stage that completed last, the ones that could run now |
170
+ | `spritegen cost [<asset>]` | the paid calls, read from the ledger alone |
171
+ | `spritegen init [--dir <path>] [--force]` | rewrite this skill |"""
172
+
173
+
174
+ def free_commands() -> str:
175
+ """The commands that are not stages. Authored, because the registry does not hold them."""
176
+ return _FREE
177
+
178
+
179
+ #: Each marker in the source, and what replaces it. All four are required — see `render`.
180
+ SECTIONS: dict[str, object] = {
181
+ "<!-- generated: stages -->": stage_table,
182
+ "<!-- generated: options -->": stage_sections,
183
+ "<!-- generated: endpoints -->": endpoint_table,
184
+ "<!-- generated: free -->": free_commands,
185
+ }
186
+
187
+
188
+ def render() -> str:
189
+ """`SKILL.md`, whole — R5.1, R5.2, R5.5."""
190
+ text = _source()
191
+ for marker, section in SECTIONS.items():
192
+ if marker not in text:
193
+ raise ValueError(
194
+ f"{SKILL_FILE} lost its {marker} line; it would ship with that section missing"
195
+ )
196
+ text = text.replace(marker, section())
197
+ return _stamp(text)
198
+
199
+
200
+ def _found_claude(start: Path) -> Path | None:
201
+ """The nearest directory at or above `start` that already holds a `.claude`."""
202
+ for base in (start, *start.parents):
203
+ if (base / ".claude").is_dir():
204
+ return base
205
+ return None
206
+
207
+
208
+ def target(directory: str | Path | None = None, start: Path | None = None) -> Path:
209
+ """The directory the skill goes in — R5.4, R5.6.
210
+
211
+ A named directory wins. Without one, the working directory and each of its parents are
212
+ searched for an existing `.claude`, the same way `cli.load_env` searches for a `.env`
213
+ and for the same reason: this tool is run from inside `spritegen/`, while the checkout
214
+ that wants the skill is the one above it.
215
+
216
+ With no `.claude` anywhere it refuses rather than creating one. A `.claude` is how a
217
+ workspace says it is a workspace, and a skill written into a directory that never had
218
+ one is a file nothing reads — `--dir` is the way to say where, when the answer is not
219
+ a workspace already there.
220
+
221
+ The search does not stop at the home directory, so run from a checkout with no
222
+ `.claude` of its own it finds the user's `~/.claude` and writes a skill available to
223
+ every project. The command prints where it wrote, so that is visible rather than
224
+ silent.
225
+ """
226
+ if directory:
227
+ return Path(directory)
228
+ start = Path.cwd() if start is None else start
229
+ found = _found_claude(start)
230
+ if found is None:
231
+ raise FileNotFoundError(
232
+ f"no .claude directory at {start} or above it; name one with --dir"
233
+ )
234
+ return found / SKILL_DIR
235
+
236
+
237
+ def write(root: Path, *, force: bool = False) -> Path:
238
+ """Write the skill under `root` — R5.1, R5.3."""
239
+ text = render()
240
+ path = root / SKILL_FILE
241
+ if path.exists() and not force:
242
+ raise FileExistsError(f"{path} is already there; pass --force to rewrite it")
243
+ root.mkdir(parents=True, exist_ok=True)
244
+ path.write_text(text, encoding="utf-8")
245
+ return path
246
+
247
+
248
+ def _confirm(question: str, assume_yes: bool) -> bool:
249
+ """Ask on a terminal, and take silence for no anywhere else.
250
+
251
+ `init` writes into somebody else's project, so it asks first. What it must not do is
252
+ block: run from a script, a hook or a harness there is nobody at the other end, and a
253
+ prompt that never returns is worse than a step that did not happen. So a
254
+ non-interactive stdin is a no, and `--yes` is how such a run says yes in advance.
255
+ """
256
+ if assume_yes:
257
+ return True
258
+ if not sys.stdin.isatty():
259
+ print(f"{question} [skipped: nothing is attached to answer; pass --yes]")
260
+ return False
261
+ return input(f"{question} [y/N] ").strip().lower() in {"y", "yes"}
262
+
263
+
264
+ def cmd_init(args: argparse.Namespace) -> int:
265
+ """Set the project up: the asset workspace, and the skill — R5.1.
266
+
267
+ Two acts, and only the first is unconditional. The workspace is this tool's own
268
+ directory and nothing else reads it, so it is made without asking. The skill goes
269
+ into the project's `.claude`, which belongs to whoever works there, so it is asked
270
+ for — see `_confirm` for what happens when there is nobody to ask.
271
+
272
+ ASCII only, for the reason `workspace.cmd_status` is: a Windows console on a codepage
273
+ without an em dash drops the line rather than raising where anyone would see it.
274
+ """
275
+ from .. import workspace
276
+
277
+ assume_yes = getattr(args, "yes", False)
278
+ force = getattr(args, "force", False)
279
+
280
+ root = workspace.assets_root()
281
+ if root.is_dir():
282
+ print(f"workspace already at {root}")
283
+ else:
284
+ root.mkdir(parents=True)
285
+ print(f"workspace created at {root}")
286
+
287
+ try:
288
+ destination = target(getattr(args, "directory", None))
289
+ except FileNotFoundError:
290
+ # No .claude anywhere. Offer the project root rather than refusing outright:
291
+ # this is setup, and a project without one is the ordinary case for setup. A
292
+ # declined offer is still the refusal it always was, and still exits 1 — what
293
+ # changed is that there is now an offer to decline.
294
+ where = workspace.project_root() / SKILL_DIR
295
+ if not _confirm(f"no .claude found; install the skill at {where}?", assume_yes):
296
+ raise
297
+ destination = where
298
+
299
+ # `write` refuses a skill already there, and that refusal is the exit code the CLI
300
+ # reports. Checking it here as well would answer twice and disagree once.
301
+ path = write(destination, force=force)
302
+ print(f"skill written to {path} ({len(path.read_text(encoding='utf-8').splitlines())} lines)")
303
+ return 0