@ssheleg/make-skill 0.8.1 → 0.9.1
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 +73 -0
- package/README.md +46 -16
- package/SECURITY.md +15 -3
- package/cursor/rules/make-skill.mdc +24 -0
- package/package.json +2 -3
- package/plugins/make-skill/.claude-plugin/plugin.json +1 -1
- package/plugins/make-skill/agents/skill-auditor.md +46 -0
- package/plugins/make-skill/commands/skill-audit.md +31 -0
- package/plugins/make-skill/hooks/hooks.json +19 -0
- package/plugins/make-skill/hooks/skill-md-audit.sh +43 -0
- package/plugins/make-skill/skills/make-skill/SKILL.md +79 -50
- package/plugins/make-skill/skills/make-skill/assets/agent.template.md +33 -0
- package/plugins/make-skill/skills/make-skill/assets/command.template.md +32 -0
- package/plugins/make-skill/skills/make-skill/assets/hooks.template.json +32 -0
- package/plugins/make-skill/skills/make-skill/references/authoring.md +7 -0
- package/plugins/make-skill/skills/make-skill/references/distribution.md +23 -8
- package/plugins/make-skill/skills/make-skill/references/host-capabilities.md +218 -0
- package/plugins/make-skill/skills/make-skill/references/retrofit.md +25 -2
- package/plugins/make-skill/skills/make-skill/scripts/audit_skill.py +378 -0
- package/templates/README.md +0 -34
- /package/{templates → plugins/make-skill/skills/make-skill/assets}/SKILL.template.md +0 -0
- /package/{templates → plugins/make-skill/skills/make-skill/assets}/marketplace.template.json +0 -0
- /package/{templates → plugins/make-skill/skills/make-skill/assets}/plugin.template.json +0 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audit ANY skill directory against the Agent Skills standard + Anthropic's rules.
|
|
3
|
+
|
|
4
|
+
python3 audit_skill.py <skill-dir> [--house] [--json] [--quiet]
|
|
5
|
+
|
|
6
|
+
Exit 0 when every check passes, 1 when any GAP is found, 2 on a usage error.
|
|
7
|
+
|
|
8
|
+
Why this exists: the mechanical half of a retrofit audit — charset, lengths,
|
|
9
|
+
reserved words, budgets, link integrity — is the same every time, and an agent
|
|
10
|
+
re-deriving it from prose gets a different subset right on each run. This runs the
|
|
11
|
+
same checks in the same order and prints file:line evidence for each, so the agent
|
|
12
|
+
spends its context on the half that needs judgement.
|
|
13
|
+
|
|
14
|
+
core the Agent Skills open standard (agentskills.io/specification) plus the
|
|
15
|
+
rules Anthropic's platform enforces on upload (reserved words, XML
|
|
16
|
+
tags) and its authoring guidance (third person, tables of contents)
|
|
17
|
+
--house adds the ssheleg canon: description starts "Use when …" and carries
|
|
18
|
+
English AND Russian trigger phrases
|
|
19
|
+
|
|
20
|
+
Python 3.9+, standard library only: it has to run wherever the skill landed.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
# --- limits, each with the authority that sets it --------------------------
|
|
31
|
+
NAME_MAX = 64 # spec: name is 1-64 characters
|
|
32
|
+
DESC_MAX = 1024 # spec: description is 1-1024 characters
|
|
33
|
+
COMPAT_MAX = 500 # spec: compatibility is 1-500 characters
|
|
34
|
+
BODY_MAX_LINES = 500 # spec + Anthropic: keep the body under 500 lines
|
|
35
|
+
BODY_MAX_TOKENS = 5000 # spec + Anthropic: level-2 budget
|
|
36
|
+
# No tokenizer in the stdlib. 3.9 chars/token is measured, not assumed: tokenizing
|
|
37
|
+
# this skill's own bundle gives 3.78-4.47. `claude plugin details` is far more
|
|
38
|
+
# pessimistic (~2.8) and will always show a bigger number than this estimate.
|
|
39
|
+
CHARS_PER_TOKEN = 3.9
|
|
40
|
+
TOC_MIN_LINES = 100 # Anthropic: longer reference files need a table of contents
|
|
41
|
+
|
|
42
|
+
SPEC_KEYS = {"name", "description", "license", "compatibility", "metadata", "allowed-tools"}
|
|
43
|
+
# Claude Code reads these too. Legal, ignored by every other agent.
|
|
44
|
+
HOST_KEYS = {
|
|
45
|
+
"when_to_use", "argument-hint", "arguments", "disable-model-invocation",
|
|
46
|
+
"user-invocable", "disallowed-tools", "model", "effort", "context", "agent",
|
|
47
|
+
"background", "hooks", "paths", "shell",
|
|
48
|
+
}
|
|
49
|
+
RESERVED_NAME_WORDS = ("anthropic", "claude")
|
|
50
|
+
NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
51
|
+
XML_TAG_RE = re.compile(r"<[^<>\s][^<>]*>")
|
|
52
|
+
PERSON_RE = re.compile(
|
|
53
|
+
r"\b(?:I can|I will|I'll|I help|I'm|you can use|you should use|you may use|"
|
|
54
|
+
r"this skill (?:lets|allows|helps) you)\b", re.I)
|
|
55
|
+
LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
|
|
56
|
+
WIN_PATH_RE = re.compile(r"\b[\w.-]+\\[\w.-]+\.(?:md|py|sh|js|json|ya?ml)\b")
|
|
57
|
+
TIME_BRANCH_RE = re.compile(
|
|
58
|
+
r"\b(?:before|after|until|from)\s+"
|
|
59
|
+
r"(?:january|february|march|april|may|june|july|august|september|october|"
|
|
60
|
+
r"november|december|\d{4})\b", re.I)
|
|
61
|
+
BUNDLE_DIRS = ("references", "scripts", "assets")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Audit:
|
|
65
|
+
"""Collects verdicts so every check reports, rather than the first failure."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, root):
|
|
68
|
+
self.root = root
|
|
69
|
+
self.results = []
|
|
70
|
+
|
|
71
|
+
def add(self, verdict, check, message, path=None, line=None):
|
|
72
|
+
self.results.append({
|
|
73
|
+
"verdict": verdict, "check": check, "message": message,
|
|
74
|
+
"file": path, "line": line,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
def ok(self, check, message, path=None, line=None):
|
|
78
|
+
self.add("PASS", check, message, path, line)
|
|
79
|
+
|
|
80
|
+
def gap(self, check, message, path=None, line=None):
|
|
81
|
+
self.add("GAP", check, message, path, line)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def gaps(self):
|
|
85
|
+
return [r for r in self.results if r["verdict"] == "GAP"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_frontmatter(text):
|
|
89
|
+
"""YAML subset: top-level scalars, block scalars, one nested map.
|
|
90
|
+
|
|
91
|
+
Returns (data, line_of_key). A full YAML parser is not in the stdlib and a
|
|
92
|
+
skill's frontmatter is a flat map by specification, so this is enough — and
|
|
93
|
+
it keeps the script dependency-free, which is the point of shipping it.
|
|
94
|
+
"""
|
|
95
|
+
data, lines, key, mode = {}, {}, None, None
|
|
96
|
+
for i, raw in enumerate(text.split("\n"), start=2): # +2: the opening '---'
|
|
97
|
+
if not raw.strip():
|
|
98
|
+
continue
|
|
99
|
+
if raw[0] not in " \t":
|
|
100
|
+
m = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", raw)
|
|
101
|
+
if not m:
|
|
102
|
+
key, mode = None, None
|
|
103
|
+
continue
|
|
104
|
+
key, val = m.group(1), m.group(2).strip()
|
|
105
|
+
lines[key] = i
|
|
106
|
+
if val in (">", "|", ">-", "|-", ">+", "|+"):
|
|
107
|
+
data[key], mode = "", "block"
|
|
108
|
+
elif val == "":
|
|
109
|
+
data[key], mode = {}, "map"
|
|
110
|
+
else:
|
|
111
|
+
data[key], mode = _unquote(val), None
|
|
112
|
+
elif mode == "block":
|
|
113
|
+
data[key] = (data[key] + " " + raw.strip()).strip()
|
|
114
|
+
elif mode == "map":
|
|
115
|
+
m = re.match(r"^\s+([A-Za-z0-9_-]+):\s*(.*)$", raw)
|
|
116
|
+
if m:
|
|
117
|
+
data[key][m.group(1)] = _unquote(m.group(2).strip())
|
|
118
|
+
return data, lines
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _unquote(v):
|
|
122
|
+
v = v.strip()
|
|
123
|
+
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
|
124
|
+
return v[1:-1]
|
|
125
|
+
return v
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def audit(skill_dir, house=False):
|
|
129
|
+
a = Audit(skill_dir)
|
|
130
|
+
name_on_disk = os.path.basename(os.path.abspath(skill_dir.rstrip("/")))
|
|
131
|
+
skill_md = os.path.join(skill_dir, "SKILL.md")
|
|
132
|
+
rel = os.path.join(name_on_disk, "SKILL.md")
|
|
133
|
+
|
|
134
|
+
if not os.path.isdir(skill_dir):
|
|
135
|
+
a.gap("LAYOUT", "not a directory: %s" % skill_dir, skill_dir)
|
|
136
|
+
return a
|
|
137
|
+
if not os.path.isfile(skill_md):
|
|
138
|
+
a.gap("LAYOUT", "no SKILL.md — a skill is a directory with SKILL.md at its "
|
|
139
|
+
"top level", skill_md)
|
|
140
|
+
return a
|
|
141
|
+
|
|
142
|
+
text = open(skill_md, encoding="utf-8").read()
|
|
143
|
+
m = re.match(r"^---\n(.*?)\n---\n", text, re.S)
|
|
144
|
+
if not m:
|
|
145
|
+
a.gap("FRONTMATTER", "no YAML frontmatter — it must be the first thing in "
|
|
146
|
+
"the file, delimited by ---", rel, 1)
|
|
147
|
+
return a
|
|
148
|
+
fm, fm_lines = parse_frontmatter(m.group(1))
|
|
149
|
+
body = text[m.end():]
|
|
150
|
+
|
|
151
|
+
_check_name(a, fm, fm_lines, name_on_disk, rel)
|
|
152
|
+
_check_description(a, fm, fm_lines, rel, house)
|
|
153
|
+
_check_optional_fields(a, fm, fm_lines, rel)
|
|
154
|
+
_check_keys(a, fm, fm_lines, rel)
|
|
155
|
+
_check_body_budget(a, body, rel)
|
|
156
|
+
_check_bundle(a, skill_dir, text, name_on_disk)
|
|
157
|
+
_check_links(a, skill_dir, text, rel)
|
|
158
|
+
_check_prose(a, body, rel)
|
|
159
|
+
return a
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _check_name(a, fm, lines, dir_name, rel):
|
|
163
|
+
name = fm.get("name")
|
|
164
|
+
ln = lines.get("name")
|
|
165
|
+
if not isinstance(name, str) or not name:
|
|
166
|
+
a.gap("NAME_MISSING", "name is required", rel, ln)
|
|
167
|
+
return
|
|
168
|
+
if len(name) > NAME_MAX:
|
|
169
|
+
a.gap("NAME_LENGTH", "name is %d chars, the maximum is %d" % (len(name), NAME_MAX), rel, ln)
|
|
170
|
+
elif not NAME_RE.match(name):
|
|
171
|
+
a.gap("NAME_CHARSET", "name %r must be lowercase a-z0-9 with single internal "
|
|
172
|
+
"hyphens — no uppercase, no leading/trailing or doubled hyphen" % name, rel, ln)
|
|
173
|
+
else:
|
|
174
|
+
a.ok("NAME_CHARSET", "name %r is spec-legal" % name, rel, ln)
|
|
175
|
+
if name != dir_name:
|
|
176
|
+
a.gap("NAME_DIR", "name %r != directory %r — the spec requires them to match, "
|
|
177
|
+
"and the Skills API matches the uploaded top-level directory against it"
|
|
178
|
+
% (name, dir_name), rel, ln)
|
|
179
|
+
else:
|
|
180
|
+
a.ok("NAME_DIR", "name matches the directory", rel, ln)
|
|
181
|
+
hit = [w for w in RESERVED_NAME_WORDS if w in name.lower()]
|
|
182
|
+
if hit:
|
|
183
|
+
a.gap("NAME_RESERVED", "name contains the reserved word %r — Claude Code "
|
|
184
|
+
"loads it, the Skills API rejects the upload" % hit[0], rel, ln)
|
|
185
|
+
else:
|
|
186
|
+
a.ok("NAME_RESERVED", "no reserved word in name", rel, ln)
|
|
187
|
+
if XML_TAG_RE.search(name):
|
|
188
|
+
a.gap("NAME_XML", "name contains an XML tag — rejected by Anthropic's "
|
|
189
|
+
"platform; a leftover <placeholder> is the usual cause", rel, ln)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _check_description(a, fm, lines, rel, house):
|
|
193
|
+
desc = fm.get("description")
|
|
194
|
+
ln = lines.get("description")
|
|
195
|
+
if not isinstance(desc, str) or not desc.strip():
|
|
196
|
+
a.gap("DESC_MISSING", "description is required and must be non-empty", rel, ln)
|
|
197
|
+
return
|
|
198
|
+
if len(desc) > DESC_MAX:
|
|
199
|
+
a.gap("DESC_LENGTH", "description is %d chars, the maximum is %d"
|
|
200
|
+
% (len(desc), DESC_MAX), rel, ln)
|
|
201
|
+
else:
|
|
202
|
+
a.ok("DESC_LENGTH", "description is %d/%d chars" % (len(desc), DESC_MAX), rel, ln)
|
|
203
|
+
if XML_TAG_RE.search(desc):
|
|
204
|
+
a.gap("DESC_XML", "description contains an XML tag — rejected by Anthropic's "
|
|
205
|
+
"platform", rel, ln)
|
|
206
|
+
hit = PERSON_RE.search(desc)
|
|
207
|
+
if hit:
|
|
208
|
+
a.gap("DESC_PERSON", "description is not third person (%r) — it is injected "
|
|
209
|
+
"into the system prompt, where first/second person degrades skill "
|
|
210
|
+
"selection" % hit.group(0), rel, ln)
|
|
211
|
+
else:
|
|
212
|
+
a.ok("DESC_PERSON", "description is third person", rel, ln)
|
|
213
|
+
if house:
|
|
214
|
+
if not desc.lower().startswith("use when"):
|
|
215
|
+
a.gap("DESC_USEWHEN", "description must start with 'Use when …' (house rule)", rel, ln)
|
|
216
|
+
if not re.search(r"[а-яё]", desc, re.I):
|
|
217
|
+
a.gap("DESC_RU", "description carries no Russian trigger phrases (house rule)", rel, ln)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _check_optional_fields(a, fm, lines, rel):
|
|
221
|
+
compat = fm.get("compatibility")
|
|
222
|
+
if compat is not None:
|
|
223
|
+
if not isinstance(compat, str) or not compat.strip():
|
|
224
|
+
a.gap("COMPAT_TYPE", "compatibility must be a non-empty string", rel, lines.get("compatibility"))
|
|
225
|
+
elif len(compat) > COMPAT_MAX:
|
|
226
|
+
a.gap("COMPAT_LENGTH", "compatibility is %d chars, the maximum is %d"
|
|
227
|
+
% (len(compat), COMPAT_MAX), rel, lines.get("compatibility"))
|
|
228
|
+
if "allowed-tools" in fm and not isinstance(fm["allowed-tools"], str):
|
|
229
|
+
a.gap("TOOLS_TYPE", "allowed-tools must be a space-separated string; Claude "
|
|
230
|
+
"Code also accepts a list, no other host does", rel, lines.get("allowed-tools"))
|
|
231
|
+
meta = fm.get("metadata")
|
|
232
|
+
if meta is not None:
|
|
233
|
+
if not isinstance(meta, dict):
|
|
234
|
+
a.gap("META_TYPE", "metadata must be a map of string keys to string values",
|
|
235
|
+
rel, lines.get("metadata"))
|
|
236
|
+
else:
|
|
237
|
+
for k, v in meta.items():
|
|
238
|
+
if not isinstance(v, str) or not v:
|
|
239
|
+
a.gap("META_TYPE", "metadata.%s must be a non-empty string — quote "
|
|
240
|
+
"versions, or YAML turns 1.0 into a float" % k, rel, lines.get("metadata"))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _check_keys(a, fm, lines, rel):
|
|
244
|
+
unknown = sorted(set(fm) - SPEC_KEYS - HOST_KEYS)
|
|
245
|
+
if unknown:
|
|
246
|
+
a.gap("FM_UNKNOWN_KEY", "frontmatter keys in neither the open standard nor the "
|
|
247
|
+
"Claude Code extension set: %s — anything outside both is a typo"
|
|
248
|
+
% ", ".join(unknown), rel, lines.get(unknown[0]))
|
|
249
|
+
else:
|
|
250
|
+
a.ok("FM_UNKNOWN_KEY", "no frontmatter key outside spec ∪ host extensions", rel)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _check_body_budget(a, body, rel):
|
|
254
|
+
n_lines = body.count("\n") + 1
|
|
255
|
+
est = int(len(body) / CHARS_PER_TOKEN)
|
|
256
|
+
if n_lines >= BODY_MAX_LINES:
|
|
257
|
+
a.gap("BODY_LINES", "body is %d lines, the budget is < %d — move detail into "
|
|
258
|
+
"references/" % (n_lines, BODY_MAX_LINES), rel)
|
|
259
|
+
elif est >= BODY_MAX_TOKENS:
|
|
260
|
+
a.gap("BODY_TOKENS", "body is ~%d tokens (%d chars / %s), the budget is < %d"
|
|
261
|
+
% (est, len(body), CHARS_PER_TOKEN, BODY_MAX_TOKENS), rel)
|
|
262
|
+
else:
|
|
263
|
+
a.ok("BODY_BUDGET", "body is %d lines / ~%d tokens (budget %d / %d)"
|
|
264
|
+
% (n_lines, est, BODY_MAX_LINES, BODY_MAX_TOKENS), rel)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _check_bundle(a, skill_dir, skill_text, dir_name):
|
|
268
|
+
"""references/ scripts/ assets/: one level deep, reachable, navigable."""
|
|
269
|
+
for sub in BUNDLE_DIRS:
|
|
270
|
+
d = os.path.join(skill_dir, sub)
|
|
271
|
+
if not os.path.isdir(d):
|
|
272
|
+
continue
|
|
273
|
+
for entry in sorted(os.listdir(d)):
|
|
274
|
+
full = os.path.join(d, entry)
|
|
275
|
+
rel = os.path.join(dir_name, sub, entry)
|
|
276
|
+
if os.path.isdir(full):
|
|
277
|
+
a.gap("BUNDLE_NESTED", "%s/%s/ is nested — keep bundled files one level "
|
|
278
|
+
"deep, or the agent previews them instead of reading them"
|
|
279
|
+
% (sub, entry), rel)
|
|
280
|
+
continue
|
|
281
|
+
if "%s/%s" % (sub, entry) not in skill_text:
|
|
282
|
+
a.gap("BUNDLE_UNREACHABLE", "%s/%s is never referenced from SKILL.md — "
|
|
283
|
+
"an unreachable file is dead weight the agent never opens"
|
|
284
|
+
% (sub, entry), rel)
|
|
285
|
+
continue
|
|
286
|
+
if entry.endswith(".md"):
|
|
287
|
+
txt = open(full, encoding="utf-8").read()
|
|
288
|
+
n = txt.count("\n") + 1
|
|
289
|
+
if n > TOC_MIN_LINES and "\n## Contents" not in txt:
|
|
290
|
+
a.gap("REF_NO_TOC", "%d lines with no '## Contents' list — past %d "
|
|
291
|
+
"lines an agent previews with head and never learns what the "
|
|
292
|
+
"rest holds" % (n, TOC_MIN_LINES), rel)
|
|
293
|
+
if any(os.path.isdir(os.path.join(skill_dir, s)) for s in BUNDLE_DIRS):
|
|
294
|
+
a.ok("BUNDLE_LAYOUT", "bundled directories present and checked")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _check_links(a, skill_dir, skill_text, rel):
|
|
298
|
+
"""A relative link that escapes the skill directory arrives broken everywhere."""
|
|
299
|
+
escaped = broken = False
|
|
300
|
+
for target in LINK_RE.findall(skill_text):
|
|
301
|
+
if target.startswith(("http://", "https://", "mailto:", "#")):
|
|
302
|
+
continue
|
|
303
|
+
if target.startswith("../") or "/../" in target:
|
|
304
|
+
a.gap("LINK_ESCAPE", "link %r escapes the skill directory — packagers ship "
|
|
305
|
+
"that directory alone, so it arrives broken on every agent" % target, rel)
|
|
306
|
+
escaped = True
|
|
307
|
+
continue
|
|
308
|
+
path = os.path.normpath(os.path.join(skill_dir, target.split("#")[0]))
|
|
309
|
+
if not os.path.exists(path):
|
|
310
|
+
a.gap("LINK_BROKEN", "link %r does not resolve" % target, rel)
|
|
311
|
+
broken = True
|
|
312
|
+
if not escaped and not broken:
|
|
313
|
+
a.ok("LINK_INTEGRITY", "every relative link resolves and stays inside the skill", rel)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _strip_quoted(line):
|
|
317
|
+
"""Blank out quoted and backticked spans.
|
|
318
|
+
|
|
319
|
+
A skill that teaches an anti-pattern quotes it — "Before August, use the old
|
|
320
|
+
API" is the example, not the instruction. Matching inside quotes would flag
|
|
321
|
+
every doc that warns about the thing it detects, which is how a linter earns
|
|
322
|
+
the habit of being ignored.
|
|
323
|
+
"""
|
|
324
|
+
return re.sub(r"`[^`]*`|\"[^\"]*\"|'[^']*'|“[^”]*”", " ", line)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _check_prose(a, body, rel):
|
|
328
|
+
"""Content rules that are mechanically detectable."""
|
|
329
|
+
for i, raw in enumerate(body.split("\n"), start=1):
|
|
330
|
+
line = _strip_quoted(raw)
|
|
331
|
+
for hit in WIN_PATH_RE.findall(line):
|
|
332
|
+
a.gap("WIN_PATH", "Windows-style path %r — forward slashes work on every "
|
|
333
|
+
"platform, backslashes break on Unix" % hit, rel, i)
|
|
334
|
+
if TIME_BRANCH_RE.search(line) and re.search(r"\buse\b|\bswitch\b|\bapply\b", line, re.I):
|
|
335
|
+
a.gap("TIME_BRANCH", "time-branching instruction (%r) — it is wrong the day "
|
|
336
|
+
"it ships; put superseded material under '## Old patterns'"
|
|
337
|
+
% raw.strip()[:60], rel, i)
|
|
338
|
+
# a pointer at a directory rather than a file with a load condition
|
|
339
|
+
for i, line in enumerate(body.split("\n"), start=1):
|
|
340
|
+
if re.search(r"\b(?:see|read)\s+`?(?:references|scripts|assets)/`?(?![\w.-])", line, re.I):
|
|
341
|
+
a.gap("REF_NO_TRIGGER", "points at a directory instead of a file with a "
|
|
342
|
+
"stated load condition — 'read X when Y' beats 'see references/'", rel, i)
|
|
343
|
+
if not [r for r in a.results if r["check"] in ("WIN_PATH", "TIME_BRANCH", "REF_NO_TRIGGER")]:
|
|
344
|
+
a.ok("PROSE", "no Windows paths, time-branching, or bare directory pointers", rel)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def main(argv):
|
|
348
|
+
p = argparse.ArgumentParser(
|
|
349
|
+
description="Audit a skill directory against the Agent Skills standard.")
|
|
350
|
+
p.add_argument("skill_dir", help="the directory containing SKILL.md")
|
|
351
|
+
p.add_argument("--house", action="store_true",
|
|
352
|
+
help="also apply the ssheleg canon (Use-when opener, EN+RU triggers)")
|
|
353
|
+
p.add_argument("--json", action="store_true", help="emit results as JSON")
|
|
354
|
+
p.add_argument("--quiet", action="store_true", help="print GAP lines only")
|
|
355
|
+
args = p.parse_args(argv[1:])
|
|
356
|
+
|
|
357
|
+
a = audit(args.skill_dir, house=args.house)
|
|
358
|
+
|
|
359
|
+
if args.json:
|
|
360
|
+
print(json.dumps(a.results, indent=2, ensure_ascii=False))
|
|
361
|
+
else:
|
|
362
|
+
for r in a.results:
|
|
363
|
+
if args.quiet and r["verdict"] == "PASS":
|
|
364
|
+
continue
|
|
365
|
+
where = r["file"] or ""
|
|
366
|
+
if r["line"]:
|
|
367
|
+
where += ":%d" % r["line"]
|
|
368
|
+
print("%-4s %-18s %-28s %s" % (r["verdict"], r["check"], where, r["message"]))
|
|
369
|
+
n_gap = len(a.gaps)
|
|
370
|
+
print("\n%d GAP, %d PASS — %s" % (n_gap, len(a.results) - n_gap, args.skill_dir))
|
|
371
|
+
if n_gap:
|
|
372
|
+
print("Fix these, then re-run. The judgement half of the audit "
|
|
373
|
+
"(one job, entry point, evals, distribution) is in references/retrofit.md.")
|
|
374
|
+
return 1 if a.gaps else 0
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
if __name__ == "__main__":
|
|
378
|
+
sys.exit(main(sys.argv))
|
package/templates/README.md
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# templates
|
|
2
|
-
|
|
3
|
-
Skeletons make-skill seeds when scaffolding a new skill. Copy only when the target
|
|
4
|
-
is absent; never overwrite existing files.
|
|
5
|
-
|
|
6
|
-
| Template | Seeds | Used by |
|
|
7
|
-
|---|---|---|
|
|
8
|
-
| `SKILL.template.md` | a new `skills/<name>/SKILL.md` — front-matter legal under BOTH rulebooks (limits stated inline, plain placeholders instead of `<angle brackets>`, which count as XML tags and are rejected on upload) and section stubs incl. References and Gotchas | Create / Promote |
|
|
9
|
-
| `plugin.template.json` | `plugins/<name>/.claude-plugin/plugin.json` | Create (distributable) / Promote |
|
|
10
|
-
| `marketplace.template.json` | `.claude-plugin/marketplace.json` at the repo root | Create (distributable) / Promote |
|
|
11
|
-
|
|
12
|
-
The two JSON skeletons carry only fields Claude Code recognizes, so
|
|
13
|
-
`claude plugin validate <path> --strict` passes on the seeded repo. Replace every
|
|
14
|
-
placeholder, keep the `$schema` lines (editor autocomplete), and keep all
|
|
15
|
-
four versions in sync. Marketplace level takes `name`, `owner`, `plugins`,
|
|
16
|
-
`$schema`, `description`, `version`, `metadata`,
|
|
17
|
-
`allowCrossMarketplaceDependenciesOn`, `renames` and nothing else —
|
|
18
|
-
`homepage`/`repository`/`license` belong to the plugin entry.
|
|
19
|
-
|
|
20
|
-
**Never name a skeleton `SKILL.md`.** The skills CLI discovers every `SKILL.md` in
|
|
21
|
-
a repo tree and installs it as a real skill — a placeholder would land in every
|
|
22
|
-
agent. `test/validate.py` rejects any `SKILL.md` outside `plugins/*/skills/*/`.
|
|
23
|
-
|
|
24
|
-
Heavier material for a seeded skill goes next to it, inside the skill directory:
|
|
25
|
-
`references/` (docs), `scripts/` (code), `assets/` (templates, schemas) — one
|
|
26
|
-
level deep, each with a stated load trigger in the body. A sibling directory
|
|
27
|
-
reaches Claude Code plugins but arrives broken on every other agent.
|
|
28
|
-
|
|
29
|
-
Distributable repos also get the full tree (manifests, `bin/`, `test/validate.py`,
|
|
30
|
-
`test/evals/`, workflows, `install.sh`, README/CHANGELOG/LICENSE) — copy those
|
|
31
|
-
from a reference impl (`ssheleg/task-pipeline` or `ssheleg/super-ux`) per the
|
|
32
|
-
layout in `references/distribution.md`. The evaluation suite is not optional
|
|
33
|
-
scaffolding: the canon requires ≥3 behavioral scenarios and a trigger set with
|
|
34
|
-
near-miss negatives before a skill ships.
|
|
File without changes
|
/package/{templates → plugins/make-skill/skills/make-skill/assets}/marketplace.template.json
RENAMED
|
File without changes
|
|
File without changes
|