""" 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: . ## 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()