Spaces:
Running
Running
File size: 8,268 Bytes
4fa5831 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | /*
* 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);
|