skill-forge / skillforge.py
WhySoCodius's picture
Skill Forge: static browser app + Python/MCP port for Agent Skills
4fa5831 verified
Raw
History Blame Contribute Delete
10.4 kB
"""
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()