@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.
- package/CHANGELOG.md +123 -0
- package/README.md +32 -25
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/agents/code-reviewer.md +14 -0
- package/app/agents/frontend-specialist.md +27 -24
- package/app/agents/infrastructure-validator.md +2 -2
- package/app/claude-app/hooks/hooks.json +4 -2
- package/app/claude-app/skills/ai-toolkit-rules/SKILL.md +74 -17
- package/app/hooks/quality-gate.sh +9 -2
- package/app/hooks.json +4 -2
- package/app/personas/frontend-lead.md +20 -12
- package/app/rules/claude-toolkit-rules.md +4 -1
- package/app/rules/common/git-team.md +33 -0
- package/app/rules/common/git-workflow.md +6 -20
- package/app/rules/common/performance.md +25 -1
- package/app/rules/common/testing.md +7 -1
- package/app/rules/edit-discipline.md +37 -0
- package/app/skills/app-builder/SKILL.md +37 -6
- package/app/skills/claude-toolkit-rules/SKILL.md +37 -0
- package/app/skills/design-engineering/SKILL.md +87 -102
- package/app/skills/design-engineering/reference/animation-recipes.md +67 -0
- package/app/skills/design-engineering/reference/component-craft.md +28 -0
- package/app/skills/documentation-standards/SKILL.md +1 -1
- package/app/skills/edit-discipline/SKILL.md +50 -0
- package/app/skills/git-conventions/SKILL.md +18 -0
- package/app/skills/output-mode/SKILL.md +41 -0
- package/app/skills/quality-gates/SKILL.md +23 -0
- package/app/skills/review/SKILL.md +10 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +17 -15
- package/bin/ai-toolkit.js +2 -0
- package/kb/procedures/{ecosystem-sync-sop.md → sop-ecosystem-sync.md} +2 -2
- package/kb/procedures/{maintenance-sop.md → sop-maintenance.md} +6 -3
- package/kb/procedures/{post-release-testing-sop.md → sop-post-release-testing.md} +3 -3
- package/kb/procedures/sop-pre-commit.md +96 -0
- package/kb/procedures/{release-preparation-sop.md → sop-release.md} +6 -6
- package/kb/reference/architecture-overview.md +1 -1
- package/kb/reference/cli-reference.md +3 -2
- package/kb/reference/copilot-compatibility.md +1 -1
- package/kb/reference/global-install-model.md +16 -3
- package/kb/reference/hooks-catalog.md +5 -3
- package/kb/reference/language-rules.md +28 -10
- package/kb/reference/licensing.md +2 -2
- package/kb/reference/quick-wins-implementation-summary.md +1 -1
- package/kb/reference/supported-tools-registry.md +2 -2
- package/kb/reference/unique-features.md +2 -1
- package/kb/troubleshooting/plugin-double-load.md +1 -1
- package/llms-full.txt +1016 -871
- package/llms.txt +6 -5
- package/manifest.json +2 -2
- package/package.json +6 -2
- package/scripts/benchmark_ecosystem.py +0 -1
- package/scripts/check_split.py +11 -9
- package/scripts/claude_app.py +5 -7
- package/scripts/codex_skill_adapter.py +4 -12
- package/scripts/compile_slm.py +10 -26
- package/scripts/doctor.py +322 -0
- package/scripts/evaluate_skills.py +1 -1
- package/scripts/frontmatter.py +452 -29
- package/scripts/generate_augment_rules.py +4 -4
- package/scripts/generate_cursor_mdc.py +2 -3
- package/scripts/generate_language_rules_skills.py +8 -14
- package/scripts/generate_llms_txt.py +1 -15
- package/scripts/generate_opencode_agents.py +0 -1
- package/scripts/generate_opencode_skills.py +2 -20
- package/scripts/generate_toolkit_rules_skills.py +189 -0
- package/scripts/generate_windsurf_rules.py +0 -1
- package/scripts/generator_base.py +0 -1
- package/scripts/inject_hook_cli.py +15 -2
- package/scripts/inject_mcp_cli.py +1 -2
- package/scripts/install.py +32 -1
- package/scripts/install_git_hooks.py +0 -1
- package/scripts/install_steps/ai_tools.py +65 -25
- package/scripts/install_steps/markers.py +6 -6
- package/scripts/install_steps/skill_scope.py +188 -0
- package/scripts/instruction_core.py +5 -8
- package/scripts/merge-hooks.py +13 -3
- package/scripts/pack_codebase.py +1 -1
- package/scripts/surface_manifest.py +6 -7
- package/scripts/validate.py +181 -12
- /package/kb/procedures/{release-verification-sop.md → sop-release-verification.md} +0 -0
package/scripts/frontmatter.py
CHANGED
|
@@ -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
|
|
5
|
+
"""The one YAML-frontmatter parser for every Markdown file the toolkit reads.
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
13
|
+
Supported grammar (indentation-based, two spaces per level recommended, any
|
|
14
|
+
consistent indent accepted):
|
|
10
15
|
|
|
11
|
-
|
|
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
|
-
"""
|
|
450
|
+
"""Return one top-level scalar field from a file, or ``""``.
|
|
20
451
|
|
|
21
|
-
|
|
22
|
-
|
|
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,
|
|
111
|
+
all_rules[filename] = (lambda fn, language, g: lambda: _augment_wrap(
|
|
112
112
|
fn(),
|
|
113
|
-
description=f"{
|
|
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,
|
|
119
|
+
all_rules[filename] = (lambda fn, language: lambda: _augment_wrap(
|
|
120
120
|
fn(),
|
|
121
|
-
description=f"{
|
|
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,
|
|
136
|
+
all_rules[mdc_name] = (lambda fn, language, g: lambda: _mdc(
|
|
138
137
|
fn(),
|
|
139
|
-
description=f"{
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 =
|
|
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,
|
|
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")
|