Skip to content

Commit d4f2ca3

Browse files
committed
fix(skills): report YAML frontmatter parse errors and serialize SKILL.md safely
SKILL.md files whose description contains unescaped double quotes failed to parse silently: gray-matter's YAMLException was swallowed by the outer catch and users only saw a misleading "missing required 'name' field" log (issue #859). - Catch gray-matter parse errors separately in loadSkillMetadata and log the actual YAML syntax error, with a hint pointing at unescaped double quotes in the description line - Build createSkill frontmatter as data and serialize via matter.stringify so special characters (double quotes, YAML booleans such as "yes", etc.) are quoted automatically and created skills always load - Pass lineWidth: -1 to the dump options (with a typed alias for gray-matter's outdated options typings) to keep long plain scalars on one line and prevent updateSkillModes rewrites from reflowing values into folded block scalars - Add regression tests covering both the load path (invalid YAML is skipped with the real cause logged, no misleading field error) and the create path (quoted frontmatter round-trips and loads on re-discovery)
1 parent 55d95f5 commit d4f2ca3

3 files changed

Lines changed: 361 additions & 3 deletions

File tree

‎src/core/webview/__tests__/skillsMessageHandler.spec.ts‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ vi.mock("../../../i18n", () => ({
2727
"skills:errors.manager_unavailable": "Skills manager not available",
2828
"skills:errors.missing_delete_fields": "Missing required fields: skillName or source",
2929
"skills:errors.missing_move_fields": "Missing required fields: skillName or source",
30+
"skills:errors.missing_update_modes_fields": "Missing required fields: skillName or source",
3031
"skills:errors.skill_not_found": `Skill "${params?.name}" not found`,
3132
}
3233
return translations[key] || key
@@ -41,6 +42,7 @@ import {
4142
handleDeleteSkill,
4243
handleMoveSkill,
4344
handleOpenSkillFile,
45+
handleUpdateSkillModes,
4446
} from "../skillsMessageHandler"
4547

4648
describe("skillsMessageHandler", () => {
@@ -51,6 +53,7 @@ describe("skillsMessageHandler", () => {
5153
const mockCreateSkill = vi.fn()
5254
const mockDeleteSkill = vi.fn()
5355
const mockMoveSkill = vi.fn()
56+
const mockUpdateSkillModes = vi.fn()
5457
const mockGetSkill = vi.fn()
5558
const mockFindSkillByNameAndSource = vi.fn()
5659

@@ -62,6 +65,7 @@ describe("skillsMessageHandler", () => {
6265
createSkill: mockCreateSkill,
6366
deleteSkill: mockDeleteSkill,
6467
moveSkill: mockMoveSkill,
68+
updateSkillModes: mockUpdateSkillModes,
6569
getSkill: mockGetSkill,
6670
findSkillByNameAndSource: mockFindSkillByNameAndSource,
6771
}
@@ -384,6 +388,97 @@ describe("skillsMessageHandler", () => {
384388
})
385389
})
386390

391+
describe("handleUpdateSkillModes", () => {
392+
it("updates a skill's mode slugs successfully", async () => {
393+
const provider = createMockProvider(true)
394+
mockUpdateSkillModes.mockResolvedValue(undefined)
395+
mockGetSkillsMetadata.mockReturnValue([mockSkills[0]])
396+
397+
const result = await handleUpdateSkillModes(provider, {
398+
type: "updateSkillModes",
399+
skillName: "test-skill",
400+
source: "global",
401+
newSkillModeSlugs: ["code"],
402+
} as WebviewMessage)
403+
404+
expect(result).toEqual([mockSkills[0]])
405+
expect(mockUpdateSkillModes).toHaveBeenCalledWith("test-skill", "global", ["code"])
406+
expect(mockPostMessageToWebview).toHaveBeenCalledWith({
407+
type: "skills",
408+
skills: [mockSkills[0]],
409+
skillDiagnostics: [],
410+
})
411+
})
412+
413+
it("clears a skill's mode restriction with empty slugs", async () => {
414+
const provider = createMockProvider(true)
415+
mockUpdateSkillModes.mockResolvedValue(undefined)
416+
mockGetSkillsMetadata.mockReturnValue([mockSkills[1]])
417+
418+
const result = await handleUpdateSkillModes(provider, {
419+
type: "updateSkillModes",
420+
skillName: "project-skill",
421+
source: "project",
422+
newSkillModeSlugs: [],
423+
} as WebviewMessage)
424+
425+
expect(result).toEqual([mockSkills[1]])
426+
expect(mockUpdateSkillModes).toHaveBeenCalledWith("project-skill", "project", [])
427+
})
428+
429+
it("returns undefined when required fields are missing", async () => {
430+
const provider = createMockProvider(true)
431+
432+
const result = await handleUpdateSkillModes(provider, {
433+
type: "updateSkillModes",
434+
skillName: "test-skill",
435+
// missing source
436+
} as WebviewMessage)
437+
438+
expect(result).toBeUndefined()
439+
expect(mockUpdateSkillModes).not.toHaveBeenCalled()
440+
expect(mockLog).toHaveBeenCalledWith(
441+
"Error updating skill modes: Missing required fields: skillName or source",
442+
)
443+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
444+
"Failed to update skill modes: Missing required fields: skillName or source",
445+
)
446+
})
447+
448+
it("returns undefined when skills manager is not available", async () => {
449+
const provider = createMockProvider(false)
450+
451+
const result = await handleUpdateSkillModes(provider, {
452+
type: "updateSkillModes",
453+
skillName: "test-skill",
454+
source: "global",
455+
newSkillModeSlugs: ["code"],
456+
} as WebviewMessage)
457+
458+
expect(result).toBeUndefined()
459+
expect(mockLog).toHaveBeenCalledWith("Error updating skill modes: Skills manager not available")
460+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
461+
"Failed to update skill modes: Skills manager not available",
462+
)
463+
})
464+
465+
it("returns undefined and reports the error when updateSkillModes rejects", async () => {
466+
const provider = createMockProvider(true)
467+
mockUpdateSkillModes.mockRejectedValue(new Error("boom"))
468+
469+
const result = await handleUpdateSkillModes(provider, {
470+
type: "updateSkillModes",
471+
skillName: "test-skill",
472+
source: "global",
473+
newSkillModeSlugs: ["code"],
474+
} as WebviewMessage)
475+
476+
expect(result).toBeUndefined()
477+
expect(mockLog).toHaveBeenCalledWith("Error updating skill modes: boom")
478+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to update skill modes: boom")
479+
})
480+
})
481+
387482
describe("handleOpenSkillFile", () => {
388483
it("opens a skill file successfully", async () => {
389484
const provider = createMockProvider(true)

‎src/services/skills/SkillsManager.ts‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,33 @@ import { t } from "../../i18n"
1818
// Re-export for convenience
1919
export type { SkillMetadata, SkillContent, SkillDiagnostic }
2020

21+
/**
22+
* Extract the raw top-level `key: ...` line from the frontmatter block of a
23+
* SKILL.md file. Used to point users at the exact line when YAML parsing
24+
* fails (see issue #859). Returns undefined when the file has no
25+
* frontmatter block or no matching top-level line.
26+
*/
27+
function getRawFrontmatterLine(fileContent: string, key: string): string | undefined {
28+
const match = fileContent.match(/^---\r?\n([\s\S]*?)\r?\n---/)
29+
if (!match) {
30+
return undefined
31+
}
32+
const linePattern = new RegExp(`^${key}\\s*:`)
33+
return match[1].split(/\r?\n/).find((line) => linePattern.test(line))
34+
}
35+
36+
// gray-matter's bundled typings predate its options passthrough: `stringify`
37+
// forwards its options object to js-yaml's safeDump, so js-yaml dump options
38+
// such as `lineWidth` are honored at runtime even though the declared
39+
// `GrayMatterOption` interface does not list them. This type names the
40+
// supported subset explicitly (see issue #859).
41+
type SkillYamlDumpOptions = matter.GrayMatterOption<string, SkillYamlDumpOptions> & { lineWidth: number }
42+
43+
// `lineWidth: -1` keeps long plain scalars (e.g. descriptions) on a single
44+
// line instead of reflowing them into folded block scalars, keeping SKILL.md
45+
// files stable across edits.
46+
const SKILL_YAML_DUMP_OPTIONS: SkillYamlDumpOptions = { lineWidth: -1 }
47+
2148
export class SkillsManager {
2249
private skills: Map<string, SkillMetadata> = new Map()
2350
private diagnostics: SkillDiagnostic[] = []
@@ -104,11 +131,24 @@ export class SkillsManager {
104131
const fileContent = await fs.readFile(skillMdPath, "utf-8")
105132
let parsed: ReturnType<typeof matter>
106133

134+
// Parse errors are handled separately from field validation so that
135+
// YAML syntax problems (e.g. unescaped double quotes in the
136+
// description) report the actual cause instead of a misleading
137+
// "missing required field" message (see issue #859).
107138
try {
108139
parsed = matter(fileContent)
109140
} catch (error) {
110141
this.recordDiagnostic(skillMdPath, source, error)
111142
console.error(`Failed to parse skill at ${skillDir}:`, error)
143+
// The most common cause is unescaped double quotes in the
144+
// description value - point the user at the exact line.
145+
const descriptionLine = getRawFrontmatterLine(fileContent, "description")
146+
if (descriptionLine?.includes('"')) {
147+
console.error(
148+
`Hint: the "description" value in ${skillMdPath} contains unescaped double quotes. ` +
149+
"Wrap the value in single quotes (or escape the double quotes) and save.",
150+
)
151+
}
112152
return
113153
}
114154

@@ -422,6 +462,12 @@ export class SkillsManager {
422462
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
423463
.join(" ")
424464

465+
// Build the frontmatter as data and serialize it with gray-matter so
466+
// values containing YAML special characters (e.g. double quotes in the
467+
// description) are quoted/escaped automatically. Hand-built frontmatter
468+
// produced unparseable SKILL.md files that silently failed to load
469+
// (see issue #859). lineWidth: -1 keeps plain values on a single line,
470+
// matching the previous output format.
425471
const frontmatter = {
426472
name,
427473
description: trimmedDescription,
@@ -434,7 +480,7 @@ export class SkillsManager {
434480
435481
Add your skill instructions here.
436482
`
437-
const skillContent = matter.stringify(body, frontmatter)
483+
const skillContent = matter.stringify(body, frontmatter, SKILL_YAML_DUMP_OPTIONS)
438484

439485
// Write the SKILL.md file
440486
await fs.writeFile(skillMdPath, skillContent, "utf-8")
@@ -575,8 +621,10 @@ Add your skill instructions here.
575621
delete frontmatter.mode
576622
}
577623

578-
// Serialize back to SKILL.md format
579-
const newContent = matter.stringify(body, frontmatter)
624+
// Serialize back to SKILL.md format. lineWidth: -1 prevents long
625+
// values from being reflowed into folded block scalars, keeping the
626+
// file stable (see issue #859).
627+
const newContent = matter.stringify(body, frontmatter, SKILL_YAML_DUMP_OPTIONS)
580628
await fs.writeFile(skill.path, newContent, "utf-8")
581629

582630
// Refresh skills list

0 commit comments

Comments
 (0)