WhySoCodius commited on
Commit
4fa5831
·
verified ·
1 Parent(s): 51e0a42

Skill Forge: static browser app + Python/MCP port for Agent Skills

Browse files
Files changed (9) hide show
  1. LICENSE +21 -0
  2. README.md +58 -3
  3. app.py +124 -0
  4. index.html +179 -18
  5. requirements.txt +2 -0
  6. skillforge.js +186 -0
  7. skillforge.py +295 -0
  8. test_skillforge.mjs +62 -0
  9. 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: blue
5
  colorTo: purple
6
  sdk: static
 
7
  pinned: false
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &amp; 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 &amp;&amp; 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
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")