Spaces:
Running
Running
| """ | |
| 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") | |
| 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() | |