File size: 10,430 Bytes
4fa5831
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
"""
Skill Forge β€” core logic for validating, linting, and scaffolding Agent Skills.

An "Agent Skill" is a folder with a SKILL.md at its root: a YAML frontmatter
block (`--- ... ---`) followed by a Markdown body. The frontmatter names the
skill and, crucially, describes *when* an agent should reach for it; the body is
the instructions the agent follows once it does.

This module is pure stdlib + PyYAML so it can be unit-tested without Gradio.
Nothing here is copied from any spec document β€” the rules below are the stable,
widely-agreed structural constraints of the format.
"""

from __future__ import annotations

import io
import re
import zipfile
from dataclasses import dataclass, field

import yaml

NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
NAME_MAX = 64
DESC_MAX = 1024
# Keys commonly recognised across skill hosts. Unknown keys are only *info*.
KNOWN_KEYS = {
    "name", "description", "license", "allowed-tools", "allowed_tools",
    "metadata", "version", "compatible-with", "compatible_with",
}
TRIGGER_CUES = ("use when", "use this", "when the user", "when asked",
                "trigger", "for tasks", "helps with", "invoke when")


@dataclass
class Report:
    ok: bool = True
    errors: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)
    info: list[str] = field(default_factory=list)

    def err(self, m: str) -> None:
        self.errors.append(m)
        self.ok = False

    def warn(self, m: str) -> None:
        self.warnings.append(m)

    def note(self, m: str) -> None:
        self.info.append(m)

    def as_markdown(self) -> str:
        head = "## βœ… Valid skill" if self.ok else "## ❌ Invalid skill"
        lines = [head, ""]
        for label, items, icon in (
            ("Errors", self.errors, "πŸ”΄"),
            ("Warnings", self.warnings, "🟑"),
            ("Info", self.info, "πŸ”΅"),
        ):
            if items:
                lines.append(f"**{label}**")
                lines += [f"- {icon} {x}" for x in items]
                lines.append("")
        if self.ok and not self.warnings:
            lines.append("_No issues found._")
        return "\n".join(lines).strip()


def split_frontmatter(text: str) -> tuple[str | None, str]:
    """Return (frontmatter_yaml, body). frontmatter is None if no leading block."""
    text = text.lstrip("ο»Ώ")
    if not text.startswith("---"):
        return None, text
    m = re.match(r"^---[ \t]*\r?\n(.*?)\r?\n---[ \t]*\r?\n?(.*)\Z", text, re.S)
    if not m:
        return None, text
    return m.group(1), m.group(2)


def validate_skill(skill_md: str) -> Report:
    """Validate a SKILL.md string against the Agent Skill format.

    Args:
        skill_md: Full text of a SKILL.md file (frontmatter + Markdown body).

    Returns:
        A Report with ok/errors/warnings/info. Errors mean the skill will not
        load or trigger reliably; warnings are quality problems worth fixing.
    """
    r = Report()
    if not skill_md or not skill_md.strip():
        r.err("File is empty.")
        return r

    fm_raw, body = split_frontmatter(skill_md)
    if fm_raw is None:
        r.err("No YAML frontmatter. SKILL.md must start with a '---' fenced block.")
        return r

    try:
        fm = yaml.safe_load(fm_raw) or {}
    except yaml.YAMLError as e:
        r.err(f"Frontmatter is not valid YAML: {e}")
        return r
    if not isinstance(fm, dict):
        r.err("Frontmatter must be a YAML mapping (key: value pairs).")
        return r

    # name
    name = fm.get("name")
    if not name or not str(name).strip():
        r.err("`name` is required in frontmatter.")
    else:
        name = str(name).strip()
        if len(name) > NAME_MAX:
            r.err(f"`name` is {len(name)} chars; keep it <= {NAME_MAX}.")
        if not NAME_RE.match(name):
            r.err("`name` must be kebab-case: lowercase letters, digits, single hyphens.")

    # description
    desc = fm.get("description")
    if not desc or not str(desc).strip():
        r.err("`description` is required β€” it is how an agent decides to use the skill.")
    else:
        desc = str(desc).strip()
        if len(desc) > DESC_MAX:
            r.err(f"`description` is {len(desc)} chars; keep it <= {DESC_MAX}.")
        if len(desc) < 40:
            r.warn("`description` is very short; say what the skill does *and* when to use it.")
        if not any(cue in desc.lower() for cue in TRIGGER_CUES):
            r.warn("`description` has no explicit trigger ('Use when...'); agents may not fire it.")

    # body
    if not body.strip():
        r.err("Body is empty. Put the actual instructions after the frontmatter.")
    else:
        if not re.search(r"^#{1,6}\s", body, re.M) and len(body) > 400:
            r.warn("Long body with no Markdown headings; add structure for skimmability.")
        for link in re.findall(r"\[[^\]]*\]\(([^)]+)\)", body):
            if link.startswith(("http://", "https://", "#", "mailto:")):
                continue
            if link.startswith("/"):
                r.warn(f"Absolute path link '{link}'; use a path relative to the skill folder.")

    # unknown keys (informational only)
    unknown = sorted(set(fm) - KNOWN_KEYS)
    if unknown:
        r.note(f"Non-standard frontmatter keys (ignored by most hosts): {', '.join(unknown)}")

    at = fm.get("allowed-tools", fm.get("allowed_tools"))
    if at is not None and not isinstance(at, (list, str)):
        r.warn("`allowed-tools` should be a list (or comma string) of tool names.")

    return r


def lint_description(description: str) -> Report:
    """Score a skill `description` for how reliably an agent will trigger on it.

    Args:
        description: The frontmatter `description` string.

    Returns:
        A Report whose first info line is 'Trigger score: N/100'.
    """
    r = Report()
    d = (description or "").strip()
    if not d:
        r.err("Empty description.")
        return r

    score = 100
    low = d.lower()

    n = len(d)
    if n < 40:
        score -= 35
        r.warn(f"Too short ({n} chars). Aim for ~150–500.")
    elif n < 150:
        score -= 10
        r.warn(f"A bit short ({n} chars); add concrete trigger cases.")
    elif n > DESC_MAX:
        score -= 30
        r.err(f"Over the {DESC_MAX}-char limit ({n}).")
    elif n > 700:
        score -= 8
        r.warn(f"Long ({n} chars); tighten to the essentials.")

    if not any(cue in low for cue in TRIGGER_CUES):
        score -= 25
        r.warn("No explicit trigger phrase. Add 'Use when the user...' with real examples.")

    if low.startswith(("this skill", "a skill", "the skill", "this is")):
        score -= 10
        r.warn("Starts with 'This skill...'. Lead with the capability or the trigger.")

    if re.search(r"\b(i|you|we|my|your)\b", low):
        score -= 6
        r.note("Uses first/second person; third-person reads better in a registry.")

    verbs = re.findall(r"\b(create|build|convert|review|audit|generate|analy[sz]e|"
                       r"extract|summari[sz]e|refactor|debug|validate|lint|format|"
                       r"deploy|test|search|fetch|translate|render|plan)\w*", low)
    if not verbs:
        score -= 12
        r.warn("No action verbs; name what the skill *does*.")

    if "e.g." in low or "for example" in low or "such as" in low or "like " in low:
        r.note("Has examples β€” good for trigger matching.")
    else:
        score -= 8
        r.warn("No examples. Concrete phrases ('e.g. \"redesign my landing page\"') help matching.")

    score = max(0, min(100, score))
    verdict = "strong" if score >= 80 else "usable" if score >= 55 else "weak"
    r.note(f"Trigger score: {score}/100 ({verdict})")
    r.ok = score >= 55
    return r


def scaffold_skill(name: str, description: str, when_to_use: str = "") -> str:
    """Return a ready-to-edit SKILL.md for a new Agent Skill.

    Args:
        name: kebab-case skill name, e.g. 'pdf-form-filler'.
        description: One or two sentences: what it does and when to use it.
        when_to_use: Optional extra trigger examples appended to the description.

    Returns:
        The full text of a SKILL.md file.
    """
    name = (name or "my-skill").strip().lower().replace(" ", "-")
    name = re.sub(r"[^a-z0-9-]", "", name) or "my-skill"
    desc = " ".join((description or "").split())
    if when_to_use.strip():
        extra = when_to_use.strip()
        if "use when" not in desc.lower():
            desc = f"{desc} Use when {extra[0].lower()}{extra[1:]}" if desc else f"Use when {extra}"
        else:
            desc = f"{desc} {extra}"
    if not desc:
        desc = ("Describe what this skill does and the concrete situations that "
                "should trigger it, e.g. \"Use when the user asks to ...\".")

    return f"""---
name: {name}
description: {desc}
---

# {name.replace('-', ' ').title()}

## When to use this skill

- Trigger 1 β€” a concrete user request this handles.
- Trigger 2 β€” another phrasing or situation.
- Do **not** use it for: <the near-miss cases that belong elsewhere>.

## Instructions

1. First step.
2. Second step.
3. What to hand back to the user.

## Notes

- Edge cases, gotchas, and links to bundled files (paths relative to this folder).
"""


def package_skill(skill_md: str, extra_files: dict[str, str] | None = None) -> bytes:
    """Zip a SKILL.md (and optional sibling files) into a distributable archive.

    Args:
        skill_md: The SKILL.md text. Must validate without errors.
        extra_files: Optional {relative_path: text_content} placed next to SKILL.md.

    Returns:
        Bytes of a .zip. Raises ValueError if the skill has validation errors.
    """
    rep = validate_skill(skill_md)
    if not rep.ok:
        raise ValueError("Skill has errors; fix them before packaging:\n" +
                         "\n".join(rep.errors))
    fm_raw, _ = split_frontmatter(skill_md)
    name = str((yaml.safe_load(fm_raw) or {}).get("name", "skill")).strip()

    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
        z.writestr(f"{name}/SKILL.md", skill_md)
        for rel, content in (extra_files or {}).items():
            rel = rel.lstrip("/")
            if ".." in rel.split("/"):
                raise ValueError(f"Unsafe path: {rel}")
            z.writestr(f"{name}/{rel}", content)
    return buf.getvalue()