skill-forge / skillforge.js
WhySoCodius's picture
Skill Forge: static browser app + Python/MCP port for Agent Skills
4fa5831 verified
Raw
History Blame Contribute Delete
8.27 kB
/*
* Skill Forge — browser port of skillforge.py.
* Pure functions; depends only on window.jsyaml (js-yaml UMD).
* Kept deliberately in lockstep with the Python module so behaviour matches.
*/
(function (global) {
"use strict";
const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const NAME_MAX = 64;
const DESC_MAX = 1024;
const KNOWN_KEYS = new Set([
"name", "description", "license", "allowed-tools", "allowed_tools",
"metadata", "version", "compatible-with", "compatible_with",
]);
const TRIGGER_CUES = ["use when", "use this", "when the user", "when asked",
"trigger", "for tasks", "helps with", "invoke when"];
function Report() {
this.ok = true; this.errors = []; this.warnings = []; this.info = [];
}
Report.prototype.err = function (m) { this.errors.push(m); this.ok = false; };
Report.prototype.warn = function (m) { this.warnings.push(m); };
Report.prototype.note = function (m) { this.info.push(m); };
Report.prototype.asMarkdown = function () {
const head = this.ok ? "## ✅ Valid skill" : "## ❌ Invalid skill";
const out = [head, ""];
[["Errors", this.errors, "🔴"],
["Warnings", this.warnings, "🟡"],
["Info", this.info, "🔵"]].forEach(function (t) {
if (t[1].length) {
out.push("**" + t[0] + "**");
t[1].forEach(function (x) { out.push("- " + t[2] + " " + x); });
out.push("");
}
});
if (this.ok && !this.warnings.length) out.push("_No issues found._");
return out.join("\n").trim();
};
function splitFrontmatter(text) {
text = text.replace(/^/, "");
if (!text.startsWith("---")) return [null, text];
const m = text.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?([\s\S]*)$/);
if (!m) return [null, text];
return [m[1], m[2]];
}
function validateSkill(skillMd) {
const r = new Report();
if (!skillMd || !skillMd.trim()) { r.err("File is empty."); return r; }
const parts = splitFrontmatter(skillMd);
const fmRaw = parts[0], body = parts[1];
if (fmRaw === null) {
r.err("No YAML frontmatter. SKILL.md must start with a '---' fenced block.");
return r;
}
let fm;
try { fm = global.jsyaml.load(fmRaw) || {}; }
catch (e) { r.err("Frontmatter is not valid YAML: " + e.message); return r; }
if (typeof fm !== "object" || Array.isArray(fm)) {
r.err("Frontmatter must be a YAML mapping (key: value pairs)."); return r;
}
let name = fm.name;
if (name === undefined || name === null || !String(name).trim()) {
r.err("`name` is required in frontmatter.");
} else {
name = String(name).trim();
if (name.length > NAME_MAX) r.err("`name` is " + name.length + " chars; keep it <= " + NAME_MAX + ".");
if (!NAME_RE.test(name)) r.err("`name` must be kebab-case: lowercase letters, digits, single hyphens.");
}
let desc = fm.description;
if (desc === undefined || desc === null || !String(desc).trim()) {
r.err("`description` is required — it is how an agent decides to use the skill.");
} else {
desc = String(desc).trim();
if (desc.length > DESC_MAX) r.err("`description` is " + desc.length + " chars; keep it <= " + DESC_MAX + ".");
if (desc.length < 40) r.warn("`description` is very short; say what the skill does *and* when to use it.");
const low = desc.toLowerCase();
if (!TRIGGER_CUES.some(function (c) { return low.indexOf(c) !== -1; }))
r.warn("`description` has no explicit trigger ('Use when...'); agents may not fire it.");
}
if (!body.trim()) {
r.err("Body is empty. Put the actual instructions after the frontmatter.");
} else {
if (!/^#{1,6}\s/m.test(body) && body.length > 400)
r.warn("Long body with no Markdown headings; add structure for skimmability.");
const links = body.match(/\[[^\]]*\]\(([^)]+)\)/g) || [];
links.forEach(function (raw) {
const link = raw.match(/\(([^)]+)\)/)[1];
if (/^(https?:\/\/|#|mailto:)/.test(link)) return;
if (link.startsWith("/"))
r.warn("Absolute path link '" + link + "'; use a path relative to the skill folder.");
});
}
const unknown = Object.keys(fm).filter(function (k) { return !KNOWN_KEYS.has(k); }).sort();
if (unknown.length)
r.note("Non-standard frontmatter keys (ignored by most hosts): " + unknown.join(", "));
const at = fm["allowed-tools"] !== undefined ? fm["allowed-tools"] : fm.allowed_tools;
if (at !== undefined && at !== null && !Array.isArray(at) && typeof at !== "string")
r.warn("`allowed-tools` should be a list (or comma string) of tool names.");
return r;
}
function lintDescription(description) {
const r = new Report();
const d = (description || "").trim();
if (!d) { r.err("Empty description."); return r; }
let score = 100;
const low = d.toLowerCase();
const n = d.length;
if (n < 40) { score -= 35; r.warn("Too short (" + n + " chars). Aim for ~150–500."); }
else if (n < 150) { score -= 10; r.warn("A bit short (" + n + " chars); add concrete trigger cases."); }
else if (n > DESC_MAX) { score -= 30; r.err("Over the " + DESC_MAX + "-char limit (" + n + ")."); }
else if (n > 700) { score -= 8; r.warn("Long (" + n + " chars); tighten to the essentials."); }
if (!TRIGGER_CUES.some(function (c) { return low.indexOf(c) !== -1; })) {
score -= 25;
r.warn("No explicit trigger phrase. Add 'Use when the user...' with real examples.");
}
if (/^(this skill|a skill|the skill|this is)/.test(low)) {
score -= 10;
r.warn("Starts with 'This skill...'. Lead with the capability or the trigger.");
}
if (/\b(i|you|we|my|your)\b/.test(low)) {
score -= 6;
r.note("Uses first/second person; third-person reads better in a registry.");
}
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);
if (!verbs) { score -= 12; r.warn("No action verbs; name what the skill *does*."); }
if (/(e\.g\.|for example|such as|like )/.test(low)) r.note("Has examples — good for trigger matching.");
else { score -= 8; r.warn("No examples. Concrete phrases ('e.g. \"redesign my landing page\"') help matching."); }
score = Math.max(0, Math.min(100, score));
const verdict = score >= 80 ? "strong" : score >= 55 ? "usable" : "weak";
r.note("Trigger score: " + score + "/100 (" + verdict + ")");
r.ok = score >= 55;
r.score = score;
return r;
}
function scaffoldSkill(name, description, whenToUse) {
name = (name || "my-skill").trim().toLowerCase().replace(/ /g, "-").replace(/[^a-z0-9-]/g, "") || "my-skill";
let desc = (description || "").split(/\s+/).filter(Boolean).join(" ");
whenToUse = (whenToUse || "").trim();
if (whenToUse) {
if (desc.toLowerCase().indexOf("use when") === -1) {
desc = desc
? desc + " Use when " + whenToUse.charAt(0).toLowerCase() + whenToUse.slice(1)
: "Use when " + whenToUse;
} else {
desc = desc + " " + whenToUse;
}
}
if (!desc) {
desc = 'Describe what this skill does and the concrete situations that should ' +
'trigger it, e.g. "Use when the user asks to ...".';
}
const title = name.replace(/-/g, " ").replace(/\b\w/g, function (c) { return c.toUpperCase(); });
return "---\n" +
"name: " + name + "\n" +
"description: " + desc + "\n" +
"---\n\n" +
"# " + title + "\n\n" +
"## When to use this skill\n\n" +
"- Trigger 1 — a concrete user request this handles.\n" +
"- Trigger 2 — another phrasing or situation.\n" +
"- Do **not** use it for: <the near-miss cases that belong elsewhere>.\n\n" +
"## Instructions\n\n" +
"1. First step.\n2. Second step.\n3. What to hand back to the user.\n\n" +
"## Notes\n\n" +
"- Edge cases, gotchas, and links to bundled files (paths relative to this folder).\n";
}
global.SkillForge = { splitFrontmatter, validateSkill, lintDescription, scaffoldSkill };
})(typeof window !== "undefined" ? window : globalThis);