Spaces:
Running
Running
Skill Forge: static browser app + Python/MCP port for Agent Skills
Browse files- LICENSE +21 -0
- README.md +58 -3
- app.py +124 -0
- index.html +179 -18
- requirements.txt +2 -0
- skillforge.js +186 -0
- skillforge.py +295 -0
- test_skillforge.mjs +62 -0
- test_skillforge.py +108 -0
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 WhySoCodius
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,10 +1,65 @@
|
|
| 1 |
---
|
| 2 |
title: Skill Forge
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: purple
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Skill Forge
|
| 3 |
+
emoji: 🛠️
|
| 4 |
+
colorFrom: indigo
|
| 5 |
colorTo: purple
|
| 6 |
sdk: static
|
| 7 |
+
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Validate, lint & scaffold Agent Skills in-browser
|
| 11 |
+
tags:
|
| 12 |
+
- agents
|
| 13 |
+
- skills
|
| 14 |
+
- developer-tools
|
| 15 |
+
- mcp
|
| 16 |
---
|
| 17 |
|
| 18 |
+
# 🛠️ Skill Forge
|
| 19 |
+
|
| 20 |
+
Author better **Agent Skills**. A `SKILL.md` is a YAML frontmatter block plus a
|
| 21 |
+
Markdown body; the `description` is how an agent decides *when* to use the skill,
|
| 22 |
+
and a weak one means it silently never fires. Skill Forge catches that before you
|
| 23 |
+
ship — and runs **entirely client-side**, so nothing you paste leaves the page.
|
| 24 |
+
|
| 25 |
+
Rides the current wave of skill-centric agents — e.g.
|
| 26 |
+
[*Repo-To-Skill: Distilling GitHub Repositories Into AI4AI Skills*](https://huggingface.co/papers/2609.02749)
|
| 27 |
+
(a 5,000-skill library lifting an ML-research agent +134% on MLE-bench) — by
|
| 28 |
+
making the authoring loop fast and checkable.
|
| 29 |
+
|
| 30 |
+
## What it does
|
| 31 |
+
|
| 32 |
+
| Tab | Use |
|
| 33 |
+
|---|---|
|
| 34 |
+
| **Validate** | Parse `SKILL.md`; flag broken frontmatter, non-kebab `name`, over-long or trigger-less `description`, empty/unstructured body, absolute bundled-file links. |
|
| 35 |
+
| **Lint description** | Score the `description` 0–100 for trigger reliability: length band, explicit "use when", action verbs, concrete examples, third-person phrasing. |
|
| 36 |
+
| **Scaffold** | Generate a valid starter `SKILL.md` from a name + description + trigger, then validate it. |
|
| 37 |
+
|
| 38 |
+
## Files
|
| 39 |
+
|
| 40 |
+
| File | |
|
| 41 |
+
|---|---|
|
| 42 |
+
| [`index.html`](index.html) + [`skillforge.js`](skillforge.js) | the static app (js-yaml from CDN) |
|
| 43 |
+
| [`skillforge.py`](skillforge.py) | same logic in Python — `validate_skill`, `lint_description`, `scaffold_skill`, `package_skill` |
|
| 44 |
+
| [`app.py`](app.py) | Gradio UI **+ MCP server** (`skill_validate`, `skill_lint_description`, `skill_scaffold`) for running locally / on a PRO Space |
|
| 45 |
+
| `test_skillforge.py`, `test_skillforge.mjs` | self-checks for both ports |
|
| 46 |
+
|
| 47 |
+
### Run the MCP server locally
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
pip install -r requirements.txt
|
| 51 |
+
python app.py # UI on :7860, MCP at /gradio_api/mcp/sse
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
```json
|
| 55 |
+
{ "mcpServers": { "skill-forge": { "url": "http://localhost:7860/gradio_api/mcp/sse" } } }
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
## Scope
|
| 59 |
+
|
| 60 |
+
Only the **stable, host-agnostic** structure of the format is enforced — no
|
| 61 |
+
host-specific frontmatter keys are required, and unknown keys are reported as
|
| 62 |
+
info, not errors. The linter is opinionated about `description` quality because
|
| 63 |
+
that is what determines whether the skill is ever used.
|
| 64 |
+
|
| 65 |
+
MIT licensed. All original code.
|
app.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Skill Forge — a Gradio app + MCP server for authoring Agent Skills.
|
| 3 |
+
|
| 4 |
+
Tabs: validate a SKILL.md, score its `description` for trigger reliability, and
|
| 5 |
+
scaffold a new skill. The same three functions are exposed as MCP tools
|
| 6 |
+
(`skill_validate`, `skill_lint_description`, `skill_scaffold`) so an agent can
|
| 7 |
+
call them while it writes skills for itself.
|
| 8 |
+
|
| 9 |
+
Run locally: python app.py (UI on :7860, MCP at /gradio_api/mcp/sse)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import gradio as gr
|
| 13 |
+
|
| 14 |
+
from skillforge import validate_skill, lint_description, scaffold_skill, package_skill
|
| 15 |
+
|
| 16 |
+
EXAMPLE = """---
|
| 17 |
+
name: changelog-writer
|
| 18 |
+
description: Drafts a release changelog from merged PRs. Use when the user asks to \
|
| 19 |
+
"write the changelog", "summarize what shipped", or prep release notes for a tag.
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
# Changelog Writer
|
| 23 |
+
|
| 24 |
+
## When to use this skill
|
| 25 |
+
- The user is cutting a release and wants notes grouped by type.
|
| 26 |
+
|
| 27 |
+
## Instructions
|
| 28 |
+
1. Collect merged PRs since the last tag.
|
| 29 |
+
2. Group as Features / Fixes / Internal and write one line each.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def skill_validate(skill_md: str) -> str:
|
| 34 |
+
"""Validate a SKILL.md against the Agent Skill format.
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
skill_md: Full text of a SKILL.md file (YAML frontmatter + Markdown body).
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
A Markdown report listing errors, warnings, and info.
|
| 41 |
+
"""
|
| 42 |
+
return validate_skill(skill_md).as_markdown()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def skill_lint_description(description: str) -> str:
|
| 46 |
+
"""Score a skill `description` for how reliably an agent will trigger on it.
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
description: The frontmatter `description` string on its own.
|
| 50 |
+
|
| 51 |
+
Returns:
|
| 52 |
+
A Markdown report ending with 'Trigger score: N/100'.
|
| 53 |
+
"""
|
| 54 |
+
return lint_description(description).as_markdown()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def skill_scaffold(name: str, description: str, when_to_use: str = "") -> str:
|
| 58 |
+
"""Generate a ready-to-edit SKILL.md for a new Agent Skill.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
name: kebab-case skill name, e.g. 'pdf-form-filler'.
|
| 62 |
+
description: What the skill does and when to use it.
|
| 63 |
+
when_to_use: Optional extra trigger examples.
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
The full text of a SKILL.md file.
|
| 67 |
+
"""
|
| 68 |
+
return scaffold_skill(name, description, when_to_use)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _package(skill_md: str):
|
| 72 |
+
data = package_skill(skill_md)
|
| 73 |
+
path = "skill.zip"
|
| 74 |
+
with open(path, "wb") as f:
|
| 75 |
+
f.write(data)
|
| 76 |
+
return path
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
with gr.Blocks(title="Skill Forge") as demo:
|
| 80 |
+
gr.Markdown(
|
| 81 |
+
"# 🛠️ Skill Forge\n"
|
| 82 |
+
"Validate, lint, and scaffold **Agent Skills** (`SKILL.md`). "
|
| 83 |
+
"Also an MCP server — point your agent at `/gradio_api/mcp/sse`."
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
with gr.Tab("Validate"):
|
| 87 |
+
md_in = gr.Code(value=EXAMPLE, language="yaml", label="SKILL.md", lines=20)
|
| 88 |
+
with gr.Row():
|
| 89 |
+
v_btn = gr.Button("Validate", variant="primary")
|
| 90 |
+
z_btn = gr.Button("Package as .zip")
|
| 91 |
+
v_out = gr.Markdown()
|
| 92 |
+
z_out = gr.File(label="Packaged skill", visible=True)
|
| 93 |
+
v_btn.click(skill_validate, md_in, v_out, api_name="skill_validate")
|
| 94 |
+
z_btn.click(_package, md_in, z_out)
|
| 95 |
+
|
| 96 |
+
with gr.Tab("Lint description"):
|
| 97 |
+
d_in = gr.Textbox(
|
| 98 |
+
label="Frontmatter `description`", lines=4,
|
| 99 |
+
value=('Drafts a release changelog from merged PRs. Use when the user '
|
| 100 |
+
'asks to "write the changelog" or prep release notes.'))
|
| 101 |
+
d_btn = gr.Button("Score it", variant="primary")
|
| 102 |
+
d_out = gr.Markdown()
|
| 103 |
+
d_btn.click(skill_lint_description, d_in, d_out,
|
| 104 |
+
api_name="skill_lint_description")
|
| 105 |
+
|
| 106 |
+
with gr.Tab("Scaffold"):
|
| 107 |
+
s_name = gr.Textbox(label="name (kebab-case)", value="terraform-plan-reviewer")
|
| 108 |
+
s_desc = gr.Textbox(label="description", lines=3,
|
| 109 |
+
value="Reviews a Terraform plan and summarizes drift and risk.")
|
| 110 |
+
s_when = gr.Textbox(label="when to use (optional)",
|
| 111 |
+
value='the user runs "terraform plan" and wants a risk summary')
|
| 112 |
+
s_btn = gr.Button("Generate SKILL.md", variant="primary")
|
| 113 |
+
s_out = gr.Code(language="yaml", label="SKILL.md")
|
| 114 |
+
s_btn.click(skill_scaffold, [s_name, s_desc, s_when], s_out,
|
| 115 |
+
api_name="skill_scaffold")
|
| 116 |
+
|
| 117 |
+
gr.Markdown(
|
| 118 |
+
"---\nRules checked: frontmatter parses as YAML · `name` kebab-case ≤64 · "
|
| 119 |
+
"`description` present ≤1024 with a trigger cue · non-empty structured body · "
|
| 120 |
+
"relative bundled-file links. MIT licensed."
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
if __name__ == "__main__":
|
| 124 |
+
demo.launch(mcp_server=True)
|
index.html
CHANGED
|
@@ -1,19 +1,180 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Skill Forge</title>
|
| 7 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"></script>
|
| 8 |
+
<style>
|
| 9 |
+
:root {
|
| 10 |
+
--bg: #fbfbfd; --fg: #1a1a22; --muted: #6b7280; --card: #fff;
|
| 11 |
+
--border: #e5e7eb; --accent: #5b4bd6; --code: #f4f4f7;
|
| 12 |
+
--err: #b91c1c; --warn: #b45309; --info: #1d4ed8; --ok: #047857;
|
| 13 |
+
}
|
| 14 |
+
@media (prefers-color-scheme: dark) {
|
| 15 |
+
:root {
|
| 16 |
+
--bg: #0f0f14; --fg: #e6e6ec; --muted: #9ca3af; --card: #17171f;
|
| 17 |
+
--border: #2a2a35; --accent: #a99bff; --code: #1e1e28;
|
| 18 |
+
--err: #f87171; --warn: #fbbf24; --info: #93c5fd; --ok: #6ee7b7;
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
* { box-sizing: border-box; }
|
| 22 |
+
body {
|
| 23 |
+
margin: 0; background: var(--bg); color: var(--fg);
|
| 24 |
+
font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
| 25 |
+
}
|
| 26 |
+
.wrap { max-width: 900px; margin: 0 auto; padding: 32px 20px 64px; }
|
| 27 |
+
h1 { font-size: 26px; margin: 0 0 4px; }
|
| 28 |
+
.sub { color: var(--muted); margin: 0 0 24px; }
|
| 29 |
+
.sub code { background: var(--code); padding: 1px 6px; border-radius: 5px; }
|
| 30 |
+
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 20px; flex-wrap: wrap; }
|
| 31 |
+
.tabs button {
|
| 32 |
+
background: none; border: 0; padding: 10px 14px; font: inherit; color: var(--muted);
|
| 33 |
+
cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px;
|
| 34 |
+
}
|
| 35 |
+
.tabs button.active { color: var(--fg); border-bottom-color: var(--accent); font-weight: 600; }
|
| 36 |
+
.panel { display: none; }
|
| 37 |
+
.panel.active { display: block; }
|
| 38 |
+
label { display: block; font-weight: 600; margin: 14px 0 6px; font-size: 13px; }
|
| 39 |
+
textarea, input[type=text] {
|
| 40 |
+
width: 100%; background: var(--card); color: var(--fg); border: 1px solid var(--border);
|
| 41 |
+
border-radius: 8px; padding: 10px 12px; font: inherit; resize: vertical;
|
| 42 |
+
}
|
| 43 |
+
textarea.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
|
| 44 |
+
button.go {
|
| 45 |
+
margin-top: 14px; background: var(--accent); color: #fff; border: 0; border-radius: 8px;
|
| 46 |
+
padding: 10px 18px; font: inherit; font-weight: 600; cursor: pointer;
|
| 47 |
+
}
|
| 48 |
+
button.go.ghost { background: transparent; color: var(--accent); border: 1px solid var(--accent); margin-left: 8px; }
|
| 49 |
+
.out {
|
| 50 |
+
margin-top: 18px; background: var(--card); border: 1px solid var(--border);
|
| 51 |
+
border-radius: 10px; padding: 4px 18px; min-height: 40px;
|
| 52 |
+
}
|
| 53 |
+
.out:empty { display: none; }
|
| 54 |
+
.out h2 { font-size: 17px; }
|
| 55 |
+
.out ul { padding-left: 20px; }
|
| 56 |
+
.out li { margin: 3px 0; }
|
| 57 |
+
.out code, pre code { background: var(--code); padding: 1px 5px; border-radius: 4px; font-size: 13px; }
|
| 58 |
+
pre { background: var(--code); padding: 14px; border-radius: 8px; overflow-x: auto; }
|
| 59 |
+
.foot { margin-top: 40px; color: var(--muted); font-size: 13px; border-top: 1px solid var(--border); padding-top: 16px; }
|
| 60 |
+
.foot a { color: var(--accent); }
|
| 61 |
+
.badge { display:inline-block; font-size:12px; padding:2px 8px; border-radius:999px; border:1px solid var(--border); color:var(--muted); }
|
| 62 |
+
</style>
|
| 63 |
+
</head>
|
| 64 |
+
<body>
|
| 65 |
+
<div class="wrap">
|
| 66 |
+
<h1>🛠️ Skill Forge</h1>
|
| 67 |
+
<p class="sub">
|
| 68 |
+
Validate, lint & scaffold <strong>Agent Skills</strong> (<code>SKILL.md</code>).
|
| 69 |
+
Runs entirely in your browser. <span class="badge">static · no upload</span>
|
| 70 |
+
</p>
|
| 71 |
+
|
| 72 |
+
<div class="tabs">
|
| 73 |
+
<button data-tab="validate" class="active">Validate</button>
|
| 74 |
+
<button data-tab="lint">Lint description</button>
|
| 75 |
+
<button data-tab="scaffold">Scaffold</button>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<section id="validate" class="panel active">
|
| 79 |
+
<label for="md">SKILL.md</label>
|
| 80 |
+
<textarea id="md" class="mono" rows="18"></textarea>
|
| 81 |
+
<button class="go" id="v-go">Validate</button>
|
| 82 |
+
<button class="go ghost" id="v-sample">Load a broken sample</button>
|
| 83 |
+
<div class="out" id="v-out"></div>
|
| 84 |
+
</section>
|
| 85 |
+
|
| 86 |
+
<section id="lint" class="panel">
|
| 87 |
+
<label for="desc">Frontmatter <code>description</code></label>
|
| 88 |
+
<textarea id="desc" rows="4">Drafts a release changelog from merged PRs. Use when the user asks to "write the changelog" or prep release notes for a tag.</textarea>
|
| 89 |
+
<button class="go" id="l-go">Score it</button>
|
| 90 |
+
<div class="out" id="l-out"></div>
|
| 91 |
+
</section>
|
| 92 |
+
|
| 93 |
+
<section id="scaffold" class="panel">
|
| 94 |
+
<label for="s-name">name (kebab-case)</label>
|
| 95 |
+
<input type="text" id="s-name" value="terraform-plan-reviewer" />
|
| 96 |
+
<label for="s-desc">description</label>
|
| 97 |
+
<textarea id="s-desc" rows="3">Reviews a Terraform plan and summarizes drift and risk.</textarea>
|
| 98 |
+
<label for="s-when">when to use (optional)</label>
|
| 99 |
+
<input type="text" id="s-when" value='the user runs "terraform plan" and wants a risk summary' />
|
| 100 |
+
<button class="go" id="s-go">Generate SKILL.md</button>
|
| 101 |
+
<div class="out" id="s-out"></div>
|
| 102 |
+
</section>
|
| 103 |
+
|
| 104 |
+
<p class="foot">
|
| 105 |
+
Checks the stable, host-agnostic structure: frontmatter parses as YAML ·
|
| 106 |
+
<code>name</code> kebab-case ≤64 · <code>description</code> present ≤1024 with a
|
| 107 |
+
trigger cue · non-empty structured body · relative bundled-file links. Unknown
|
| 108 |
+
frontmatter keys are info, not errors.
|
| 109 |
+
<br />Need the MCP server / Python API? <code>pip install -r requirements.txt && python app.py</code>
|
| 110 |
+
— see <a href="./app.py">app.py</a>. MIT licensed · all original code.
|
| 111 |
+
</p>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<script src="./skillforge.js"></script>
|
| 115 |
+
<script>
|
| 116 |
+
(function () {
|
| 117 |
+
var GOOD = "---\nname: changelog-writer\ndescription: Drafts a release changelog from merged PRs. Use when the user asks to \"write the changelog\", \"summarize what shipped\", or prep release notes for a tag.\n---\n\n# Changelog Writer\n\n## When to use this skill\n- The user is cutting a release and wants notes grouped by type.\n\n## Instructions\n1. Collect merged PRs since the last tag.\n2. Group as Features / Fixes / Internal and write one line each.\n";
|
| 118 |
+
var BROKEN = "---\nname: Changelog_Writer!!\ndescription: makes changelogs\n---\n";
|
| 119 |
+
|
| 120 |
+
document.getElementById("md").value = GOOD;
|
| 121 |
+
|
| 122 |
+
// tiny markdown renderer: headings (##), bold (**x**), inline `code`, - lists
|
| 123 |
+
function mdToHtml(md) {
|
| 124 |
+
var lines = md.split("\n"), html = [], inList = false;
|
| 125 |
+
lines.forEach(function (ln) {
|
| 126 |
+
var h = ln.match(/^(#{1,6})\s+(.*)/);
|
| 127 |
+
if (h) { if (inList) { html.push("</ul>"); inList = false; }
|
| 128 |
+
html.push("<h" + h[1].length + ">" + inline(h[2]) + "</h" + h[1].length + ">"); return; }
|
| 129 |
+
var li = ln.match(/^-\s+(.*)/);
|
| 130 |
+
if (li) { if (!inList) { html.push("<ul>"); inList = true; }
|
| 131 |
+
html.push("<li>" + inline(li[1]) + "</li>"); return; }
|
| 132 |
+
if (inList) { html.push("</ul>"); inList = false; }
|
| 133 |
+
if (ln.trim() === "") return;
|
| 134 |
+
html.push("<p>" + inline(ln) + "</p>");
|
| 135 |
+
});
|
| 136 |
+
if (inList) html.push("</ul>");
|
| 137 |
+
return html.join("\n");
|
| 138 |
+
}
|
| 139 |
+
function esc(s) { return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
| 140 |
+
function inline(s) {
|
| 141 |
+
return esc(s).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
| 142 |
+
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
document.querySelectorAll(".tabs button").forEach(function (b) {
|
| 146 |
+
b.addEventListener("click", function () {
|
| 147 |
+
document.querySelectorAll(".tabs button").forEach(function (x) { x.classList.remove("active"); });
|
| 148 |
+
document.querySelectorAll(".panel").forEach(function (x) { x.classList.remove("active"); });
|
| 149 |
+
b.classList.add("active");
|
| 150 |
+
document.getElementById(b.dataset.tab).classList.add("active");
|
| 151 |
+
});
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
document.getElementById("v-go").addEventListener("click", function () {
|
| 155 |
+
document.getElementById("v-out").innerHTML =
|
| 156 |
+
mdToHtml(SkillForge.validateSkill(document.getElementById("md").value).asMarkdown());
|
| 157 |
+
});
|
| 158 |
+
document.getElementById("v-sample").addEventListener("click", function () {
|
| 159 |
+
document.getElementById("md").value = BROKEN;
|
| 160 |
+
document.getElementById("v-go").click();
|
| 161 |
+
});
|
| 162 |
+
document.getElementById("l-go").addEventListener("click", function () {
|
| 163 |
+
document.getElementById("l-out").innerHTML =
|
| 164 |
+
mdToHtml(SkillForge.lintDescription(document.getElementById("desc").value).asMarkdown());
|
| 165 |
+
});
|
| 166 |
+
document.getElementById("s-go").addEventListener("click", function () {
|
| 167 |
+
var md = SkillForge.scaffoldSkill(
|
| 168 |
+
document.getElementById("s-name").value,
|
| 169 |
+
document.getElementById("s-desc").value,
|
| 170 |
+
document.getElementById("s-when").value);
|
| 171 |
+
var rep = SkillForge.validateSkill(md);
|
| 172 |
+
document.getElementById("s-out").innerHTML =
|
| 173 |
+
"<pre><code>" + esc(md) + "</code></pre>" + mdToHtml(rep.asMarkdown());
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
document.getElementById("v-go").click();
|
| 177 |
+
})();
|
| 178 |
+
</script>
|
| 179 |
+
</body>
|
| 180 |
</html>
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio[mcp]>=5.9
|
| 2 |
+
PyYAML>=6
|
skillforge.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* Skill Forge — browser port of skillforge.py.
|
| 3 |
+
* Pure functions; depends only on window.jsyaml (js-yaml UMD).
|
| 4 |
+
* Kept deliberately in lockstep with the Python module so behaviour matches.
|
| 5 |
+
*/
|
| 6 |
+
(function (global) {
|
| 7 |
+
"use strict";
|
| 8 |
+
|
| 9 |
+
const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
| 10 |
+
const NAME_MAX = 64;
|
| 11 |
+
const DESC_MAX = 1024;
|
| 12 |
+
const KNOWN_KEYS = new Set([
|
| 13 |
+
"name", "description", "license", "allowed-tools", "allowed_tools",
|
| 14 |
+
"metadata", "version", "compatible-with", "compatible_with",
|
| 15 |
+
]);
|
| 16 |
+
const TRIGGER_CUES = ["use when", "use this", "when the user", "when asked",
|
| 17 |
+
"trigger", "for tasks", "helps with", "invoke when"];
|
| 18 |
+
|
| 19 |
+
function Report() {
|
| 20 |
+
this.ok = true; this.errors = []; this.warnings = []; this.info = [];
|
| 21 |
+
}
|
| 22 |
+
Report.prototype.err = function (m) { this.errors.push(m); this.ok = false; };
|
| 23 |
+
Report.prototype.warn = function (m) { this.warnings.push(m); };
|
| 24 |
+
Report.prototype.note = function (m) { this.info.push(m); };
|
| 25 |
+
Report.prototype.asMarkdown = function () {
|
| 26 |
+
const head = this.ok ? "## ✅ Valid skill" : "## ❌ Invalid skill";
|
| 27 |
+
const out = [head, ""];
|
| 28 |
+
[["Errors", this.errors, "🔴"],
|
| 29 |
+
["Warnings", this.warnings, "🟡"],
|
| 30 |
+
["Info", this.info, "🔵"]].forEach(function (t) {
|
| 31 |
+
if (t[1].length) {
|
| 32 |
+
out.push("**" + t[0] + "**");
|
| 33 |
+
t[1].forEach(function (x) { out.push("- " + t[2] + " " + x); });
|
| 34 |
+
out.push("");
|
| 35 |
+
}
|
| 36 |
+
});
|
| 37 |
+
if (this.ok && !this.warnings.length) out.push("_No issues found._");
|
| 38 |
+
return out.join("\n").trim();
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
function splitFrontmatter(text) {
|
| 42 |
+
text = text.replace(/^/, "");
|
| 43 |
+
if (!text.startsWith("---")) return [null, text];
|
| 44 |
+
const m = text.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?([\s\S]*)$/);
|
| 45 |
+
if (!m) return [null, text];
|
| 46 |
+
return [m[1], m[2]];
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function validateSkill(skillMd) {
|
| 50 |
+
const r = new Report();
|
| 51 |
+
if (!skillMd || !skillMd.trim()) { r.err("File is empty."); return r; }
|
| 52 |
+
|
| 53 |
+
const parts = splitFrontmatter(skillMd);
|
| 54 |
+
const fmRaw = parts[0], body = parts[1];
|
| 55 |
+
if (fmRaw === null) {
|
| 56 |
+
r.err("No YAML frontmatter. SKILL.md must start with a '---' fenced block.");
|
| 57 |
+
return r;
|
| 58 |
+
}
|
| 59 |
+
let fm;
|
| 60 |
+
try { fm = global.jsyaml.load(fmRaw) || {}; }
|
| 61 |
+
catch (e) { r.err("Frontmatter is not valid YAML: " + e.message); return r; }
|
| 62 |
+
if (typeof fm !== "object" || Array.isArray(fm)) {
|
| 63 |
+
r.err("Frontmatter must be a YAML mapping (key: value pairs)."); return r;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
let name = fm.name;
|
| 67 |
+
if (name === undefined || name === null || !String(name).trim()) {
|
| 68 |
+
r.err("`name` is required in frontmatter.");
|
| 69 |
+
} else {
|
| 70 |
+
name = String(name).trim();
|
| 71 |
+
if (name.length > NAME_MAX) r.err("`name` is " + name.length + " chars; keep it <= " + NAME_MAX + ".");
|
| 72 |
+
if (!NAME_RE.test(name)) r.err("`name` must be kebab-case: lowercase letters, digits, single hyphens.");
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
let desc = fm.description;
|
| 76 |
+
if (desc === undefined || desc === null || !String(desc).trim()) {
|
| 77 |
+
r.err("`description` is required — it is how an agent decides to use the skill.");
|
| 78 |
+
} else {
|
| 79 |
+
desc = String(desc).trim();
|
| 80 |
+
if (desc.length > DESC_MAX) r.err("`description` is " + desc.length + " chars; keep it <= " + DESC_MAX + ".");
|
| 81 |
+
if (desc.length < 40) r.warn("`description` is very short; say what the skill does *and* when to use it.");
|
| 82 |
+
const low = desc.toLowerCase();
|
| 83 |
+
if (!TRIGGER_CUES.some(function (c) { return low.indexOf(c) !== -1; }))
|
| 84 |
+
r.warn("`description` has no explicit trigger ('Use when...'); agents may not fire it.");
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
if (!body.trim()) {
|
| 88 |
+
r.err("Body is empty. Put the actual instructions after the frontmatter.");
|
| 89 |
+
} else {
|
| 90 |
+
if (!/^#{1,6}\s/m.test(body) && body.length > 400)
|
| 91 |
+
r.warn("Long body with no Markdown headings; add structure for skimmability.");
|
| 92 |
+
const links = body.match(/\[[^\]]*\]\(([^)]+)\)/g) || [];
|
| 93 |
+
links.forEach(function (raw) {
|
| 94 |
+
const link = raw.match(/\(([^)]+)\)/)[1];
|
| 95 |
+
if (/^(https?:\/\/|#|mailto:)/.test(link)) return;
|
| 96 |
+
if (link.startsWith("/"))
|
| 97 |
+
r.warn("Absolute path link '" + link + "'; use a path relative to the skill folder.");
|
| 98 |
+
});
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
const unknown = Object.keys(fm).filter(function (k) { return !KNOWN_KEYS.has(k); }).sort();
|
| 102 |
+
if (unknown.length)
|
| 103 |
+
r.note("Non-standard frontmatter keys (ignored by most hosts): " + unknown.join(", "));
|
| 104 |
+
|
| 105 |
+
const at = fm["allowed-tools"] !== undefined ? fm["allowed-tools"] : fm.allowed_tools;
|
| 106 |
+
if (at !== undefined && at !== null && !Array.isArray(at) && typeof at !== "string")
|
| 107 |
+
r.warn("`allowed-tools` should be a list (or comma string) of tool names.");
|
| 108 |
+
|
| 109 |
+
return r;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function lintDescription(description) {
|
| 113 |
+
const r = new Report();
|
| 114 |
+
const d = (description || "").trim();
|
| 115 |
+
if (!d) { r.err("Empty description."); return r; }
|
| 116 |
+
|
| 117 |
+
let score = 100;
|
| 118 |
+
const low = d.toLowerCase();
|
| 119 |
+
const n = d.length;
|
| 120 |
+
|
| 121 |
+
if (n < 40) { score -= 35; r.warn("Too short (" + n + " chars). Aim for ~150–500."); }
|
| 122 |
+
else if (n < 150) { score -= 10; r.warn("A bit short (" + n + " chars); add concrete trigger cases."); }
|
| 123 |
+
else if (n > DESC_MAX) { score -= 30; r.err("Over the " + DESC_MAX + "-char limit (" + n + ")."); }
|
| 124 |
+
else if (n > 700) { score -= 8; r.warn("Long (" + n + " chars); tighten to the essentials."); }
|
| 125 |
+
|
| 126 |
+
if (!TRIGGER_CUES.some(function (c) { return low.indexOf(c) !== -1; })) {
|
| 127 |
+
score -= 25;
|
| 128 |
+
r.warn("No explicit trigger phrase. Add 'Use when the user...' with real examples.");
|
| 129 |
+
}
|
| 130 |
+
if (/^(this skill|a skill|the skill|this is)/.test(low)) {
|
| 131 |
+
score -= 10;
|
| 132 |
+
r.warn("Starts with 'This skill...'. Lead with the capability or the trigger.");
|
| 133 |
+
}
|
| 134 |
+
if (/\b(i|you|we|my|your)\b/.test(low)) {
|
| 135 |
+
score -= 6;
|
| 136 |
+
r.note("Uses first/second person; third-person reads better in a registry.");
|
| 137 |
+
}
|
| 138 |
+
const verbs = low.match(/\b(create|build|convert|review|audit|generate|analy[sz]e|extract|summari[sz]e|refactor|debug|validate|lint|format|deploy|test|search|fetch|translate|render|plan)\w*/g);
|
| 139 |
+
if (!verbs) { score -= 12; r.warn("No action verbs; name what the skill *does*."); }
|
| 140 |
+
|
| 141 |
+
if (/(e\.g\.|for example|such as|like )/.test(low)) r.note("Has examples — good for trigger matching.");
|
| 142 |
+
else { score -= 8; r.warn("No examples. Concrete phrases ('e.g. \"redesign my landing page\"') help matching."); }
|
| 143 |
+
|
| 144 |
+
score = Math.max(0, Math.min(100, score));
|
| 145 |
+
const verdict = score >= 80 ? "strong" : score >= 55 ? "usable" : "weak";
|
| 146 |
+
r.note("Trigger score: " + score + "/100 (" + verdict + ")");
|
| 147 |
+
r.ok = score >= 55;
|
| 148 |
+
r.score = score;
|
| 149 |
+
return r;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
function scaffoldSkill(name, description, whenToUse) {
|
| 153 |
+
name = (name || "my-skill").trim().toLowerCase().replace(/ /g, "-").replace(/[^a-z0-9-]/g, "") || "my-skill";
|
| 154 |
+
let desc = (description || "").split(/\s+/).filter(Boolean).join(" ");
|
| 155 |
+
whenToUse = (whenToUse || "").trim();
|
| 156 |
+
if (whenToUse) {
|
| 157 |
+
if (desc.toLowerCase().indexOf("use when") === -1) {
|
| 158 |
+
desc = desc
|
| 159 |
+
? desc + " Use when " + whenToUse.charAt(0).toLowerCase() + whenToUse.slice(1)
|
| 160 |
+
: "Use when " + whenToUse;
|
| 161 |
+
} else {
|
| 162 |
+
desc = desc + " " + whenToUse;
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
if (!desc) {
|
| 166 |
+
desc = 'Describe what this skill does and the concrete situations that should ' +
|
| 167 |
+
'trigger it, e.g. "Use when the user asks to ...".';
|
| 168 |
+
}
|
| 169 |
+
const title = name.replace(/-/g, " ").replace(/\b\w/g, function (c) { return c.toUpperCase(); });
|
| 170 |
+
return "---\n" +
|
| 171 |
+
"name: " + name + "\n" +
|
| 172 |
+
"description: " + desc + "\n" +
|
| 173 |
+
"---\n\n" +
|
| 174 |
+
"# " + title + "\n\n" +
|
| 175 |
+
"## When to use this skill\n\n" +
|
| 176 |
+
"- Trigger 1 — a concrete user request this handles.\n" +
|
| 177 |
+
"- Trigger 2 — another phrasing or situation.\n" +
|
| 178 |
+
"- Do **not** use it for: <the near-miss cases that belong elsewhere>.\n\n" +
|
| 179 |
+
"## Instructions\n\n" +
|
| 180 |
+
"1. First step.\n2. Second step.\n3. What to hand back to the user.\n\n" +
|
| 181 |
+
"## Notes\n\n" +
|
| 182 |
+
"- Edge cases, gotchas, and links to bundled files (paths relative to this folder).\n";
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
global.SkillForge = { splitFrontmatter, validateSkill, lintDescription, scaffoldSkill };
|
| 186 |
+
})(typeof window !== "undefined" ? window : globalThis);
|
skillforge.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Skill Forge — core logic for validating, linting, and scaffolding Agent Skills.
|
| 3 |
+
|
| 4 |
+
An "Agent Skill" is a folder with a SKILL.md at its root: a YAML frontmatter
|
| 5 |
+
block (`--- ... ---`) followed by a Markdown body. The frontmatter names the
|
| 6 |
+
skill and, crucially, describes *when* an agent should reach for it; the body is
|
| 7 |
+
the instructions the agent follows once it does.
|
| 8 |
+
|
| 9 |
+
This module is pure stdlib + PyYAML so it can be unit-tested without Gradio.
|
| 10 |
+
Nothing here is copied from any spec document — the rules below are the stable,
|
| 11 |
+
widely-agreed structural constraints of the format.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import io
|
| 17 |
+
import re
|
| 18 |
+
import zipfile
|
| 19 |
+
from dataclasses import dataclass, field
|
| 20 |
+
|
| 21 |
+
import yaml
|
| 22 |
+
|
| 23 |
+
NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
| 24 |
+
NAME_MAX = 64
|
| 25 |
+
DESC_MAX = 1024
|
| 26 |
+
# Keys commonly recognised across skill hosts. Unknown keys are only *info*.
|
| 27 |
+
KNOWN_KEYS = {
|
| 28 |
+
"name", "description", "license", "allowed-tools", "allowed_tools",
|
| 29 |
+
"metadata", "version", "compatible-with", "compatible_with",
|
| 30 |
+
}
|
| 31 |
+
TRIGGER_CUES = ("use when", "use this", "when the user", "when asked",
|
| 32 |
+
"trigger", "for tasks", "helps with", "invoke when")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class Report:
|
| 37 |
+
ok: bool = True
|
| 38 |
+
errors: list[str] = field(default_factory=list)
|
| 39 |
+
warnings: list[str] = field(default_factory=list)
|
| 40 |
+
info: list[str] = field(default_factory=list)
|
| 41 |
+
|
| 42 |
+
def err(self, m: str) -> None:
|
| 43 |
+
self.errors.append(m)
|
| 44 |
+
self.ok = False
|
| 45 |
+
|
| 46 |
+
def warn(self, m: str) -> None:
|
| 47 |
+
self.warnings.append(m)
|
| 48 |
+
|
| 49 |
+
def note(self, m: str) -> None:
|
| 50 |
+
self.info.append(m)
|
| 51 |
+
|
| 52 |
+
def as_markdown(self) -> str:
|
| 53 |
+
head = "## ✅ Valid skill" if self.ok else "## ❌ Invalid skill"
|
| 54 |
+
lines = [head, ""]
|
| 55 |
+
for label, items, icon in (
|
| 56 |
+
("Errors", self.errors, "🔴"),
|
| 57 |
+
("Warnings", self.warnings, "🟡"),
|
| 58 |
+
("Info", self.info, "🔵"),
|
| 59 |
+
):
|
| 60 |
+
if items:
|
| 61 |
+
lines.append(f"**{label}**")
|
| 62 |
+
lines += [f"- {icon} {x}" for x in items]
|
| 63 |
+
lines.append("")
|
| 64 |
+
if self.ok and not self.warnings:
|
| 65 |
+
lines.append("_No issues found._")
|
| 66 |
+
return "\n".join(lines).strip()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def split_frontmatter(text: str) -> tuple[str | None, str]:
|
| 70 |
+
"""Return (frontmatter_yaml, body). frontmatter is None if no leading block."""
|
| 71 |
+
text = text.lstrip("")
|
| 72 |
+
if not text.startswith("---"):
|
| 73 |
+
return None, text
|
| 74 |
+
m = re.match(r"^---[ \t]*\r?\n(.*?)\r?\n---[ \t]*\r?\n?(.*)\Z", text, re.S)
|
| 75 |
+
if not m:
|
| 76 |
+
return None, text
|
| 77 |
+
return m.group(1), m.group(2)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def validate_skill(skill_md: str) -> Report:
|
| 81 |
+
"""Validate a SKILL.md string against the Agent Skill format.
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
skill_md: Full text of a SKILL.md file (frontmatter + Markdown body).
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
A Report with ok/errors/warnings/info. Errors mean the skill will not
|
| 88 |
+
load or trigger reliably; warnings are quality problems worth fixing.
|
| 89 |
+
"""
|
| 90 |
+
r = Report()
|
| 91 |
+
if not skill_md or not skill_md.strip():
|
| 92 |
+
r.err("File is empty.")
|
| 93 |
+
return r
|
| 94 |
+
|
| 95 |
+
fm_raw, body = split_frontmatter(skill_md)
|
| 96 |
+
if fm_raw is None:
|
| 97 |
+
r.err("No YAML frontmatter. SKILL.md must start with a '---' fenced block.")
|
| 98 |
+
return r
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
fm = yaml.safe_load(fm_raw) or {}
|
| 102 |
+
except yaml.YAMLError as e:
|
| 103 |
+
r.err(f"Frontmatter is not valid YAML: {e}")
|
| 104 |
+
return r
|
| 105 |
+
if not isinstance(fm, dict):
|
| 106 |
+
r.err("Frontmatter must be a YAML mapping (key: value pairs).")
|
| 107 |
+
return r
|
| 108 |
+
|
| 109 |
+
# name
|
| 110 |
+
name = fm.get("name")
|
| 111 |
+
if not name or not str(name).strip():
|
| 112 |
+
r.err("`name` is required in frontmatter.")
|
| 113 |
+
else:
|
| 114 |
+
name = str(name).strip()
|
| 115 |
+
if len(name) > NAME_MAX:
|
| 116 |
+
r.err(f"`name` is {len(name)} chars; keep it <= {NAME_MAX}.")
|
| 117 |
+
if not NAME_RE.match(name):
|
| 118 |
+
r.err("`name` must be kebab-case: lowercase letters, digits, single hyphens.")
|
| 119 |
+
|
| 120 |
+
# description
|
| 121 |
+
desc = fm.get("description")
|
| 122 |
+
if not desc or not str(desc).strip():
|
| 123 |
+
r.err("`description` is required — it is how an agent decides to use the skill.")
|
| 124 |
+
else:
|
| 125 |
+
desc = str(desc).strip()
|
| 126 |
+
if len(desc) > DESC_MAX:
|
| 127 |
+
r.err(f"`description` is {len(desc)} chars; keep it <= {DESC_MAX}.")
|
| 128 |
+
if len(desc) < 40:
|
| 129 |
+
r.warn("`description` is very short; say what the skill does *and* when to use it.")
|
| 130 |
+
if not any(cue in desc.lower() for cue in TRIGGER_CUES):
|
| 131 |
+
r.warn("`description` has no explicit trigger ('Use when...'); agents may not fire it.")
|
| 132 |
+
|
| 133 |
+
# body
|
| 134 |
+
if not body.strip():
|
| 135 |
+
r.err("Body is empty. Put the actual instructions after the frontmatter.")
|
| 136 |
+
else:
|
| 137 |
+
if not re.search(r"^#{1,6}\s", body, re.M) and len(body) > 400:
|
| 138 |
+
r.warn("Long body with no Markdown headings; add structure for skimmability.")
|
| 139 |
+
for link in re.findall(r"\[[^\]]*\]\(([^)]+)\)", body):
|
| 140 |
+
if link.startswith(("http://", "https://", "#", "mailto:")):
|
| 141 |
+
continue
|
| 142 |
+
if link.startswith("/"):
|
| 143 |
+
r.warn(f"Absolute path link '{link}'; use a path relative to the skill folder.")
|
| 144 |
+
|
| 145 |
+
# unknown keys (informational only)
|
| 146 |
+
unknown = sorted(set(fm) - KNOWN_KEYS)
|
| 147 |
+
if unknown:
|
| 148 |
+
r.note(f"Non-standard frontmatter keys (ignored by most hosts): {', '.join(unknown)}")
|
| 149 |
+
|
| 150 |
+
at = fm.get("allowed-tools", fm.get("allowed_tools"))
|
| 151 |
+
if at is not None and not isinstance(at, (list, str)):
|
| 152 |
+
r.warn("`allowed-tools` should be a list (or comma string) of tool names.")
|
| 153 |
+
|
| 154 |
+
return r
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def lint_description(description: str) -> Report:
|
| 158 |
+
"""Score a skill `description` for how reliably an agent will trigger on it.
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
description: The frontmatter `description` string.
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
A Report whose first info line is 'Trigger score: N/100'.
|
| 165 |
+
"""
|
| 166 |
+
r = Report()
|
| 167 |
+
d = (description or "").strip()
|
| 168 |
+
if not d:
|
| 169 |
+
r.err("Empty description.")
|
| 170 |
+
return r
|
| 171 |
+
|
| 172 |
+
score = 100
|
| 173 |
+
low = d.lower()
|
| 174 |
+
|
| 175 |
+
n = len(d)
|
| 176 |
+
if n < 40:
|
| 177 |
+
score -= 35
|
| 178 |
+
r.warn(f"Too short ({n} chars). Aim for ~150–500.")
|
| 179 |
+
elif n < 150:
|
| 180 |
+
score -= 10
|
| 181 |
+
r.warn(f"A bit short ({n} chars); add concrete trigger cases.")
|
| 182 |
+
elif n > DESC_MAX:
|
| 183 |
+
score -= 30
|
| 184 |
+
r.err(f"Over the {DESC_MAX}-char limit ({n}).")
|
| 185 |
+
elif n > 700:
|
| 186 |
+
score -= 8
|
| 187 |
+
r.warn(f"Long ({n} chars); tighten to the essentials.")
|
| 188 |
+
|
| 189 |
+
if not any(cue in low for cue in TRIGGER_CUES):
|
| 190 |
+
score -= 25
|
| 191 |
+
r.warn("No explicit trigger phrase. Add 'Use when the user...' with real examples.")
|
| 192 |
+
|
| 193 |
+
if low.startswith(("this skill", "a skill", "the skill", "this is")):
|
| 194 |
+
score -= 10
|
| 195 |
+
r.warn("Starts with 'This skill...'. Lead with the capability or the trigger.")
|
| 196 |
+
|
| 197 |
+
if re.search(r"\b(i|you|we|my|your)\b", low):
|
| 198 |
+
score -= 6
|
| 199 |
+
r.note("Uses first/second person; third-person reads better in a registry.")
|
| 200 |
+
|
| 201 |
+
verbs = re.findall(r"\b(create|build|convert|review|audit|generate|analy[sz]e|"
|
| 202 |
+
r"extract|summari[sz]e|refactor|debug|validate|lint|format|"
|
| 203 |
+
r"deploy|test|search|fetch|translate|render|plan)\w*", low)
|
| 204 |
+
if not verbs:
|
| 205 |
+
score -= 12
|
| 206 |
+
r.warn("No action verbs; name what the skill *does*.")
|
| 207 |
+
|
| 208 |
+
if "e.g." in low or "for example" in low or "such as" in low or "like " in low:
|
| 209 |
+
r.note("Has examples — good for trigger matching.")
|
| 210 |
+
else:
|
| 211 |
+
score -= 8
|
| 212 |
+
r.warn("No examples. Concrete phrases ('e.g. \"redesign my landing page\"') help matching.")
|
| 213 |
+
|
| 214 |
+
score = max(0, min(100, score))
|
| 215 |
+
verdict = "strong" if score >= 80 else "usable" if score >= 55 else "weak"
|
| 216 |
+
r.note(f"Trigger score: {score}/100 ({verdict})")
|
| 217 |
+
r.ok = score >= 55
|
| 218 |
+
return r
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def scaffold_skill(name: str, description: str, when_to_use: str = "") -> str:
|
| 222 |
+
"""Return a ready-to-edit SKILL.md for a new Agent Skill.
|
| 223 |
+
|
| 224 |
+
Args:
|
| 225 |
+
name: kebab-case skill name, e.g. 'pdf-form-filler'.
|
| 226 |
+
description: One or two sentences: what it does and when to use it.
|
| 227 |
+
when_to_use: Optional extra trigger examples appended to the description.
|
| 228 |
+
|
| 229 |
+
Returns:
|
| 230 |
+
The full text of a SKILL.md file.
|
| 231 |
+
"""
|
| 232 |
+
name = (name or "my-skill").strip().lower().replace(" ", "-")
|
| 233 |
+
name = re.sub(r"[^a-z0-9-]", "", name) or "my-skill"
|
| 234 |
+
desc = " ".join((description or "").split())
|
| 235 |
+
if when_to_use.strip():
|
| 236 |
+
extra = when_to_use.strip()
|
| 237 |
+
if "use when" not in desc.lower():
|
| 238 |
+
desc = f"{desc} Use when {extra[0].lower()}{extra[1:]}" if desc else f"Use when {extra}"
|
| 239 |
+
else:
|
| 240 |
+
desc = f"{desc} {extra}"
|
| 241 |
+
if not desc:
|
| 242 |
+
desc = ("Describe what this skill does and the concrete situations that "
|
| 243 |
+
"should trigger it, e.g. \"Use when the user asks to ...\".")
|
| 244 |
+
|
| 245 |
+
return f"""---
|
| 246 |
+
name: {name}
|
| 247 |
+
description: {desc}
|
| 248 |
+
---
|
| 249 |
+
|
| 250 |
+
# {name.replace('-', ' ').title()}
|
| 251 |
+
|
| 252 |
+
## When to use this skill
|
| 253 |
+
|
| 254 |
+
- Trigger 1 — a concrete user request this handles.
|
| 255 |
+
- Trigger 2 — another phrasing or situation.
|
| 256 |
+
- Do **not** use it for: <the near-miss cases that belong elsewhere>.
|
| 257 |
+
|
| 258 |
+
## Instructions
|
| 259 |
+
|
| 260 |
+
1. First step.
|
| 261 |
+
2. Second step.
|
| 262 |
+
3. What to hand back to the user.
|
| 263 |
+
|
| 264 |
+
## Notes
|
| 265 |
+
|
| 266 |
+
- Edge cases, gotchas, and links to bundled files (paths relative to this folder).
|
| 267 |
+
"""
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def package_skill(skill_md: str, extra_files: dict[str, str] | None = None) -> bytes:
|
| 271 |
+
"""Zip a SKILL.md (and optional sibling files) into a distributable archive.
|
| 272 |
+
|
| 273 |
+
Args:
|
| 274 |
+
skill_md: The SKILL.md text. Must validate without errors.
|
| 275 |
+
extra_files: Optional {relative_path: text_content} placed next to SKILL.md.
|
| 276 |
+
|
| 277 |
+
Returns:
|
| 278 |
+
Bytes of a .zip. Raises ValueError if the skill has validation errors.
|
| 279 |
+
"""
|
| 280 |
+
rep = validate_skill(skill_md)
|
| 281 |
+
if not rep.ok:
|
| 282 |
+
raise ValueError("Skill has errors; fix them before packaging:\n" +
|
| 283 |
+
"\n".join(rep.errors))
|
| 284 |
+
fm_raw, _ = split_frontmatter(skill_md)
|
| 285 |
+
name = str((yaml.safe_load(fm_raw) or {}).get("name", "skill")).strip()
|
| 286 |
+
|
| 287 |
+
buf = io.BytesIO()
|
| 288 |
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
| 289 |
+
z.writestr(f"{name}/SKILL.md", skill_md)
|
| 290 |
+
for rel, content in (extra_files or {}).items():
|
| 291 |
+
rel = rel.lstrip("/")
|
| 292 |
+
if ".." in rel.split("/"):
|
| 293 |
+
raise ValueError(f"Unsafe path: {rel}")
|
| 294 |
+
z.writestr(f"{name}/{rel}", content)
|
| 295 |
+
return buf.getvalue()
|
test_skillforge.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Node smoke test for skillforge.js parity. Run: node test_skillforge.mjs
|
| 2 |
+
* Uses a minimal yaml shim covering only the shapes the tests exercise. */
|
| 3 |
+
import { readFileSync } from "node:fs";
|
| 4 |
+
import assert from "node:assert";
|
| 5 |
+
import vm from "node:vm";
|
| 6 |
+
|
| 7 |
+
const shim = {
|
| 8 |
+
load(src) {
|
| 9 |
+
const o = {};
|
| 10 |
+
for (const raw of src.split("\n")) {
|
| 11 |
+
const line = raw.replace(/\r$/, "");
|
| 12 |
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
| 13 |
+
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
| 14 |
+
if (!m) { const e = new Error("bad yaml line: " + line); throw e; }
|
| 15 |
+
let v = m[2].trim();
|
| 16 |
+
if (v.startsWith("[") && !v.endsWith("]")) throw new Error("unterminated flow seq");
|
| 17 |
+
o[m[1]] = v.replace(/^["']|["']$/g, "");
|
| 18 |
+
}
|
| 19 |
+
return o;
|
| 20 |
+
},
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const ctx = { window: { jsyaml: shim }, globalThis: {} };
|
| 24 |
+
vm.createContext(ctx);
|
| 25 |
+
vm.runInContext(readFileSync(new URL("./skillforge.js", import.meta.url), "utf8"), ctx);
|
| 26 |
+
const SF = ctx.window.SkillForge;
|
| 27 |
+
|
| 28 |
+
const GOOD = `---
|
| 29 |
+
name: pdf-form-filler
|
| 30 |
+
description: Fills interactive PDF forms from a data mapping. Use when the user asks to complete or auto-fill a PDF form, e.g. "fill out this application PDF".
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
# PDF Form Filler
|
| 34 |
+
|
| 35 |
+
## When to use
|
| 36 |
+
- Fillable PDF + field values.
|
| 37 |
+
|
| 38 |
+
## Instructions
|
| 39 |
+
1. Inspect fields.
|
| 40 |
+
2. Map and flatten.
|
| 41 |
+
`;
|
| 42 |
+
|
| 43 |
+
assert.ok(SF.validateSkill(GOOD).ok, "good skill should pass");
|
| 44 |
+
assert.ok(!SF.validateSkill("# no frontmatter").ok, "missing frontmatter fails");
|
| 45 |
+
|
| 46 |
+
const bad = SF.validateSkill('---\nname: Not_Kebab!!\ndescription: short\n---\n\nbody\n');
|
| 47 |
+
assert.ok(!bad.ok && bad.errors.some(e => e.includes("kebab-case")), "kebab error");
|
| 48 |
+
|
| 49 |
+
assert.ok(!SF.validateSkill('---\nname: [unclosed\n---\nbody\n').ok, "invalid yaml fails");
|
| 50 |
+
|
| 51 |
+
const weak = SF.lintDescription("Does stuff.");
|
| 52 |
+
const strong = SF.lintDescription(
|
| 53 |
+
'Converts Markdown notes into a styled slide deck. Use when the user asks to ' +
|
| 54 |
+
'"turn this into slides" or "make a deck", e.g. a report rendered as shareable slides.');
|
| 55 |
+
assert.ok(weak.score < 55 && strong.score >= 55, `scores ${weak.score}/${strong.score}`);
|
| 56 |
+
|
| 57 |
+
const md = SF.scaffoldSkill("My Cool Skill", "Reviews Terraform plans for drift.",
|
| 58 |
+
"the user runs terraform plan and wants a risk summary");
|
| 59 |
+
assert.ok(md.includes("name: my-cool-skill"), "scaffold name");
|
| 60 |
+
assert.ok(SF.validateSkill(md).ok, "scaffold output validates");
|
| 61 |
+
|
| 62 |
+
console.log("all checks passed");
|
test_skillforge.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Self-check for skillforge.py. Run: python test_skillforge.py"""
|
| 2 |
+
import io
|
| 3 |
+
import zipfile
|
| 4 |
+
|
| 5 |
+
from skillforge import (validate_skill, lint_description, scaffold_skill,
|
| 6 |
+
package_skill, split_frontmatter)
|
| 7 |
+
|
| 8 |
+
GOOD = """---
|
| 9 |
+
name: pdf-form-filler
|
| 10 |
+
description: >
|
| 11 |
+
Fills interactive PDF forms from a data mapping and flattens the result.
|
| 12 |
+
Use when the user asks to complete, populate, or auto-fill a PDF form,
|
| 13 |
+
e.g. "fill out this application PDF" or "populate the tax form fields".
|
| 14 |
+
license: MIT
|
| 15 |
+
allowed-tools:
|
| 16 |
+
- Read
|
| 17 |
+
- Bash
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
# PDF Form Filler
|
| 21 |
+
|
| 22 |
+
## When to use this skill
|
| 23 |
+
- The user hands over a fillable PDF and field values.
|
| 24 |
+
|
| 25 |
+
## Instructions
|
| 26 |
+
1. Inspect the form fields.
|
| 27 |
+
2. Map data onto them and flatten.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_split():
|
| 32 |
+
fm, body = split_frontmatter(GOOD)
|
| 33 |
+
assert fm and "pdf-form-filler" in fm
|
| 34 |
+
assert body.strip().startswith("# PDF Form Filler")
|
| 35 |
+
assert split_frontmatter("no frontmatter here")[0] is None
|
| 36 |
+
# BOM + CRLF tolerance
|
| 37 |
+
assert split_frontmatter("---\r\nname: x\r\n---\r\nbody")[0].strip() == "name: x"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_good_skill_passes():
|
| 41 |
+
r = validate_skill(GOOD)
|
| 42 |
+
assert r.ok, r.errors
|
| 43 |
+
assert not r.errors
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_missing_frontmatter():
|
| 47 |
+
r = validate_skill("# just a heading\n\nsome text")
|
| 48 |
+
assert not r.ok and any("frontmatter" in e for e in r.errors)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_bad_name_and_desc():
|
| 52 |
+
md = "---\nname: Not_KebabCase!!\ndescription: short\n---\n\nbody text here\n"
|
| 53 |
+
r = validate_skill(md)
|
| 54 |
+
assert not r.ok
|
| 55 |
+
assert any("kebab-case" in e for e in r.errors)
|
| 56 |
+
assert any("short" in w for w in r.warnings)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_empty_body():
|
| 60 |
+
md = "---\nname: ok-name\ndescription: " + "x" * 60 + " use when needed\n---\n\n \n"
|
| 61 |
+
r = validate_skill(md)
|
| 62 |
+
assert not r.ok and any("Body is empty" in e for e in r.errors)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_invalid_yaml():
|
| 66 |
+
r = validate_skill("---\nname: [unclosed\n---\nbody\n")
|
| 67 |
+
assert not r.ok and any("YAML" in e for e in r.errors)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_lint_scores():
|
| 71 |
+
weak = lint_description("Does stuff.")
|
| 72 |
+
strong = lint_description(
|
| 73 |
+
"Converts Markdown notes into a styled slide deck. Use when the user asks "
|
| 74 |
+
"to 'turn this into slides', 'make a deck', or 'present this document', "
|
| 75 |
+
"e.g. a report they want rendered as shareable slides.")
|
| 76 |
+
ws = int(weak.info[-1].split(":")[1].split("/")[0])
|
| 77 |
+
ss = int(strong.info[-1].split(":")[1].split("/")[0])
|
| 78 |
+
assert ws < 55 <= ss, (ws, ss)
|
| 79 |
+
assert strong.ok and not weak.ok
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_scaffold_roundtrips():
|
| 83 |
+
md = scaffold_skill("My Cool Skill",
|
| 84 |
+
"Reviews Terraform plans for drift.",
|
| 85 |
+
"the user runs terraform plan and wants a risk summary")
|
| 86 |
+
assert "name: my-cool-skill" in md
|
| 87 |
+
r = validate_skill(md)
|
| 88 |
+
assert r.ok, r.errors
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_package_rejects_bad_and_zips_good():
|
| 92 |
+
try:
|
| 93 |
+
package_skill("---\nname: X\n---\n")
|
| 94 |
+
assert False, "should have raised"
|
| 95 |
+
except ValueError:
|
| 96 |
+
pass
|
| 97 |
+
blob = package_skill(GOOD, {"reference.md": "# notes"})
|
| 98 |
+
with zipfile.ZipFile(io.BytesIO(blob)) as z:
|
| 99 |
+
names = z.namelist()
|
| 100 |
+
assert "pdf-form-filler/SKILL.md" in names
|
| 101 |
+
assert "pdf-form-filler/reference.md" in names
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
if __name__ == "__main__":
|
| 105 |
+
for fn in list(globals().values()):
|
| 106 |
+
if callable(fn) and getattr(fn, "__name__", "").startswith("test_"):
|
| 107 |
+
fn()
|
| 108 |
+
print("all checks passed")
|