/* * 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: .\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);