@runecraft/grimoire 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/catalog.json +9 -0
- package/dist/grimoire.js +1758 -0
- package/package.json +54 -0
- package/references/definition-of-done.md +67 -0
- package/references/testing-patterns.md +260 -0
- package/skills/code-review-and-quality/README.md +13 -0
- package/skills/code-review-and-quality/SKILL.md +389 -0
- package/skills/code-simplification/README.md +13 -0
- package/skills/code-simplification/SKILL.md +338 -0
- package/skills/debugging-and-error-recovery/README.md +13 -0
- package/skills/debugging-and-error-recovery/SKILL.md +343 -0
- package/skills/debugging-and-error-recovery/scripts/__pycache__/triage_state.cpython-314.pyc +0 -0
- package/skills/debugging-and-error-recovery/scripts/triage_state.py +206 -0
- package/skills/deprecation-and-migration/README.md +13 -0
- package/skills/deprecation-and-migration/SKILL.md +248 -0
- package/skills/deprecation-and-migration/scripts/__pycache__/migration_tracker.cpython-314.pyc +0 -0
- package/skills/deprecation-and-migration/scripts/migration_tracker.py +237 -0
- package/skills/doubt-driven-development/README.md +13 -0
- package/skills/doubt-driven-development/SKILL.md +251 -0
- package/skills/git-commit-learning/.skill-meta.json +14 -0
- package/skills/git-commit-learning/README.md +205 -0
- package/skills/git-commit-learning/SKILL.md +435 -0
- package/skills/git-commit-learning/references/commit-patterns.md +595 -0
- package/skills/git-worktree/README.md +13 -0
- package/skills/git-worktree/SKILL.md +220 -0
- package/skills/idea-refine/README.md +13 -0
- package/skills/idea-refine/SKILL.md +186 -0
- package/skills/interview-me/README.md +13 -0
- package/skills/interview-me/SKILL.md +233 -0
- package/skills/linkedin-audit/SKILL.md +98 -0
- package/skills/linkedin-audit/references/dashboard-spec.md +43 -0
- package/skills/memory-management/README.md +13 -0
- package/skills/memory-management/SKILL.md +198 -0
- package/skills/security-and-hardening/README.md +13 -0
- package/skills/security-and-hardening/SKILL.md +472 -0
- package/skills/shipping-and-launch/README.md +13 -0
- package/skills/shipping-and-launch/SKILL.md +317 -0
- package/skills/skill-forge/README.md +153 -0
- package/skills/skill-forge/SKILL.md +291 -0
- package/skills/skill-forge/assets/SKILL.template.md +73 -0
- package/skills/skill-forge/references/authoring-patterns.md +249 -0
- package/skills/skill-forge/references/description-optimization.md +171 -0
- package/skills/skill-forge/references/output-evaluation.md +276 -0
- package/skills/skill-forge/references/scripts-guide.md +232 -0
- package/skills/skill-forge/references/spec.md +175 -0
- package/skills/skill-forge/scripts/validate.py +536 -0
- package/skills/spec-driven/.skill-meta.json +14 -0
- package/skills/spec-driven/README.md +335 -0
- package/skills/spec-driven/SKILL.md +174 -0
- package/skills/spec-driven/references/code-analysis.md +98 -0
- package/skills/spec-driven/references/coding-principles.md +56 -0
- package/skills/spec-driven/references/context-limits.md +31 -0
- package/skills/spec-driven/references/design.md +199 -0
- package/skills/spec-driven/references/discuss.md +136 -0
- package/skills/spec-driven/references/implement.md +425 -0
- package/skills/spec-driven/references/lessons.md +113 -0
- package/skills/spec-driven/references/memory.md +126 -0
- package/skills/spec-driven/references/specify.md +210 -0
- package/skills/spec-driven/references/sub-agents.md +96 -0
- package/skills/spec-driven/references/tasks.md +484 -0
- package/skills/spec-driven/references/validate.md +350 -0
- package/skills/spec-driven/scripts/__pycache__/lessons.cpython-314.pyc +0 -0
- package/skills/spec-driven/scripts/lessons.py +370 -0
- package/skills/spec-loop/README.md +36 -0
- package/skills/spec-loop/SKILL.md +61 -0
- package/skills/test-driven-development/README.md +13 -0
- package/skills/test-driven-development/SKILL.md +388 -0
- package/skills/typescript-patterns/README.md +13 -0
- package/skills/typescript-patterns/SKILL.md +346 -0
- package/skills/using-agent-skills/README.md +13 -0
- package/skills/using-agent-skills/SKILL.md +187 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# ///
|
|
5
|
+
"""
|
|
6
|
+
Validate a skill folder against the open SKILL.md format.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 scripts/validate.py <path-to-skill-folder>
|
|
10
|
+
python3 scripts/validate.py <path-to-skill-folder> --format json
|
|
11
|
+
python3 scripts/validate.py <path-to-skill-folder> --json-out /tmp/skill-report.json
|
|
12
|
+
python3 scripts/validate.py --help
|
|
13
|
+
|
|
14
|
+
Exit codes:
|
|
15
|
+
0 = pass (warnings allowed)
|
|
16
|
+
1 = fail (at least one error)
|
|
17
|
+
2 = usage error (bad arguments, missing path)
|
|
18
|
+
|
|
19
|
+
Errors are format violations. Warnings are quality nudges.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Errors are spec violations; warnings are quality nudges.
|
|
33
|
+
SEVERITY_ERROR = "error"
|
|
34
|
+
SEVERITY_WARNING = "warning"
|
|
35
|
+
|
|
36
|
+
# name: lowercase letters, digits, hyphens. 1-64 chars.
|
|
37
|
+
NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
38
|
+
NAME_MAX = 64
|
|
39
|
+
DESCRIPTION_MAX = 1024
|
|
40
|
+
COMPATIBILITY_MAX = 500
|
|
41
|
+
BODY_MAX_LINES = 500
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FrontmatterParseError(ValueError):
|
|
45
|
+
"""Raised when frontmatter parsing fails."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Frontmatter parsing (stdlib only)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_scalar(raw: str) -> Any:
|
|
54
|
+
value = raw.strip()
|
|
55
|
+
if value == "":
|
|
56
|
+
return ""
|
|
57
|
+
if (value.startswith('"') and value.endswith('"')) or (
|
|
58
|
+
value.startswith("'") and value.endswith("'")
|
|
59
|
+
):
|
|
60
|
+
return value[1:-1]
|
|
61
|
+
lower = value.lower()
|
|
62
|
+
if lower == "true":
|
|
63
|
+
return True
|
|
64
|
+
if lower == "false":
|
|
65
|
+
return False
|
|
66
|
+
if lower in {"null", "none", "~"}:
|
|
67
|
+
return None
|
|
68
|
+
if re.fullmatch(r"-?\d+", value):
|
|
69
|
+
return int(value)
|
|
70
|
+
if re.fullmatch(r"-?\d+\.\d+", value):
|
|
71
|
+
return float(value)
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _collect_block_scalar(
|
|
76
|
+
lines: list[str], start_idx: int, min_indent: int, folded: bool
|
|
77
|
+
) -> tuple[str, int]:
|
|
78
|
+
block_lines: list[str] = []
|
|
79
|
+
i = start_idx
|
|
80
|
+
while i < len(lines):
|
|
81
|
+
line = lines[i]
|
|
82
|
+
if line.strip() == "":
|
|
83
|
+
block_lines.append("")
|
|
84
|
+
i += 1
|
|
85
|
+
continue
|
|
86
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
87
|
+
if indent < min_indent:
|
|
88
|
+
break
|
|
89
|
+
block_lines.append(line[min_indent:])
|
|
90
|
+
i += 1
|
|
91
|
+
|
|
92
|
+
if not folded:
|
|
93
|
+
return ("\n".join(block_lines)).rstrip(), i
|
|
94
|
+
|
|
95
|
+
paragraphs: list[str] = []
|
|
96
|
+
current: list[str] = []
|
|
97
|
+
for line in block_lines:
|
|
98
|
+
if line == "":
|
|
99
|
+
if current:
|
|
100
|
+
paragraphs.append(" ".join(current))
|
|
101
|
+
current = []
|
|
102
|
+
paragraphs.append("")
|
|
103
|
+
continue
|
|
104
|
+
current.append(line.strip())
|
|
105
|
+
if current:
|
|
106
|
+
paragraphs.append(" ".join(current))
|
|
107
|
+
return ("\n".join(paragraphs)).rstrip(), i
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_frontmatter_stdlib(frontmatter_raw: str) -> dict[str, Any]:
|
|
111
|
+
"""
|
|
112
|
+
Parse a conservative subset of YAML with stdlib only.
|
|
113
|
+
Supports: top-level key:value, one-level nested mapping, block scalars (| and >).
|
|
114
|
+
"""
|
|
115
|
+
lines = frontmatter_raw.splitlines()
|
|
116
|
+
data: dict[str, Any] = {}
|
|
117
|
+
i = 0
|
|
118
|
+
top_key_pattern = re.compile(r"^([A-Za-z0-9_-]+)\s*:\s*(.*)$")
|
|
119
|
+
block_markers = {"|", ">", "|-", ">-"}
|
|
120
|
+
|
|
121
|
+
while i < len(lines):
|
|
122
|
+
line = lines[i]
|
|
123
|
+
stripped = line.strip()
|
|
124
|
+
if stripped == "" or stripped.startswith("#"):
|
|
125
|
+
i += 1
|
|
126
|
+
continue
|
|
127
|
+
if line.startswith(" "):
|
|
128
|
+
raise FrontmatterParseError(
|
|
129
|
+
f"Unexpected top-level indentation near line {i + 1}: {line}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
top_match = top_key_pattern.match(line)
|
|
133
|
+
if not top_match:
|
|
134
|
+
raise FrontmatterParseError(
|
|
135
|
+
f"Invalid top-level entry near line {i + 1}: {line}"
|
|
136
|
+
)
|
|
137
|
+
key, raw_value = top_match.group(1), top_match.group(2).strip()
|
|
138
|
+
|
|
139
|
+
if raw_value in block_markers:
|
|
140
|
+
folded = raw_value.startswith(">")
|
|
141
|
+
parsed_block, next_idx = _collect_block_scalar(lines, i + 1, min_indent=2, folded=folded)
|
|
142
|
+
data[key] = parsed_block
|
|
143
|
+
i = next_idx
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
if raw_value != "":
|
|
147
|
+
if raw_value.startswith("- "):
|
|
148
|
+
raise FrontmatterParseError(
|
|
149
|
+
f"List syntax requires PyYAML near line {i + 1}."
|
|
150
|
+
)
|
|
151
|
+
data[key] = _parse_scalar(raw_value)
|
|
152
|
+
i += 1
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
# raw_value == "" — possibly nested mapping
|
|
156
|
+
j = i + 1
|
|
157
|
+
while j < len(lines):
|
|
158
|
+
candidate = lines[j]
|
|
159
|
+
if candidate.strip() == "" or candidate.strip().startswith("#"):
|
|
160
|
+
j += 1
|
|
161
|
+
continue
|
|
162
|
+
if not candidate.startswith(" "):
|
|
163
|
+
break
|
|
164
|
+
j += 1
|
|
165
|
+
|
|
166
|
+
nested_lines = lines[i + 1 : j]
|
|
167
|
+
if not any(nl.strip() and not nl.strip().startswith("#") for nl in nested_lines):
|
|
168
|
+
data[key] = ""
|
|
169
|
+
i = j
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
nested_data: dict[str, Any] = {}
|
|
173
|
+
k = i + 1
|
|
174
|
+
while k < j:
|
|
175
|
+
nested_line = lines[k]
|
|
176
|
+
ns = nested_line.strip()
|
|
177
|
+
if ns == "" or ns.startswith("#"):
|
|
178
|
+
k += 1
|
|
179
|
+
continue
|
|
180
|
+
if not nested_line.startswith(" "):
|
|
181
|
+
raise FrontmatterParseError(
|
|
182
|
+
f"Invalid nested indentation near line {k + 1}: {nested_line}"
|
|
183
|
+
)
|
|
184
|
+
content = nested_line[2:]
|
|
185
|
+
if content.startswith(" "):
|
|
186
|
+
raise FrontmatterParseError(
|
|
187
|
+
f"Deep nesting requires PyYAML near line {k + 1}."
|
|
188
|
+
)
|
|
189
|
+
if content.startswith("- "):
|
|
190
|
+
raise FrontmatterParseError(
|
|
191
|
+
f"List syntax requires PyYAML near line {k + 1}."
|
|
192
|
+
)
|
|
193
|
+
nested_match = top_key_pattern.match(content)
|
|
194
|
+
if not nested_match:
|
|
195
|
+
raise FrontmatterParseError(
|
|
196
|
+
f"Invalid nested mapping near line {k + 1}: {nested_line}"
|
|
197
|
+
)
|
|
198
|
+
child_key, child_raw = nested_match.group(1), nested_match.group(2).strip()
|
|
199
|
+
if child_raw in block_markers:
|
|
200
|
+
folded = child_raw.startswith(">")
|
|
201
|
+
child_block, next_k = _collect_block_scalar(
|
|
202
|
+
lines, k + 1, min_indent=4, folded=folded
|
|
203
|
+
)
|
|
204
|
+
nested_data[child_key] = child_block
|
|
205
|
+
k = next_k
|
|
206
|
+
continue
|
|
207
|
+
nested_data[child_key] = _parse_scalar(child_raw)
|
|
208
|
+
k += 1
|
|
209
|
+
|
|
210
|
+
data[key] = nested_data
|
|
211
|
+
i = j
|
|
212
|
+
|
|
213
|
+
return data
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Validation checks
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def check_skill(skill_path: str) -> dict:
|
|
222
|
+
results: dict = {
|
|
223
|
+
"path": skill_path,
|
|
224
|
+
"checks": [],
|
|
225
|
+
"passed": 0,
|
|
226
|
+
"failed": 0,
|
|
227
|
+
"warnings": 0,
|
|
228
|
+
"next_steps": [],
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
def add(name: str, passed: bool, message: str, severity: str = SEVERITY_ERROR):
|
|
232
|
+
results["checks"].append(
|
|
233
|
+
{"name": name, "passed": passed, "message": message, "severity": severity}
|
|
234
|
+
)
|
|
235
|
+
if passed:
|
|
236
|
+
results["passed"] += 1
|
|
237
|
+
elif severity == SEVERITY_WARNING:
|
|
238
|
+
results["warnings"] += 1
|
|
239
|
+
else:
|
|
240
|
+
results["failed"] += 1
|
|
241
|
+
|
|
242
|
+
# 1. Folder exists
|
|
243
|
+
if not os.path.isdir(skill_path):
|
|
244
|
+
add("folder_exists", False, f"Path is not a directory: {skill_path}")
|
|
245
|
+
results["summary"] = "FAIL — folder not found"
|
|
246
|
+
return results
|
|
247
|
+
add("folder_exists", True, "Skill folder exists")
|
|
248
|
+
|
|
249
|
+
# 2. Folder name is kebab-case (a-z, 0-9, single hyphens, not at edges)
|
|
250
|
+
folder_name = os.path.basename(os.path.normpath(skill_path))
|
|
251
|
+
is_folder_kebab = bool(NAME_PATTERN.match(folder_name)) and 1 <= len(folder_name) <= NAME_MAX
|
|
252
|
+
add(
|
|
253
|
+
"folder_kebab_case",
|
|
254
|
+
is_folder_kebab,
|
|
255
|
+
f"Folder name '{folder_name}' "
|
|
256
|
+
f"{'is' if is_folder_kebab else 'is NOT'} valid (lowercase a-z, 0-9, single hyphens, {NAME_MAX} chars max).",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
entries = os.listdir(skill_path)
|
|
260
|
+
|
|
261
|
+
# 3. SKILL.md exists (exact casing)
|
|
262
|
+
has_skill_md = "SKILL.md" in entries
|
|
263
|
+
add("skill_md_exists", has_skill_md, "SKILL.md exists" if has_skill_md else "SKILL.md not found")
|
|
264
|
+
wrong_casings = [e for e in entries if e.lower() == "skill.md" and e != "SKILL.md"]
|
|
265
|
+
if wrong_casings:
|
|
266
|
+
add(
|
|
267
|
+
"skill_md_casing",
|
|
268
|
+
False,
|
|
269
|
+
f"Wrong casing: '{wrong_casings[0]}' (must be exactly 'SKILL.md').",
|
|
270
|
+
)
|
|
271
|
+
if not has_skill_md:
|
|
272
|
+
results["summary"] = "FAIL — SKILL.md not found"
|
|
273
|
+
return results
|
|
274
|
+
|
|
275
|
+
# 4. README.md inside the skill folder (house style, not a spec violation)
|
|
276
|
+
has_readme = any(e.lower() == "readme.md" for e in entries)
|
|
277
|
+
add(
|
|
278
|
+
"readme_in_skill",
|
|
279
|
+
not has_readme,
|
|
280
|
+
"No README.md inside the skill folder" if not has_readme
|
|
281
|
+
else "README.md found inside the skill folder. The SKILL.md format allows extra files, but the convention is to keep human docs outside the skill folder (parent package README). Move it to the parent package and reference it from catalog docs.",
|
|
282
|
+
severity=SEVERITY_WARNING,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# 5. Parse frontmatter
|
|
286
|
+
skill_md_path = os.path.join(skill_path, "SKILL.md")
|
|
287
|
+
with open(skill_md_path, "r", encoding="utf-8") as f:
|
|
288
|
+
content = f.read()
|
|
289
|
+
|
|
290
|
+
fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
|
|
291
|
+
if not fm_match:
|
|
292
|
+
add("frontmatter_delimiters", False, "Missing or malformed '---' delimiters")
|
|
293
|
+
results["summary"] = "FAIL — frontmatter parse error"
|
|
294
|
+
return results
|
|
295
|
+
add("frontmatter_delimiters", True, "YAML frontmatter delimiters present")
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
fm = _parse_frontmatter_stdlib(fm_match.group(1))
|
|
299
|
+
add("frontmatter_valid_yaml", True, "Frontmatter parsed (stdlib subset; install PyYAML for full YAML).")
|
|
300
|
+
except Exception as e:
|
|
301
|
+
add("frontmatter_valid_yaml", False, f"YAML parse error: {e}")
|
|
302
|
+
results["summary"] = "FAIL — YAML parse error"
|
|
303
|
+
return results
|
|
304
|
+
|
|
305
|
+
# 6. name field
|
|
306
|
+
name = fm.get("name")
|
|
307
|
+
if not name:
|
|
308
|
+
add("name_present", False, "Missing required 'name' field in frontmatter")
|
|
309
|
+
else:
|
|
310
|
+
name_str = str(name)
|
|
311
|
+
add("name_present", True, f"name: {name_str}")
|
|
312
|
+
is_name_kebab = bool(NAME_PATTERN.match(name_str)) and 1 <= len(name_str) <= NAME_MAX
|
|
313
|
+
add(
|
|
314
|
+
"name_kebab_case",
|
|
315
|
+
is_name_kebab,
|
|
316
|
+
f"name '{name_str}' {'is' if is_name_kebab else 'is NOT'} valid kebab-case.",
|
|
317
|
+
)
|
|
318
|
+
names_match = name_str == folder_name
|
|
319
|
+
add(
|
|
320
|
+
"name_matches_folder",
|
|
321
|
+
names_match,
|
|
322
|
+
f"name '{name_str}' {'matches' if names_match else 'does NOT match'} folder '{folder_name}'.",
|
|
323
|
+
severity=SEVERITY_WARNING,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# 7. description field
|
|
327
|
+
desc = fm.get("description")
|
|
328
|
+
if not desc:
|
|
329
|
+
add("description_present", False, "Missing required 'description' field in frontmatter")
|
|
330
|
+
else:
|
|
331
|
+
desc_str = str(desc).strip()
|
|
332
|
+
add("description_present", True, f"description present ({len(desc_str)} chars)")
|
|
333
|
+
add(
|
|
334
|
+
"description_length",
|
|
335
|
+
len(desc_str) <= DESCRIPTION_MAX,
|
|
336
|
+
f"Description length: {len(desc_str)}/{DESCRIPTION_MAX} chars",
|
|
337
|
+
)
|
|
338
|
+
has_xml = "<" in desc_str or ">" in desc_str
|
|
339
|
+
add(
|
|
340
|
+
"description_no_xml",
|
|
341
|
+
not has_xml,
|
|
342
|
+
"No XML angle brackets in description" if not has_xml
|
|
343
|
+
else "Description contains XML angle brackets '<>' (forbidden by spec).",
|
|
344
|
+
)
|
|
345
|
+
# Trigger guidance is a quality nudge, not a spec error.
|
|
346
|
+
trigger_keywords = ["use when", "use for", "use this", "when the user", "when you"]
|
|
347
|
+
has_triggers = any(kw in desc_str.lower() for kw in trigger_keywords)
|
|
348
|
+
add(
|
|
349
|
+
"description_has_triggers",
|
|
350
|
+
has_triggers,
|
|
351
|
+
"Description includes trigger guidance ('Use when...')" if has_triggers
|
|
352
|
+
else "Consider adding 'Use when...' trigger guidance to improve agent pickup.",
|
|
353
|
+
severity=SEVERITY_WARNING,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# 8. license (optional, warning if missing for catalog publishing)
|
|
357
|
+
if "license" not in fm or not fm.get("license"):
|
|
358
|
+
add(
|
|
359
|
+
"license_present",
|
|
360
|
+
False,
|
|
361
|
+
"Missing optional 'license' field. Recommended for published skills (e.g. 'CC-BY-4.0').",
|
|
362
|
+
severity=SEVERITY_WARNING,
|
|
363
|
+
)
|
|
364
|
+
else:
|
|
365
|
+
add("license_present", True, f"license: {fm.get('license')}")
|
|
366
|
+
|
|
367
|
+
# 9. compatibility (optional, length check)
|
|
368
|
+
compat = fm.get("compatibility")
|
|
369
|
+
if compat:
|
|
370
|
+
compat_str = str(compat)
|
|
371
|
+
add(
|
|
372
|
+
"compatibility_length",
|
|
373
|
+
len(compat_str) <= COMPATIBILITY_MAX,
|
|
374
|
+
f"compatibility length: {len(compat_str)}/{COMPATIBILITY_MAX} chars",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# 10. metadata (optional)
|
|
378
|
+
metadata = fm.get("metadata")
|
|
379
|
+
if metadata and isinstance(metadata, dict):
|
|
380
|
+
if "version" not in metadata:
|
|
381
|
+
add(
|
|
382
|
+
"metadata_version",
|
|
383
|
+
False,
|
|
384
|
+
"metadata.version not set (recommended for catalog publishing).",
|
|
385
|
+
severity=SEVERITY_WARNING,
|
|
386
|
+
)
|
|
387
|
+
else:
|
|
388
|
+
add("metadata_version", True, f"metadata.version: {metadata.get('version')}")
|
|
389
|
+
else:
|
|
390
|
+
add(
|
|
391
|
+
"metadata_present",
|
|
392
|
+
False,
|
|
393
|
+
"No metadata block. Recommended: metadata.version and metadata.author.",
|
|
394
|
+
severity=SEVERITY_WARNING,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# 11. Body content checks
|
|
398
|
+
body = content[fm_match.end():]
|
|
399
|
+
body_line_count = len(body.strip().split("\n"))
|
|
400
|
+
add(
|
|
401
|
+
"body_line_count",
|
|
402
|
+
body_line_count <= BODY_MAX_LINES,
|
|
403
|
+
f"SKILL.md body: {body_line_count} lines "
|
|
404
|
+
f"{'(good)' if body_line_count <= BODY_MAX_LINES else f'(>{BODY_MAX_LINES} — consider moving detail to references/)'}",
|
|
405
|
+
severity=SEVERITY_WARNING if body_line_count > BODY_MAX_LINES else SEVERITY_ERROR,
|
|
406
|
+
)
|
|
407
|
+
has_examples = bool(re.search(r"(?i)(example|user says|trigger phrase|use case)", body))
|
|
408
|
+
add(
|
|
409
|
+
"body_has_examples",
|
|
410
|
+
has_examples,
|
|
411
|
+
"Body includes examples" if has_examples else "Consider adding usage examples.",
|
|
412
|
+
severity=SEVERITY_WARNING,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# 12. Optional dirs and link integrity
|
|
416
|
+
for dirname in ("references", "scripts", "assets"):
|
|
417
|
+
dir_path = os.path.join(skill_path, dirname)
|
|
418
|
+
if os.path.isdir(dir_path):
|
|
419
|
+
for entry in os.listdir(dir_path):
|
|
420
|
+
ref_token = f"{dirname}/{entry}"
|
|
421
|
+
if entry in body or ref_token in body:
|
|
422
|
+
add(f"linked:{ref_token}", True, f"{ref_token} is referenced from SKILL.md")
|
|
423
|
+
else:
|
|
424
|
+
add(
|
|
425
|
+
f"linked:{ref_token}",
|
|
426
|
+
False,
|
|
427
|
+
f"{ref_token} exists but is not referenced from SKILL.md. "
|
|
428
|
+
f"Either reference it with a clear 'when to load' clause or remove it.",
|
|
429
|
+
severity=SEVERITY_WARNING,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# 13. summary
|
|
433
|
+
if results["failed"] == 0:
|
|
434
|
+
results["summary"] = (
|
|
435
|
+
f"PASS — {results['passed']} checks passed"
|
|
436
|
+
+ (f", {results['warnings']} warnings" if results["warnings"] else "")
|
|
437
|
+
)
|
|
438
|
+
else:
|
|
439
|
+
results["summary"] = (
|
|
440
|
+
f"FAIL — {results['failed']} errors, {results['warnings']} warnings"
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
if results["failed"] > 0:
|
|
444
|
+
results["next_steps"] = [
|
|
445
|
+
f"Fix '{c['name']}': {c['message']}"
|
|
446
|
+
for c in results["checks"]
|
|
447
|
+
if (not c["passed"] and c["severity"] == SEVERITY_ERROR)
|
|
448
|
+
]
|
|
449
|
+
|
|
450
|
+
return results
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
# Reporting
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def print_report(results: dict, verbose: bool = False) -> None:
|
|
459
|
+
print(f"\n{'=' * 64}")
|
|
460
|
+
print(" Skill Validation Report (open SKILL.md format)")
|
|
461
|
+
print(f" Path: {results['path']}")
|
|
462
|
+
print(f"{'=' * 64}\n")
|
|
463
|
+
for c in results["checks"]:
|
|
464
|
+
if c["passed"] and not verbose:
|
|
465
|
+
continue
|
|
466
|
+
icon = "✅" if c["passed"] else ("⚠️ " if c["severity"] == SEVERITY_WARNING else "❌")
|
|
467
|
+
print(f" {icon} {c['name']}: {c['message']}")
|
|
468
|
+
print(f"\n{'-' * 64}")
|
|
469
|
+
print(f" {results['summary']}")
|
|
470
|
+
print(f" Passed: {results['passed']} | Failed: {results['failed']} | Warnings: {results['warnings']}")
|
|
471
|
+
print(f"{'-' * 64}\n")
|
|
472
|
+
if results.get("next_steps"):
|
|
473
|
+
print(" Next steps:")
|
|
474
|
+
for i, step in enumerate(results["next_steps"], start=1):
|
|
475
|
+
print(f" {i}. {step}")
|
|
476
|
+
print()
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
# CLI
|
|
481
|
+
# ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def main(argv: list[str] | None = None) -> int:
|
|
485
|
+
parser = argparse.ArgumentParser(
|
|
486
|
+
prog="validate.py",
|
|
487
|
+
description="Validate a skill folder against the open SKILL.md format.",
|
|
488
|
+
)
|
|
489
|
+
parser.add_argument("path", help="Path to the skill folder containing SKILL.md")
|
|
490
|
+
parser.add_argument(
|
|
491
|
+
"--format",
|
|
492
|
+
choices=["human", "json", "both"],
|
|
493
|
+
default="human",
|
|
494
|
+
help="Output format (default: human).",
|
|
495
|
+
)
|
|
496
|
+
parser.add_argument("--verbose", action="store_true", help="Include passed checks in human output.")
|
|
497
|
+
parser.add_argument("--pretty-json", action="store_true", help="Pretty-print JSON output.")
|
|
498
|
+
parser.add_argument(
|
|
499
|
+
"--json-out",
|
|
500
|
+
metavar="FILE",
|
|
501
|
+
help="Write JSON report to FILE (reusable for downstream agentic checks).",
|
|
502
|
+
)
|
|
503
|
+
args = parser.parse_args(argv)
|
|
504
|
+
|
|
505
|
+
if not os.path.isdir(args.path):
|
|
506
|
+
print(f"Error: not a directory: {args.path}", file=sys.stderr)
|
|
507
|
+
return 2
|
|
508
|
+
|
|
509
|
+
results = check_skill(args.path)
|
|
510
|
+
|
|
511
|
+
if args.format in {"human", "both"}:
|
|
512
|
+
print_report(results, verbose=args.verbose)
|
|
513
|
+
if not args.json_out:
|
|
514
|
+
print(" Tip: add --json-out FILE to reuse this report without re-running.\n")
|
|
515
|
+
|
|
516
|
+
if args.format in {"json", "both"}:
|
|
517
|
+
indent = 2 if args.pretty_json else None
|
|
518
|
+
separators = None if args.pretty_json else (",", ":")
|
|
519
|
+
report_json = json.dumps(results, indent=indent, separators=separators)
|
|
520
|
+
if args.format == "both":
|
|
521
|
+
print("--- JSON Report ---")
|
|
522
|
+
print(report_json)
|
|
523
|
+
|
|
524
|
+
if args.json_out:
|
|
525
|
+
indent = 2 if args.pretty_json else None
|
|
526
|
+
separators = None if args.pretty_json else (",", ":")
|
|
527
|
+
with open(args.json_out, "w", encoding="utf-8") as f:
|
|
528
|
+
json.dump(results, f, indent=indent, separators=separators)
|
|
529
|
+
if args.format in {"human", "both"}:
|
|
530
|
+
print(f" JSON report saved to: {args.json_out}")
|
|
531
|
+
|
|
532
|
+
return 0 if results["failed"] == 0 else 1
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
if __name__ == "__main__":
|
|
536
|
+
sys.exit(main())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spec-driven",
|
|
3
|
+
"version": "5.0.0",
|
|
4
|
+
"type": "skill",
|
|
5
|
+
"description": "Feature planning with 4 adaptive phases (Specify, Design, Tasks, Execute) + Verifier (author != verifier) + self-improving lessons layer",
|
|
6
|
+
"phases": ["specify", "design", "tasks", "execute", "validate", "memory", "lessons"],
|
|
7
|
+
"trigger": ["/spec", "/explore", "/plan", "/apply"],
|
|
8
|
+
"scope": "public",
|
|
9
|
+
"audience": "development-teams",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"required": [],
|
|
12
|
+
"optional": ["mermaid-studio", "codenavi"]
|
|
13
|
+
}
|
|
14
|
+
}
|