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);