diff --git a/plugin/dist/build-manifest.json b/plugin/dist/build-manifest.json index 49edfa1..8ab66d0 100644 --- a/plugin/dist/build-manifest.json +++ b/plugin/dist/build-manifest.json @@ -1,6 +1,6 @@ { "entrypoint": "skill-creator.ts", "runtimeEntrypoint": "runtime-entry.ts", - "sourceHash": "7e988b2130537db9015790ba36e227f7b8a5844bc1200ae7ffeab250cc201368", - "builtAt": "2026-07-05T23:03:33.548Z" + "sourceHash": "7ab254f2c22233b0ced8cc832878baacd73f3bd63fa45c81019e9149bf319605", + "builtAt": "2026-07-05T23:29:06.331Z" } diff --git a/plugin/dist/package.json b/plugin/dist/package.json index 6486e54..e069f31 100644 --- a/plugin/dist/package.json +++ b/plugin/dist/package.json @@ -1,6 +1,6 @@ { "name": "opencode-skill-creator", - "version": "0.2.20", + "version": "0.2.21", "description": "OpenCode plugin for skill creation — custom tools for validation, evaluation, description optimization, benchmarking, and review serving.", "type": "module", "main": "./dist/skill-creator.js", diff --git a/plugin/dist/skill-creator.js b/plugin/dist/skill-creator.js index 9d186bb..2013d18 100644 --- a/plugin/dist/skill-creator.js +++ b/plugin/dist/skill-creator.js @@ -12352,6 +12352,12 @@ var ALLOWED_PROPERTIES = new Set([ "metadata", "compatibility" ]); +function isQuotedValue(value) { + return value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")); +} +function isBlockScalarMarker(value) { + return /^[|>](?:[1-9][+-]?|[+-][1-9]?)?$/.test(value); +} function validateSkill(skillPath) { const skillMdPath = join(skillPath, "SKILL.md"); if (!existsSync(skillMdPath)) { @@ -12370,8 +12376,9 @@ function validateSkill(skillPath) { let currentKey = ""; let currentValue = ""; let inMultiline = false; - for (const line of frontmatterText.split(` -`)) { + const frontmatterLines = frontmatterText.split(` +`); + for (const [index, line] of frontmatterLines.entries()) { if (inMultiline) { if (line.startsWith(" ") || line.startsWith("\t")) { currentValue += " " + line.trim(); @@ -12385,7 +12392,13 @@ function validateSkill(skillPath) { if (kvMatch) { currentKey = kvMatch[1]; const value = kvMatch[2].trim(); - if ([">", "|", ">-", "|-"].includes(value)) { + if (value && !isQuotedValue(value) && !isBlockScalarMarker(value) && (/:[ \t]/.test(value) || value.endsWith(":"))) { + return { + valid: false, + message: `Invalid frontmatter value for '${currentKey}' on line ${index + 2}: unquoted values containing ': ' or ending with ':' are invalid YAML and the runtime will drop this skill. Hint: quote the value (e.g. ${currentKey}: "your text here").` + }; + } + if (isBlockScalarMarker(value)) { currentValue = ""; inMultiline = true; } else if (currentKey === "metadata" && (value === "" || value === "{}")) { diff --git a/plugin/lib/validate.ts b/plugin/lib/validate.ts index afa9f1f..c703919 100644 --- a/plugin/lib/validate.ts +++ b/plugin/lib/validate.ts @@ -23,6 +23,21 @@ const ALLOWED_PROPERTIES = new Set([ "compatibility", ]) +// Intentionally simple first/last-char check — this validator is a lint +// heuristic for common frontmatter mistakes, not a full YAML parser. +function isQuotedValue(value: string): boolean { + return ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) +} + +function isBlockScalarMarker(value: string): boolean { + // header allows indentation (1-9) and chomping (+/-) indicators in either order + return /^[|>](?:[1-9][+-]?|[+-][1-9]?)?$/.test(value) +} + /** * Validate a skill directory. * @@ -56,7 +71,8 @@ export function validateSkill(skillPath: string): ValidationResult { let currentValue = "" let inMultiline = false - for (const line of frontmatterText.split("\n")) { + const frontmatterLines = frontmatterText.split("\n") + for (const [index, line] of frontmatterLines.entries()) { if (inMultiline) { if (line.startsWith(" ") || line.startsWith("\t")) { currentValue += " " + line.trim() @@ -72,7 +88,20 @@ export function validateSkill(skillPath: string): ValidationResult { currentKey = kvMatch[1] const value = kvMatch[2].trim() - if ([">", "|", ">-", "|-"].includes(value)) { + if ( + value && + !isQuotedValue(value) && + !isBlockScalarMarker(value) && + (/:[ \t]/.test(value) || value.endsWith(":")) + ) { + return { + valid: false, + // +2: frontmatter starts on file line 2, after the opening --- + message: `Invalid frontmatter value for '${currentKey}' on line ${index + 2}: unquoted values containing ': ' or ending with ':' are invalid YAML and the runtime will drop this skill. Hint: quote the value (e.g. ${currentKey}: "your text here").`, + } + } + + if (isBlockScalarMarker(value)) { currentValue = "" inMultiline = true } else if ( diff --git a/plugin/test/validate.test.ts b/plugin/test/validate.test.ts new file mode 100644 index 0000000..74ef73f --- /dev/null +++ b/plugin/test/validate.test.ts @@ -0,0 +1,172 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" + +import { expect, test } from "bun:test" + +import { validateSkill } from "../lib/validate" + +const withSkill = (frontmatter: string, fn: (skillPath: string) => T): T => { + const dir = mkdtempSync(join(tmpdir(), "skill-creator-validate-")) + try { + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "SKILL.md"), `---\n${frontmatter}\n---\n\n# Test Skill\n`) + return fn(dir) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +test("quoted description containing colon-space passes", () => { + withSkill( + `name: pdf-reader +description: "Use for PDF files: reading, extracting."`, + (skillPath) => { + expect(validateSkill(skillPath)).toEqual({ + valid: true, + message: "Skill is valid!", + }) + }, + ) +}) + +test("unquoted description without colon-space passes", () => { + withSkill( + `name: pdf-reader +description: Use for PDF files, reading and extracting.`, + (skillPath) => { + expect(validateSkill(skillPath)).toEqual({ + valid: true, + message: "Skill is valid!", + }) + }, + ) +}) + +test("bundled skill fixture passes validation", () => { + expect(validateSkill(join(import.meta.dir, "..", "skill"))).toEqual({ + valid: true, + message: "Skill is valid!", + }) +}) + +test("unquoted description with colon-space fails with quote hint", () => { + withSkill( + `name: pdf-reader +description: Use for PDF files: reading, extracting.`, + (skillPath) => { + const result = validateSkill(skillPath) + + expect(result.valid).toBe(false) + expect(result.message).toContain("description") + expect(result.message).toContain("line 3") + expect(result.message).toContain("Hint: quote the value") + expect(result.message).toContain("description: \"your text here\"") + expect(result.message).toContain( + "unquoted values containing ': ' or ending with ':' are invalid YAML", + ) + expect(result.message).toContain("runtime will drop this skill") + }, + ) +}) + +test("single-quoted description containing colon-space passes", () => { + withSkill( + `name: pdf-reader +description: 'Use for PDF files: reading, extracting.'`, + (skillPath) => { + expect(validateSkill(skillPath)).toEqual({ + valid: true, + message: "Skill is valid!", + }) + }, + ) +}) + +test("block scalar content with colon-space passes", () => { + withSkill( + `name: pdf-reader +description: |2- + Use for PDF files: reading, extracting.`, + (skillPath) => { + expect(validateSkill(skillPath)).toEqual({ + valid: true, + message: "Skill is valid!", + }) + }, + ) +}) + +test("unquoted value with colon-space mid-value fails", () => { + withSkill( + `name: pdf-reader +description: Use when reading docs. +compatibility: Works with docs: markdown and PDF`, + (skillPath) => { + const result = validateSkill(skillPath) + + expect(result.valid).toBe(false) + expect(result.message).toContain("compatibility") + expect(result.message).toContain("line 4") + }, + ) +}) + +test("unquoted value ending with colon fails", () => { + withSkill( + `name: pdf-reader +description: Note:`, + (skillPath) => { + const result = validateSkill(skillPath) + + expect(result.valid).toBe(false) + expect(result.message).toContain("description") + expect(result.message).toContain("line 3") + expect(result.message).toContain("Hint: quote the value") + }, + ) +}) + +test("unquoted value with trailing colon-space fails after trim (ends-with-colon path)", () => { + withSkill( + `name: pdf-reader +description: Note: `, + (skillPath) => { + const result = validateSkill(skillPath) + + expect(result.valid).toBe(false) + expect(result.message).toContain("description") + expect(result.message).toContain("line 3") + expect(result.message).toContain("Hint: quote the value") + }, + ) +}) + +test("unquoted value with colon-tab fails", () => { + withSkill( + `name: pdf-reader +description: Use for PDF files:\treading`, + (skillPath) => { + const result = validateSkill(skillPath) + + expect(result.valid).toBe(false) + expect(result.message).toContain("description") + expect(result.message).toContain("line 3") + }, + ) +}) + +test("unquoted name with colon-space fails before name validation", () => { + withSkill( + `name: pdf: reader +description: Use when reading docs.`, + (skillPath) => { + const result = validateSkill(skillPath) + + expect(result.valid).toBe(false) + expect(result.message).toContain("name") + expect(result.message).toContain("line 2") + expect(result.message).toContain("Hint: quote the value") + }, + ) +})