@softspark/ai-toolkit 4.30.3 → 4.32.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 (80) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +32 -25
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/agents/code-reviewer.md +14 -0
  5. package/app/agents/frontend-specialist.md +27 -24
  6. package/app/agents/infrastructure-validator.md +2 -2
  7. package/app/claude-app/hooks/hooks.json +4 -2
  8. package/app/claude-app/skills/ai-toolkit-rules/SKILL.md +74 -17
  9. package/app/hooks/quality-gate.sh +9 -2
  10. package/app/hooks.json +4 -2
  11. package/app/personas/frontend-lead.md +20 -12
  12. package/app/rules/claude-toolkit-rules.md +4 -1
  13. package/app/rules/common/git-team.md +33 -0
  14. package/app/rules/common/git-workflow.md +6 -20
  15. package/app/rules/common/performance.md +25 -1
  16. package/app/rules/common/testing.md +7 -1
  17. package/app/rules/edit-discipline.md +37 -0
  18. package/app/skills/app-builder/SKILL.md +37 -6
  19. package/app/skills/claude-toolkit-rules/SKILL.md +37 -0
  20. package/app/skills/design-engineering/SKILL.md +87 -102
  21. package/app/skills/design-engineering/reference/animation-recipes.md +67 -0
  22. package/app/skills/design-engineering/reference/component-craft.md +28 -0
  23. package/app/skills/documentation-standards/SKILL.md +1 -1
  24. package/app/skills/edit-discipline/SKILL.md +50 -0
  25. package/app/skills/git-conventions/SKILL.md +18 -0
  26. package/app/skills/output-mode/SKILL.md +41 -0
  27. package/app/skills/quality-gates/SKILL.md +23 -0
  28. package/app/skills/review/SKILL.md +10 -0
  29. package/benchmarks/ecosystem-doctor-snapshot.json +17 -15
  30. package/bin/ai-toolkit.js +2 -0
  31. package/kb/procedures/{ecosystem-sync-sop.md → sop-ecosystem-sync.md} +2 -2
  32. package/kb/procedures/{maintenance-sop.md → sop-maintenance.md} +6 -3
  33. package/kb/procedures/{post-release-testing-sop.md → sop-post-release-testing.md} +3 -3
  34. package/kb/procedures/sop-pre-commit.md +96 -0
  35. package/kb/procedures/{release-preparation-sop.md → sop-release.md} +6 -6
  36. package/kb/reference/architecture-overview.md +1 -1
  37. package/kb/reference/cli-reference.md +3 -2
  38. package/kb/reference/copilot-compatibility.md +1 -1
  39. package/kb/reference/global-install-model.md +16 -3
  40. package/kb/reference/hooks-catalog.md +5 -3
  41. package/kb/reference/language-rules.md +28 -10
  42. package/kb/reference/licensing.md +2 -2
  43. package/kb/reference/quick-wins-implementation-summary.md +1 -1
  44. package/kb/reference/supported-tools-registry.md +2 -2
  45. package/kb/reference/unique-features.md +2 -1
  46. package/kb/troubleshooting/plugin-double-load.md +1 -1
  47. package/llms-full.txt +1016 -871
  48. package/llms.txt +6 -5
  49. package/manifest.json +2 -2
  50. package/package.json +6 -2
  51. package/scripts/benchmark_ecosystem.py +0 -1
  52. package/scripts/check_split.py +11 -9
  53. package/scripts/claude_app.py +5 -7
  54. package/scripts/codex_skill_adapter.py +4 -12
  55. package/scripts/compile_slm.py +10 -26
  56. package/scripts/doctor.py +322 -0
  57. package/scripts/evaluate_skills.py +1 -1
  58. package/scripts/frontmatter.py +452 -29
  59. package/scripts/generate_augment_rules.py +4 -4
  60. package/scripts/generate_cursor_mdc.py +2 -3
  61. package/scripts/generate_language_rules_skills.py +8 -14
  62. package/scripts/generate_llms_txt.py +1 -15
  63. package/scripts/generate_opencode_agents.py +0 -1
  64. package/scripts/generate_opencode_skills.py +2 -20
  65. package/scripts/generate_toolkit_rules_skills.py +189 -0
  66. package/scripts/generate_windsurf_rules.py +0 -1
  67. package/scripts/generator_base.py +0 -1
  68. package/scripts/inject_hook_cli.py +15 -2
  69. package/scripts/inject_mcp_cli.py +1 -2
  70. package/scripts/install.py +32 -1
  71. package/scripts/install_git_hooks.py +0 -1
  72. package/scripts/install_steps/ai_tools.py +65 -25
  73. package/scripts/install_steps/markers.py +6 -6
  74. package/scripts/install_steps/skill_scope.py +188 -0
  75. package/scripts/instruction_core.py +5 -8
  76. package/scripts/merge-hooks.py +13 -3
  77. package/scripts/pack_codebase.py +1 -1
  78. package/scripts/surface_manifest.py +6 -7
  79. package/scripts/validate.py +181 -12
  80. /package/kb/procedures/{release-verification-sop.md → sop-release-verification.md} +0 -0
@@ -2,61 +2,484 @@
2
2
  # Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
3
3
  # Source: https://github.com/softspark/ai-toolkit
4
4
 
5
- """YAML frontmatter parsing for agent and skill markdown files.
5
+ """The one YAML-frontmatter parser for every Markdown file the toolkit reads.
6
6
 
7
- Stdlib-only. Extracts fields from ``---`` delimited frontmatter blocks.
7
+ The toolkit emits its own frontmatter and ``validate.py`` polices it, so this
8
+ is deliberately a *subset* of YAML, not a YAML implementation. Anything the
9
+ subset cannot represent is an error, never a silent guess: a stricter parser
10
+ downstream (Claude Code, Codex, an editor plugin) must not see a different
11
+ document than the toolkit saw.
8
12
 
9
- Usage::
13
+ Supported grammar (indentation-based, two spaces per level recommended, any
14
+ consistent indent accepted):
10
15
 
11
- from frontmatter import frontmatter_field, frontmatter_block
16
+ * ``key: value`` with a plain scalar, ``"double"`` / ``'single'`` quoted
17
+ scalar, or a flow list of scalars ``[a, "b"]``.
18
+ * ``key: >-`` / ``>`` / ``|`` / ``|-`` block scalars on the following indented
19
+ lines.
20
+ * ``key:`` followed by an indented block list (``- item``) or nested map.
21
+ List items may themselves be maps (``- matcher: "Bash"`` + indented keys).
22
+ * ``# comment`` lines; a `` #`` after a quoted scalar.
23
+
24
+ Rejected in strict mode (the default) because a YAML parser would read them
25
+ as something else: a plain scalar containing ``: `` or `` #``, anchors and
26
+ tags (``&``, ``*``, ``!``), flow maps (``{``), duplicate keys, quoted keys,
27
+ tabs in indentation, and an unterminated block. ``strict=False`` keeps the
28
+ plain-scalar hazards as literal text for read-only tools (doctor, surface
29
+ manifest) that must describe a broken file rather than refuse it.
30
+
31
+ Stdlib-only. Values come back as ``str``, ``list``, or ``dict``; nothing is
32
+ coerced to bool or int, callers decide.
12
33
  """
13
34
  from __future__ import annotations
14
35
 
15
36
  from pathlib import Path
37
+ from typing import Any, Union
38
+
39
+ __all__ = [
40
+ "FrontmatterError",
41
+ "frontmatter_block",
42
+ "frontmatter_field",
43
+ "frontmatter_sections",
44
+ "load_frontmatter",
45
+ "parse_frontmatter",
46
+ "parse_scalar",
47
+ "split_frontmatter",
48
+ ]
49
+
50
+ Value = Union[str, list[Any], dict[str, Any]]
51
+
52
+ _DELIMITER = "---"
53
+ _BLOCK_INDICATORS = (">", "|")
54
+ _RESERVED_INDICATORS = ("&", "*", "!", "{", "%", "@", "`")
55
+
56
+
57
+ class FrontmatterError(ValueError):
58
+ """A frontmatter block that the toolkit subset cannot represent."""
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Splitting
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def split_frontmatter(text: str) -> tuple[str | None, str]:
66
+ """Return ``(block, body)``.
67
+
68
+ ``block`` is the text between the ``---`` delimiter lines without the
69
+ delimiters, or ``None`` when the document has no frontmatter. ``body`` is
70
+ everything after the closing delimiter line, with the delimiter's own
71
+ newline removed and nothing else stripped, so callers keep whatever
72
+ ``strip()`` policy they already had.
73
+
74
+ A leading BOM and CRLF line endings are normalised. An opening delimiter
75
+ without a closing one is an error, not "no frontmatter": treating it as
76
+ body would ship the half-block as prose.
77
+ """
78
+ if text.startswith("\ufeff"):
79
+ text = text[1:]
80
+ if "\r" in text:
81
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
82
+ if not text.startswith(_DELIMITER):
83
+ return None, text
84
+ first_newline = text.find("\n")
85
+ if first_newline == -1:
86
+ opening = text
87
+ else:
88
+ opening = text[:first_newline]
89
+ if opening.rstrip() != _DELIMITER:
90
+ # `----` or `--- foo` is prose, not a delimiter.
91
+ return None, text
92
+ if first_newline == -1:
93
+ raise FrontmatterError("frontmatter opened on the last line and never closed")
94
+ cursor = first_newline + 1
95
+ while True:
96
+ line_end = text.find("\n", cursor)
97
+ line = text[cursor:] if line_end == -1 else text[cursor:line_end]
98
+ if line.rstrip() == _DELIMITER:
99
+ block = text[first_newline + 1:cursor]
100
+ body = "" if line_end == -1 else text[line_end + 1:]
101
+ return block.rstrip("\n"), body
102
+ if line_end == -1:
103
+ raise FrontmatterError("frontmatter block is not terminated by a '---' line")
104
+ cursor = line_end + 1
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Scalars
109
+ # ---------------------------------------------------------------------------
110
+
111
+ def _unescape_double(raw: str) -> str:
112
+ out: list[str] = []
113
+ i = 0
114
+ while i < len(raw):
115
+ ch = raw[i]
116
+ if ch == "\\" and i + 1 < len(raw):
117
+ nxt = raw[i + 1]
118
+ out.append({"n": "\n", "t": "\t", '"': '"', "\\": "\\"}.get(nxt, "\\" + nxt))
119
+ i += 2
120
+ continue
121
+ out.append(ch)
122
+ i += 1
123
+ return "".join(out)
124
+
125
+
126
+ def _split_quoted(raw: str, quote: str) -> tuple[str, str]:
127
+ """Return ``(inner, remainder)`` for a scalar that starts with ``quote``."""
128
+ i = 1
129
+ while i < len(raw):
130
+ ch = raw[i]
131
+ if quote == '"' and ch == "\\":
132
+ i += 2
133
+ continue
134
+ if ch == quote:
135
+ if quote == "'" and i + 1 < len(raw) and raw[i + 1] == "'":
136
+ i += 2
137
+ continue
138
+ return raw[1:i], raw[i + 1:]
139
+ i += 1
140
+ raise FrontmatterError(f"unterminated {quote} quoted scalar: {raw}")
141
+
142
+
143
+ def _reject_trailing(remainder: str, raw: str) -> None:
144
+ rest = remainder.strip()
145
+ if rest and not rest.startswith("#"):
146
+ raise FrontmatterError(f"unexpected text after quoted scalar: {raw}")
147
+
148
+
149
+ def parse_scalar(raw: str, *, strict: bool = True) -> str:
150
+ """Decode one scalar value as it appears after ``key:``."""
151
+ value = raw.strip()
152
+ if not value:
153
+ return ""
154
+ if value[0] == '"':
155
+ inner, rest = _split_quoted(value, '"')
156
+ _reject_trailing(rest, value)
157
+ return _unescape_double(inner)
158
+ if value[0] == "'":
159
+ inner, rest = _split_quoted(value, "'")
160
+ _reject_trailing(rest, value)
161
+ return inner.replace("''", "'")
162
+ if value[0] in _RESERVED_INDICATORS:
163
+ raise FrontmatterError(
164
+ f"scalar starts with YAML indicator '{value[0]}' (anchor, tag, or flow map); "
165
+ f"quote it: {value}"
166
+ )
167
+ if strict and (": " in value or value.endswith(":")):
168
+ raise FrontmatterError(
169
+ f"plain scalar contains ': ' and would parse as a nested mapping; "
170
+ f"quote it or use a '>-' block: {value}"
171
+ )
172
+ if " #" in value or value.startswith("#"):
173
+ if strict:
174
+ raise FrontmatterError(
175
+ f"plain scalar contains ' #' and would be cut as a comment; quote it: {value}"
176
+ )
177
+ return value.split(" #", 1)[0].rstrip()
178
+ return value
179
+
180
+
181
+ def _parse_flow_list(raw: str, *, strict: bool) -> list[str]:
182
+ inner = raw.strip()[1:-1]
183
+ items: list[str] = []
184
+ current: list[str] = []
185
+ quote: str | None = None
186
+ i = 0
187
+ while i < len(inner):
188
+ ch = inner[i]
189
+ if quote:
190
+ current.append(ch)
191
+ if ch == "\\" and quote == '"' and i + 1 < len(inner):
192
+ current.append(inner[i + 1])
193
+ i += 2
194
+ continue
195
+ if ch == quote:
196
+ quote = None
197
+ elif ch in "\"'":
198
+ quote = ch
199
+ current.append(ch)
200
+ elif ch == ",":
201
+ items.append("".join(current))
202
+ current = []
203
+ elif ch in "[{":
204
+ raise FrontmatterError(f"nested flow collections are unsupported: {raw}")
205
+ else:
206
+ current.append(ch)
207
+ i += 1
208
+ if quote:
209
+ raise FrontmatterError(f"unterminated quote in flow list: {raw}")
210
+ if current or items:
211
+ items.append("".join(current))
212
+ result: list[str] = []
213
+ for item in items:
214
+ if not item.strip():
215
+ if strict:
216
+ raise FrontmatterError(f"empty item in flow list: {raw}")
217
+ continue
218
+ result.append(parse_scalar(item, strict=strict))
219
+ return result
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Block structure
224
+ # ---------------------------------------------------------------------------
225
+
226
+ class _Line:
227
+ __slots__ = ("indent", "number", "text")
228
+
229
+ def __init__(self, number: int, raw: str) -> None:
230
+ if raw[: len(raw) - len(raw.lstrip(" \t"))].find("\t") != -1:
231
+ raise FrontmatterError(f"line {number}: tab in indentation")
232
+ self.number = number
233
+ self.indent = len(raw) - len(raw.lstrip(" "))
234
+ self.text = raw.strip()
235
+
236
+
237
+ def _logical_lines(block: str) -> list[_Line]:
238
+ lines: list[_Line] = []
239
+ for number, raw in enumerate(block.splitlines(), start=2):
240
+ if not raw.strip() or raw.lstrip().startswith("#"):
241
+ continue
242
+ lines.append(_Line(number, raw))
243
+ return lines
244
+
245
+
246
+ def _split_key(line: _Line) -> tuple[str, str]:
247
+ text = line.text
248
+ if text[0] in "\"'":
249
+ raise FrontmatterError(f"line {line.number}: quoted keys are unsupported")
250
+ sep = text.find(":")
251
+ if sep <= 0:
252
+ raise FrontmatterError(f"line {line.number}: expected 'key: value', got: {text}")
253
+ after = text[sep + 1:]
254
+ if after and not after[0].isspace():
255
+ raise FrontmatterError(f"line {line.number}: expected 'key: value', got: {text}")
256
+ key = text[:sep].strip()
257
+ if not key or any(ch.isspace() for ch in key):
258
+ raise FrontmatterError(f"line {line.number}: invalid key: {text}")
259
+ return key, after.strip()
260
+
261
+
262
+ def _block_scalar(lines: list[_Line], index: int, parent_indent: int,
263
+ indicator: str, raw_lines: list[str]) -> tuple[str, int]:
264
+ """Collect an indented block scalar. Returns ``(value, next_index)``."""
265
+ fold = indicator[0] == ">"
266
+ chomp = indicator.endswith("-")
267
+ collected: list[str] = []
268
+ while index < len(lines) and lines[index].indent > parent_indent:
269
+ collected.append(raw_lines[lines[index].number - 2])
270
+ index += 1
271
+ if not collected:
272
+ return "", index
273
+ indent = min(len(line) - len(line.lstrip(" ")) for line in collected)
274
+ stripped = [line[indent:] for line in collected]
275
+ if fold:
276
+ value = " ".join(part.strip() for part in stripped)
277
+ else:
278
+ value = "\n".join(stripped)
279
+ return (value if chomp else value + "\n"), index
280
+
281
+
282
+ def _parse_value(lines: list[_Line], index: int, parent_indent: int,
283
+ raw: str, raw_lines: list[str], *, strict: bool) -> tuple[Value, int]:
284
+ """Parse the value that follows ``key:`` on ``lines[index - 1]``."""
285
+ if raw:
286
+ if raw[0] in _BLOCK_INDICATORS:
287
+ if raw not in (">", ">-", "|", "|-"):
288
+ raise FrontmatterError(f"unsupported block scalar header: {raw}")
289
+ return _block_scalar(lines, index, parent_indent, raw, raw_lines)
290
+ if raw[0] == "[":
291
+ if not raw.endswith("]"):
292
+ raise FrontmatterError(f"flow list must close on the same line: {raw}")
293
+ return _parse_flow_list(raw, strict=strict), index
294
+ return parse_scalar(raw, strict=strict), index
295
+ # Nothing after the colon: an indented list or map follows, or the value is empty.
296
+ if index < len(lines) and lines[index].indent > parent_indent:
297
+ return _parse_block(lines, index, raw_lines, strict=strict)
298
+ return "", index
299
+
300
+
301
+ def _parse_block(lines: list[_Line], index: int, raw_lines: list[str],
302
+ *, strict: bool) -> tuple[Value, int]:
303
+ """Parse a map or list whose first line is ``lines[index]``."""
304
+ indent = lines[index].indent
305
+ if lines[index].text.startswith("- ") or lines[index].text == "-":
306
+ return _parse_list(lines, index, indent, raw_lines, strict=strict)
307
+ return _parse_map(lines, index, indent, raw_lines, strict=strict)
308
+
309
+
310
+ def _parse_map(lines: list[_Line], index: int, indent: int, raw_lines: list[str],
311
+ *, strict: bool) -> tuple[dict[str, Any], int]:
312
+ result: dict[str, Any] = {}
313
+ while index < len(lines) and lines[index].indent == indent:
314
+ line = lines[index]
315
+ if line.text.startswith("- "):
316
+ raise FrontmatterError(f"line {line.number}: list item where a key was expected")
317
+ key, raw = _split_key(line)
318
+ if key in result:
319
+ raise FrontmatterError(f"line {line.number}: duplicate key '{key}'")
320
+ value, index = _parse_value(lines, index + 1, indent, raw, raw_lines, strict=strict)
321
+ result[key] = value
322
+ if index < len(lines) and lines[index].indent > indent:
323
+ raise FrontmatterError(f"line {lines[index].number}: unexpected indentation")
324
+ return result, index
325
+
326
+
327
+ def _parse_list(lines: list[_Line], index: int, indent: int, raw_lines: list[str],
328
+ *, strict: bool) -> tuple[list[Any], int]:
329
+ result: list[Any] = []
330
+ while index < len(lines) and lines[index].indent == indent:
331
+ line = lines[index]
332
+ if not (line.text.startswith("- ") or line.text == "-"):
333
+ raise FrontmatterError(f"line {line.number}: key where a list item was expected")
334
+ item_text = line.text[1:].strip()
335
+ if not item_text:
336
+ # `-` alone: a nested block on the following lines.
337
+ value, index = _parse_value(lines, index + 1, indent, "", raw_lines, strict=strict)
338
+ result.append(value)
339
+ continue
340
+ # A list item that is itself a map starts with `key: value`; its
341
+ # remaining keys sit on following lines indented past the dash.
342
+ if _looks_like_key(item_text):
343
+ key, raw = _split_key(_Line(line.number, " " * (indent + 2) + item_text))
344
+ item_indent = indent + 2
345
+ value, index = _parse_value(lines, index + 1, item_indent, raw, raw_lines, strict=strict)
346
+ item: dict[str, Any] = {key: value}
347
+ while index < len(lines) and lines[index].indent > indent:
348
+ if lines[index].indent != item_indent:
349
+ raise FrontmatterError(f"line {lines[index].number}: unexpected indentation")
350
+ nkey, nraw = _split_key(lines[index])
351
+ if nkey in item:
352
+ raise FrontmatterError(f"line {lines[index].number}: duplicate key '{nkey}'")
353
+ value, index = _parse_value(lines, index + 1, item_indent, nraw, raw_lines, strict=strict)
354
+ item[nkey] = value
355
+ result.append(item)
356
+ continue
357
+ if item_text[0] == "[":
358
+ result.append(_parse_flow_list(item_text, strict=strict))
359
+ else:
360
+ result.append(parse_scalar(item_text, strict=strict))
361
+ index += 1
362
+ return result, index
363
+
364
+
365
+ def _looks_like_key(text: str) -> bool:
366
+ if text[0] in "\"'[":
367
+ return False
368
+ sep = text.find(":")
369
+ if sep <= 0:
370
+ return False
371
+ after = text[sep + 1:]
372
+ key = text[:sep]
373
+ return (not after or after[0].isspace()) and not any(ch.isspace() for ch in key)
374
+
375
+
376
+ # ---------------------------------------------------------------------------
377
+ # Public parsing API
378
+ # ---------------------------------------------------------------------------
379
+
380
+ def parse_frontmatter(text: str, *, strict: bool = True) -> dict[str, Any]:
381
+ """Parse a whole document (or a bare block) into a dict.
382
+
383
+ Accepts either a full Markdown document starting with ``---`` or the
384
+ block text on its own (as ``split_frontmatter`` returns it). A document
385
+ without frontmatter yields ``{}``.
386
+ """
387
+ block: str | None
388
+ if text.lstrip("\ufeff").startswith(_DELIMITER):
389
+ block, _ = split_frontmatter(text)
390
+ else:
391
+ block = text
392
+ if block is None or not block.strip():
393
+ return {}
394
+ raw_lines = block.replace("\r\n", "\n").split("\n")
395
+ lines = _logical_lines(block)
396
+ if not lines:
397
+ return {}
398
+ if lines[0].indent != 0:
399
+ raise FrontmatterError(f"line {lines[0].number}: top-level entry must not be indented")
400
+ if lines[0].text.startswith("- "):
401
+ raise FrontmatterError("frontmatter must be a mapping, not a list")
402
+ result, index = _parse_map(lines, 0, 0, raw_lines, strict=strict)
403
+ if index != len(lines):
404
+ raise FrontmatterError(f"line {lines[index].number}: unexpected content")
405
+ return result
406
+
407
+
408
+ def load_frontmatter(path: str | Path, *, strict: bool = True) -> dict[str, Any]:
409
+ """``parse_frontmatter`` over a file; a missing file yields ``{}``."""
410
+ filepath = Path(path)
411
+ if not filepath.is_file():
412
+ return {}
413
+ return parse_frontmatter(filepath.read_text(encoding="utf-8"), strict=strict)
414
+
415
+
416
+ def frontmatter_sections(block: str) -> dict[str, list[str]]:
417
+ """Group the raw lines of a block by top-level key, verbatim.
418
+
419
+ For tools that re-emit a section unchanged (``generate_opencode_skills``
420
+ copies ``hooks:`` through) and must not normalise quoting or indentation.
421
+ """
422
+ sections: dict[str, list[str]] = {}
423
+ current: str | None = None
424
+ for line in block.splitlines():
425
+ if line and not line[0].isspace() and not line.startswith("#") and ":" in line:
426
+ current = line.split(":", 1)[0].strip()
427
+ sections[current] = [line]
428
+ elif current is not None:
429
+ sections[current].append(line)
430
+ return sections
431
+
432
+
433
+ # ---------------------------------------------------------------------------
434
+ # Compatibility helpers (pre-v4.31 API, kept for the 19 existing importers)
435
+ # ---------------------------------------------------------------------------
436
+
437
+ def frontmatter_block(filepath: str | Path) -> str:
438
+ """Return the raw frontmatter text (excluding ``---`` delimiters)."""
439
+ filepath = Path(filepath)
440
+ if not filepath.is_file():
441
+ return ""
442
+ try:
443
+ block, _ = split_frontmatter(filepath.read_text(encoding="utf-8"))
444
+ except FrontmatterError:
445
+ return ""
446
+ return block or ""
16
447
 
17
448
 
18
449
  def frontmatter_field(filepath: str | Path, field: str) -> str:
19
- """Extract a YAML frontmatter field value from a file.
450
+ """Return one top-level scalar field from a file, or ``""``.
20
451
 
21
- Reads lines between the first pair of ``---`` delimiters and returns
22
- the value for the given field. Strips surrounding quotes.
452
+ Tolerant by design: this is the read path for generators that must keep
453
+ producing output while ``validate.py`` is what rejects a broken file.
454
+ Block scalars are folded to one line; a list or map value yields ``""``.
23
455
  """
24
456
  filepath = Path(filepath)
25
457
  if not filepath.is_file():
26
458
  return ""
459
+ try:
460
+ data = parse_frontmatter(filepath.read_text(encoding="utf-8"), strict=False)
461
+ except FrontmatterError:
462
+ return _legacy_field(filepath, field)
463
+ value = data.get(field, "")
464
+ if isinstance(value, str):
465
+ return value.replace("\n", " ").strip()
466
+ return ""
467
+
468
+
469
+ def _legacy_field(filepath: Path, field: str) -> str:
470
+ """Line scan for files the subset parser refuses, so nothing regresses."""
27
471
  in_frontmatter = False
28
472
  with open(filepath, encoding="utf-8") as f:
29
473
  for line in f:
30
474
  stripped = line.rstrip("\n")
31
- if stripped == "---":
475
+ if stripped == _DELIMITER:
32
476
  if in_frontmatter:
33
477
  break
34
478
  in_frontmatter = True
35
479
  continue
36
480
  if in_frontmatter and stripped.startswith(f"{field}:"):
37
481
  value = stripped[len(field) + 1:].strip()
38
- # Strip surrounding quotes
39
482
  if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
40
483
  value = value[1:-1]
41
484
  return value
42
485
  return ""
43
-
44
-
45
- def frontmatter_block(filepath: str | Path) -> str:
46
- """Return the raw frontmatter text (excluding --- delimiters)."""
47
- filepath = Path(filepath)
48
- if not filepath.is_file():
49
- return ""
50
- lines: list[str] = []
51
- in_fm = False
52
- with open(filepath, encoding="utf-8") as f:
53
- for line in f:
54
- stripped = line.rstrip("\n")
55
- if stripped == "---":
56
- if in_fm:
57
- break
58
- in_fm = True
59
- continue
60
- if in_fm:
61
- lines.append(stripped)
62
- return "\n".join(lines)
@@ -108,17 +108,17 @@ def generate(target_dir: Path, *,
108
108
  lang = filename.removeprefix(f"{PREFIX}lang-").removesuffix(".md")
109
109
  globs = LANG_GLOBS.get(lang)
110
110
  if globs:
111
- all_rules[filename] = (lambda fn, l, g: lambda: _augment_wrap(
111
+ all_rules[filename] = (lambda fn, language, g: lambda: _augment_wrap(
112
112
  fn(),
113
- description=f"{l.title()} language rules",
113
+ description=f"{language.title()} language rules",
114
114
  rule_type="agent_requested",
115
115
  globs=g,
116
116
  ))(content_fn, lang, globs)
117
117
  else:
118
118
  # common — always apply
119
- all_rules[filename] = (lambda fn, l: lambda: _augment_wrap(
119
+ all_rules[filename] = (lambda fn, language: lambda: _augment_wrap(
120
120
  fn(),
121
- description=f"{l.title()} language rules",
121
+ description=f"{language.title()} language rules",
122
122
  ))(content_fn, lang)
123
123
 
124
124
  # Add registered rules with Augment frontmatter
@@ -41,7 +41,6 @@ from dir_rules_shared import (
41
41
  PREFIX,
42
42
  build_language_rules,
43
43
  build_registered_rules,
44
- cleanup_stale,
45
44
  rule_agents_and_skills,
46
45
  rule_code_style,
47
46
  rule_quality_standards,
@@ -134,9 +133,9 @@ def generate(target_dir: Path, *,
134
133
  lang = filename.removeprefix(f"{PREFIX}lang-").removesuffix(".md")
135
134
  globs = LANG_GLOBS.get(lang)
136
135
  mdc_name = filename.replace(".md", ".mdc")
137
- all_rules[mdc_name] = (lambda fn, l, g: lambda: _mdc(
136
+ all_rules[mdc_name] = (lambda fn, language, g: lambda: _mdc(
138
137
  fn(),
139
- description=f"{l.title()} language rules",
138
+ description=f"{language.title()} language rules",
140
139
  globs=g if g else None,
141
140
  always_apply=not g,
142
141
  ))(content_fn, lang, globs)
@@ -15,9 +15,10 @@ block in ``.claude/CLAUDE.md``: instead of nudging Claude to Read absolute
15
15
  nvm-pinned paths on demand, the rules ride on the Agent Skills mechanism.
16
16
 
17
17
  Common rules (``app/rules/common/``) are installed as Claude Code
18
- path-scoped ``.claude/rules/ai-toolkit-*.md`` files by ``install --local``.
19
- This keeps ``CLAUDE.md`` below Claude's current size guidance while still
20
- loading common guidance when project files are opened.
18
+ ``.claude/rules/ai-toolkit-*.md`` files by ``install --local``. Each source
19
+ rule's ``paths`` frontmatter decides whether it is always-on or loads only
20
+ for matching files. This keeps ``CLAUDE.md`` below Claude's current size
21
+ guidance without loading file-type-specific guidance in every session.
21
22
 
22
23
  Idempotent: rerunning overwrites generated SKILL.md but leaves any other
23
24
  files in the skill directory alone.
@@ -34,6 +35,9 @@ import sys
34
35
  from pathlib import Path
35
36
  from textwrap import dedent
36
37
 
38
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
39
+ from frontmatter import split_frontmatter # noqa: E402
40
+
37
41
  ROOT = Path(__file__).resolve().parent.parent
38
42
  RULES_DIR = ROOT / "app" / "rules"
39
43
  SKILLS_DIR = ROOT / "app" / "skills"
@@ -98,16 +102,6 @@ TRIGGERS: dict[str, dict[str, str]] = {
98
102
  }
99
103
 
100
104
 
101
- def _strip_frontmatter(text: str) -> str:
102
- """Remove YAML frontmatter (--- ... ---) if present."""
103
- if not text.startswith("---"):
104
- return text.lstrip("\n")
105
- end = text.find("\n---", 3)
106
- if end == -1:
107
- return text.lstrip("\n")
108
- return text[end + 4:].lstrip("\n")
109
-
110
-
111
105
  def _category_title(stem: str) -> str:
112
106
  """Convert filename stem (e.g. ``coding-style``) to a section title."""
113
107
  return " ".join(part.capitalize() for part in stem.split("-"))
@@ -117,7 +111,7 @@ def _build_skill_body(lang_dir: Path) -> str:
117
111
  """Concatenate all rule category files into a skill body."""
118
112
  parts: list[str] = []
119
113
  for f in sorted(lang_dir.glob("*.md")):
120
- body = _strip_frontmatter(f.read_text(encoding="utf-8")).rstrip()
114
+ body = split_frontmatter(f.read_text(encoding="utf-8"))[1].lstrip("\n").rstrip()
121
115
  # If the source file already starts with a top-level "# Title", keep
122
116
  # it. Otherwise, prepend a "## Category" header so the skill body
123
117
  # has structure.
@@ -11,12 +11,11 @@ Usage:
11
11
  """
12
12
  from __future__ import annotations
13
13
 
14
- import re
15
14
  import sys
16
15
  from pathlib import Path
17
16
 
18
17
  sys.path.insert(0, str(Path(__file__).resolve().parent))
19
- from _common import agents_dir, frontmatter_field, skill_count, skills_dir, toolkit_dir
18
+ from _common import agents_dir, frontmatter_field, skills_dir, toolkit_dir
20
19
 
21
20
 
22
21
  def _relative(path: Path) -> str:
@@ -51,21 +50,8 @@ def _find_agent_files() -> list[Path]:
51
50
  return sorted(agents_dir.glob("*.md"))
52
51
 
53
52
 
54
- def _count_skills() -> int:
55
- """Count skill directories containing SKILL.md."""
56
- return skill_count()
57
-
58
-
59
- def _count_agents() -> int:
60
- """Count agent .md files."""
61
- return sum(1 for f in agents_dir.glob("*.md") if f.is_file())
62
-
63
-
64
53
  def generate_index() -> None:
65
54
  """Print the llms.txt index."""
66
- skills = _count_skills()
67
- agents = _count_agents()
68
-
69
55
  print("# ai-toolkit")
70
56
  print()
71
57
  print(
@@ -48,7 +48,6 @@ def _agent_body(agent_file: Path) -> str:
48
48
 
49
49
  def _render_opencode_agent(agent_file: Path) -> str:
50
50
  """Render a single opencode subagent .md file from an ai-toolkit agent."""
51
- name = frontmatter_field(agent_file, "name")
52
51
  description = frontmatter_field(agent_file, "description")
53
52
  model = frontmatter_field(agent_file, "model")
54
53
  color = frontmatter_field(agent_file, "color")