Skip to content
4 changes: 2 additions & 2 deletions plugin/dist/build-manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
2 changes: 1 addition & 1 deletion plugin/dist/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
19 changes: 16 additions & 3 deletions plugin/dist/skill-creator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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();
Expand All @@ -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 === "{}")) {
Expand Down
33 changes: 31 additions & 2 deletions plugin/lib/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
antongulin marked this conversation as resolved.
Comment thread
antongulin marked this conversation as resolved.
Comment thread
antongulin marked this conversation as resolved.
return (
Comment thread
antongulin marked this conversation as resolved.
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))
)
}

function isBlockScalarMarker(value: string): boolean {
Comment thread
antongulin marked this conversation as resolved.
// header allows indentation (1-9) and chomping (+/-) indicators in either order
return /^[|>](?:[1-9][+-]?|[+-][1-9]?)?$/.test(value)
}

/**
* Validate a skill directory.
*
Expand Down Expand Up @@ -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()
Expand All @@ -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 (
Expand Down
172 changes: 172 additions & 0 deletions plugin/test/validate.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(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", () => {
Comment thread
antongulin marked this conversation as resolved.
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", () => {
Comment thread
antongulin marked this conversation as resolved.
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")
},
)
})
Comment thread
antongulin marked this conversation as resolved.
Comment thread
antongulin marked this conversation as resolved.