diff --git a/apps/vscode-e2e/src/suite/skills-diagnostics.test.ts b/apps/vscode-e2e/src/suite/skills-diagnostics.test.ts
new file mode 100644
index 0000000000..71662e6cfe
--- /dev/null
+++ b/apps/vscode-e2e/src/suite/skills-diagnostics.test.ts
@@ -0,0 +1,138 @@
+import * as assert from "assert"
+import * as fs from "fs/promises"
+import * as path from "path"
+
+import * as vscode from "vscode"
+
+import { setDefaultSuiteTimeout } from "./test-utils"
+import { waitFor } from "./utils"
+
+const GOOD_SKILL = "e2e-skill-good"
+const BAD_SKILL = "e2e-skill-bad"
+
+// Issue #859 reproduction content: the description is a double-quoted YAML
+// scalar whose inner double quotes are left unescaped, which makes the
+// frontmatter unparseable.
+const MALFORMED_SKILL_MD = `---
+name: ${BAD_SKILL}
+description: "Use when implementing features. Triggers on: "TDD", "test-driven development"
+---
+
+# E2E Skill Bad
+
+Instructions here.
+`
+
+const FIXED_SKILL_MD = `---
+name: ${BAD_SKILL}
+description: 'Use when implementing features. Triggers on: "TDD", "test-driven development"'
+---
+
+# E2E Skill Bad
+
+Instructions here.
+`
+
+const GOOD_SKILL_MD = `---
+name: ${GOOD_SKILL}
+description: A healthy skill used by the skill diagnostics e2e smoke test.
+---
+
+# E2E Skill Good
+
+Instructions here.
+`
+
+// Write a skill file atomically (write to a sidecar, then rename over the
+// target) so the extension host's file watcher only ever observes complete
+// content. An in-place fs.writeFile is visible mid-write, the watcher can
+// fire for that moment, and - because discovery scans are serialized - a
+// mid-write event could be the last one, leaving a stale scan result.
+const writeSkillFileAtomic = async (finalPath: string, content: string): Promise => {
+ const tmpPath = `${finalPath}.tmp`
+ await fs.writeFile(tmpPath, content, "utf8")
+ await fs.rename(tmpPath, finalPath)
+}
+
+suite("Roo Code Skill Diagnostics", function () {
+ setDefaultSuiteTimeout(this)
+
+ let skillsRoot: string
+
+ setup(async function () {
+ const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
+ assert.ok(workspaceRoot, "e2e workspace folder must be open")
+ skillsRoot = path.join(workspaceRoot, ".roo", "skills")
+ })
+
+ teardown(async function () {
+ // Remove only the skill directories this suite created so pre-existing
+ // or other suites' fixtures under .roo/skills are left intact.
+ await Promise.all(
+ [GOOD_SKILL, BAD_SKILL].map((name) => fs.rm(path.join(skillsRoot, name), { recursive: true, force: true })),
+ )
+ })
+
+ test("should surface a malformed SKILL.md as a diagnostic without hiding healthy skills", async function () {
+ this.timeout(180_000)
+
+ // Arrange: one healthy skill and one malformed skill on real disk in the
+ // workspace's .roo/skills directory, written atomically so the watcher
+ // only observes complete files.
+ await fs.mkdir(path.join(skillsRoot, GOOD_SKILL), { recursive: true })
+ await writeSkillFileAtomic(path.join(skillsRoot, GOOD_SKILL, "SKILL.md"), GOOD_SKILL_MD)
+ await fs.mkdir(path.join(skillsRoot, BAD_SKILL), { recursive: true })
+ const badSkillMd = path.join(skillsRoot, BAD_SKILL, "SKILL.md")
+ await writeSkillFileAtomic(badSkillMd, MALFORMED_SKILL_MD)
+
+ // Act: the extension host's file watcher re-discovers skills; wait until
+ // the real SkillsManager reports the healthy skill and a diagnostic for
+ // the malformed one.
+ await waitFor(
+ async () => {
+ const state = globalThis.api.getSkillsState()
+ const goodVisible = state.skills.some((skill) => skill.name === GOOD_SKILL)
+ const badDiagnosed = state.skillDiagnostics.some((diagnostic) => diagnostic.path.includes(BAD_SKILL))
+ return goodVisible && badDiagnosed
+ },
+ { timeout: 60_000, interval: 500 },
+ )
+
+ // Assert: the malformed skill is skipped with a diagnostic pointing at it,
+ // while the healthy skill is unaffected.
+ const state = globalThis.api.getSkillsState()
+ const diagnostic = state.skillDiagnostics.find((d) => d.path.includes(BAD_SKILL))
+ assert.ok(diagnostic, "malformed SKILL.md should produce a diagnostic")
+ assert.strictEqual(diagnostic.source, "project")
+ assert.ok(diagnostic.message.length > 0, "diagnostic should carry the parse error message")
+ assert.ok(
+ state.skills.some((skill) => skill.name === GOOD_SKILL),
+ "healthy skill should still be discovered",
+ )
+ assert.ok(
+ !state.skills.some((skill) => skill.name === BAD_SKILL),
+ "malformed skill should be omitted from skills",
+ )
+
+ // Act: repair the frontmatter in place (atomically); the watcher
+ // re-discovers and the diagnostic clears.
+ await writeSkillFileAtomic(badSkillMd, FIXED_SKILL_MD)
+
+ await waitFor(
+ async () => {
+ const next = globalThis.api.getSkillsState()
+ const cleared = !next.skillDiagnostics.some((d) => d.path.includes(BAD_SKILL))
+ const loaded = next.skills.some((skill) => skill.name === BAD_SKILL)
+ return cleared && loaded
+ },
+ { timeout: 60_000, interval: 500 },
+ )
+
+ const fixed = globalThis.api.getSkillsState()
+ assert.ok(
+ fixed.skills.some((skill) => skill.name === BAD_SKILL),
+ "fixed skill should load after repair",
+ )
+ assert.ok(fixed.skills.find((skill) => skill.name === BAD_SKILL)?.description.includes("TDD") === true)
+ })
+})
diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts
index 89e9c8bc2b..b702f67b32 100644
--- a/packages/types/src/api.ts
+++ b/packages/types/src/api.ts
@@ -6,6 +6,7 @@ import type { RooCodeSettings } from "./global-settings.js"
import type { HistoryItem } from "./history.js"
import type { ProviderSettingsEntry, ProviderSettings } from "./provider-settings.js"
import type { IpcMessage, IpcServerEvents } from "./ipc.js"
+import type { SkillDiagnostic, SkillMetadata } from "./skills.js"
export type RooCodeAPIEvents = RooCodeEvents
@@ -51,6 +52,15 @@ export interface RooCodeAPI extends EventEmitter {
* @returns The number of persisted API conversation history entries, or 0 if unavailable.
*/
getTaskApiConversationHistoryLength(taskId: string): Promise
+ /**
+ * Returns the skill metadata and load diagnostics currently discovered by the
+ * extension host. Intended for use in tests only.
+ * @returns The discovered skills and any diagnostics for skills that failed to load.
+ */
+ getSkillsState(): {
+ skills: SkillMetadata[]
+ skillDiagnostics: SkillDiagnostic[]
+ }
/**
* Returns the current task stack.
* @returns An array of task IDs.
diff --git a/packages/types/src/skills.ts b/packages/types/src/skills.ts
index 2f13b822eb..65768f7f29 100644
--- a/packages/types/src/skills.ts
+++ b/packages/types/src/skills.ts
@@ -20,6 +20,15 @@ export interface SkillMetadata {
modeSlugs?: string[]
}
+/** A user-actionable problem found while loading a SKILL.md file. */
+export interface SkillDiagnostic {
+ path: string
+ source: "global" | "project"
+ message: string
+ line?: number
+ column?: number
+}
+
/**
* Skill name validation constants per agentskills.io specification:
* https://agentskills.io/specification
diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts
index ea52c09599..8b9cce6586 100644
--- a/packages/types/src/vscode-extension-host.ts
+++ b/packages/types/src/vscode-extension-host.ts
@@ -18,7 +18,7 @@ import { OllamaModelsMessageType } from "./providers/ollama.js"
import { OpenAiModelsMessageType } from "./providers/openai.js"
import { VsCodeLmModelsMessageType } from "./providers/vscode-llm.js"
import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js"
-import type { SkillMetadata } from "./skills.js"
+import type { SkillDiagnostic, SkillMetadata } from "./skills.js"
import type { RuleMetadata } from "./rules.js"
import type { TelemetrySetting } from "./telemetry.js"
import type { WorktreeIncludeStatus } from "./worktree.js"
@@ -184,6 +184,7 @@ export interface ExtensionMessage {
list?: string[] // For dismissedUpsells
tools?: SerializedCustomToolDefinition[] // For customToolsResult
skills?: SkillMetadata[] // For skills response
+ skillDiagnostics?: SkillDiagnostic[] // For malformed skills omitted from the skills response
rules?: RuleMetadata[] // For rules response
modes?: { slug: string; name: string }[] // For modes response
rooHistoryImportProgress?: {
diff --git a/src/core/webview/__tests__/skillsMessageHandler.spec.ts b/src/core/webview/__tests__/skillsMessageHandler.spec.ts
index 5d03b95a27..f5fa7eec38 100644
--- a/src/core/webview/__tests__/skillsMessageHandler.spec.ts
+++ b/src/core/webview/__tests__/skillsMessageHandler.spec.ts
@@ -27,6 +27,7 @@ vi.mock("../../../i18n", () => ({
"skills:errors.manager_unavailable": "Skills manager not available",
"skills:errors.missing_delete_fields": "Missing required fields: skillName or source",
"skills:errors.missing_move_fields": "Missing required fields: skillName or source",
+ "skills:errors.missing_update_modes_fields": "Missing required fields: skillName or source",
"skills:errors.skill_not_found": `Skill "${params?.name}" not found`,
}
return translations[key] || key
@@ -41,15 +42,18 @@ import {
handleDeleteSkill,
handleMoveSkill,
handleOpenSkillFile,
+ handleUpdateSkillModes,
} from "../skillsMessageHandler"
describe("skillsMessageHandler", () => {
const mockLog = vi.fn()
const mockPostMessageToWebview = vi.fn()
const mockGetSkillsMetadata = vi.fn()
+ const mockGetSkillDiagnostics = vi.fn()
const mockCreateSkill = vi.fn()
const mockDeleteSkill = vi.fn()
const mockMoveSkill = vi.fn()
+ const mockUpdateSkillModes = vi.fn()
const mockGetSkill = vi.fn()
const mockFindSkillByNameAndSource = vi.fn()
@@ -57,9 +61,11 @@ describe("skillsMessageHandler", () => {
const skillsManager = hasSkillsManager
? {
getSkillsMetadata: mockGetSkillsMetadata,
+ getSkillDiagnostics: mockGetSkillDiagnostics,
createSkill: mockCreateSkill,
deleteSkill: mockDeleteSkill,
moveSkill: mockMoveSkill,
+ updateSkillModes: mockUpdateSkillModes,
getSkill: mockGetSkill,
findSkillByNameAndSource: mockFindSkillByNameAndSource,
}
@@ -90,6 +96,7 @@ describe("skillsMessageHandler", () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockGetSkillDiagnostics.mockReturnValue([])
})
describe("handleRequestSkills", () => {
@@ -100,7 +107,34 @@ describe("skillsMessageHandler", () => {
const result = await handleRequestSkills(provider)
expect(result).toEqual(mockSkills)
- expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: mockSkills })
+ expect(mockPostMessageToWebview).toHaveBeenCalledWith({
+ type: "skills",
+ skills: mockSkills,
+ skillDiagnostics: [],
+ })
+ })
+
+ it("sends structured malformed-skill diagnostics without hiding valid skills", async () => {
+ const provider = createMockProvider(true)
+ const diagnostics = [
+ {
+ path: "/workspace/.roo/skills/broken/SKILL.md",
+ source: "project" as const,
+ message: "bad indentation of a mapping entry",
+ line: 3,
+ column: 20,
+ },
+ ]
+ mockGetSkillsMetadata.mockReturnValue(mockSkills)
+ mockGetSkillDiagnostics.mockReturnValue(diagnostics)
+
+ await handleRequestSkills(provider)
+
+ expect(mockPostMessageToWebview).toHaveBeenCalledWith({
+ type: "skills",
+ skills: mockSkills,
+ skillDiagnostics: diagnostics,
+ })
})
it("returns empty skills when skills manager is not available", async () => {
@@ -109,7 +143,11 @@ describe("skillsMessageHandler", () => {
const result = await handleRequestSkills(provider)
expect(result).toEqual([])
- expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [] })
+ expect(mockPostMessageToWebview).toHaveBeenCalledWith({
+ type: "skills",
+ skills: [],
+ skillDiagnostics: [],
+ })
})
it("handles errors and returns empty skills", async () => {
@@ -122,7 +160,11 @@ describe("skillsMessageHandler", () => {
expect(result).toEqual([])
expect(mockLog).toHaveBeenCalled()
- expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [] })
+ expect(mockPostMessageToWebview).toHaveBeenCalledWith({
+ type: "skills",
+ skills: [],
+ skillDiagnostics: [],
+ })
})
})
@@ -142,7 +184,11 @@ describe("skillsMessageHandler", () => {
expect(result).toEqual(mockSkills)
expect(mockCreateSkill).toHaveBeenCalledWith("new-skill", "global", "New skill description", undefined)
expect(openFile).toHaveBeenCalledWith("/path/to/new-skill/SKILL.md")
- expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: mockSkills })
+ expect(mockPostMessageToWebview).toHaveBeenCalledWith({
+ type: "skills",
+ skills: mockSkills,
+ skillDiagnostics: [],
+ })
})
it("creates a skill with mode restriction", async () => {
@@ -212,7 +258,11 @@ describe("skillsMessageHandler", () => {
expect(result).toEqual([mockSkills[1]])
expect(mockDeleteSkill).toHaveBeenCalledWith("test-skill", "global", undefined)
- expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [mockSkills[1]] })
+ expect(mockPostMessageToWebview).toHaveBeenCalledWith({
+ type: "skills",
+ skills: [mockSkills[1]],
+ skillDiagnostics: [],
+ })
})
it("deletes a skill with mode restriction", async () => {
@@ -280,7 +330,11 @@ describe("skillsMessageHandler", () => {
expect(result).toEqual([mockSkills[0]])
expect(mockMoveSkill).toHaveBeenCalledWith("test-skill", "global", undefined, "code")
- expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [mockSkills[0]] })
+ expect(mockPostMessageToWebview).toHaveBeenCalledWith({
+ type: "skills",
+ skills: [mockSkills[0]],
+ skillDiagnostics: [],
+ })
})
it("moves a skill from one mode to another", async () => {
@@ -334,6 +388,132 @@ describe("skillsMessageHandler", () => {
})
})
+ describe("handleUpdateSkillModes", () => {
+ it("updates a skill's mode slugs successfully", async () => {
+ const provider = createMockProvider(true)
+ mockUpdateSkillModes.mockResolvedValue(undefined)
+ mockGetSkillsMetadata.mockReturnValue([mockSkills[0]])
+
+ const result = await handleUpdateSkillModes(provider, {
+ type: "updateSkillModes",
+ skillName: "test-skill",
+ source: "global",
+ newSkillModeSlugs: ["code"],
+ } as WebviewMessage)
+
+ expect(result).toEqual([mockSkills[0]])
+ expect(mockUpdateSkillModes).toHaveBeenCalledWith("test-skill", "global", ["code"])
+ expect(mockPostMessageToWebview).toHaveBeenCalledWith({
+ type: "skills",
+ skills: [mockSkills[0]],
+ skillDiagnostics: [],
+ })
+ })
+
+ it("clears a skill's mode restriction with empty slugs", async () => {
+ const provider = createMockProvider(true)
+ mockUpdateSkillModes.mockResolvedValue(undefined)
+ mockGetSkillsMetadata.mockReturnValue([mockSkills[1]])
+
+ const result = await handleUpdateSkillModes(provider, {
+ type: "updateSkillModes",
+ skillName: "project-skill",
+ source: "project",
+ newSkillModeSlugs: [],
+ } as WebviewMessage)
+
+ expect(result).toEqual([mockSkills[1]])
+ expect(mockUpdateSkillModes).toHaveBeenCalledWith("project-skill", "project", [])
+ })
+
+ it("passes undefined mode slugs and refreshes state when newSkillModeSlugs is omitted", async () => {
+ const provider = createMockProvider(true)
+ mockUpdateSkillModes.mockResolvedValue(undefined)
+ mockGetSkillsMetadata.mockReturnValue([mockSkills[0]])
+ // Forward a concrete (non-empty) diagnostic so the assertion proves the
+ // handler relays the diagnostics list rather than always posting []
+ // (which would pass even if the field were dropped or hard-coded).
+ const diagnostics = [
+ {
+ path: "/global/.roo/skills/broken/SKILL.md",
+ source: "global" as const,
+ message: "can not read a block mapping entry",
+ line: 3,
+ column: 10,
+ },
+ ]
+ mockGetSkillDiagnostics.mockReturnValue(diagnostics)
+
+ const message: WebviewMessage = {
+ type: "updateSkillModes",
+ skillName: "test-skill",
+ source: "global",
+ // newSkillModeSlugs omitted
+ }
+ const result = await handleUpdateSkillModes(provider, message)
+
+ expect(result).toEqual([mockSkills[0]])
+ expect(mockUpdateSkillModes).toHaveBeenCalledWith("test-skill", "global", undefined)
+ expect(mockPostMessageToWebview).toHaveBeenCalledWith({
+ type: "skills",
+ skills: [mockSkills[0]],
+ skillDiagnostics: diagnostics,
+ })
+ })
+
+ it("returns undefined when required fields are missing", async () => {
+ const provider = createMockProvider(true)
+
+ const result = await handleUpdateSkillModes(provider, {
+ type: "updateSkillModes",
+ skillName: "test-skill",
+ // missing source
+ } as WebviewMessage)
+
+ expect(result).toBeUndefined()
+ expect(mockUpdateSkillModes).not.toHaveBeenCalled()
+ expect(mockLog).toHaveBeenCalledWith(
+ "Error updating skill modes: Missing required fields: skillName or source",
+ )
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ "Failed to update skill modes: Missing required fields: skillName or source",
+ )
+ })
+
+ it("returns undefined when skills manager is not available", async () => {
+ const provider = createMockProvider(false)
+
+ const result = await handleUpdateSkillModes(provider, {
+ type: "updateSkillModes",
+ skillName: "test-skill",
+ source: "global",
+ newSkillModeSlugs: ["code"],
+ } as WebviewMessage)
+
+ expect(result).toBeUndefined()
+ expect(mockLog).toHaveBeenCalledWith("Error updating skill modes: Skills manager not available")
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ "Failed to update skill modes: Skills manager not available",
+ )
+ })
+
+ it("returns undefined and reports the error when updateSkillModes rejects", async () => {
+ const provider = createMockProvider(true)
+ mockUpdateSkillModes.mockRejectedValue(new Error("boom"))
+
+ const result = await handleUpdateSkillModes(provider, {
+ type: "updateSkillModes",
+ skillName: "test-skill",
+ source: "global",
+ newSkillModeSlugs: ["code"],
+ } as WebviewMessage)
+
+ expect(result).toBeUndefined()
+ expect(mockLog).toHaveBeenCalledWith("Error updating skill modes: boom")
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to update skill modes: boom")
+ })
+ })
+
describe("handleOpenSkillFile", () => {
it("opens a skill file successfully", async () => {
const provider = createMockProvider(true)
diff --git a/src/core/webview/skillsMessageHandler.ts b/src/core/webview/skillsMessageHandler.ts
index 2eab270734..8687791c59 100644
--- a/src/core/webview/skillsMessageHandler.ts
+++ b/src/core/webview/skillsMessageHandler.ts
@@ -16,15 +16,16 @@ export async function handleRequestSkills(provider: ClineProvider): Promise {
+ let api: API
+ let mockOutputChannel: vscode.OutputChannel
+ let mockProvider: ClineProvider
+ let mockGetSkillsManager: ReturnType
+
+ beforeEach(() => {
+ // mockOutputChannel and mockProvider are intentionally partial
+ // doubles: they implement only the members API touches (appendLine;
+ // context, getSkillsManager, on). The as-unknown-as casts are the
+ // last resort because the partial shapes are not subtypes of the full
+ // vscode.OutputChannel / ClineProvider types.
+ mockOutputChannel = {
+ appendLine: vi.fn(),
+ } as unknown as vscode.OutputChannel
+
+ mockGetSkillsManager = vi.fn()
+
+ mockProvider = {
+ context: {} as vscode.ExtensionContext,
+ getSkillsManager: mockGetSkillsManager,
+ on: vi.fn(),
+ } as unknown as ClineProvider
+
+ api = new API(mockOutputChannel, mockProvider, undefined, true)
+ })
+
+ it("returns the skills and diagnostics from the skills manager", () => {
+ const skills = [
+ {
+ name: "good-skill",
+ description: "A healthy skill.",
+ path: "/skills/good-skill/SKILL.md",
+ source: "project" as const,
+ },
+ ]
+ const skillDiagnostics = [
+ { path: "/skills/bad-skill/SKILL.md", source: "project" as const, message: "YAML syntax error", line: 3 },
+ ]
+ mockGetSkillsManager.mockReturnValue({
+ getSkillsMetadata: vi.fn().mockReturnValue(skills),
+ getSkillDiagnostics: vi.fn().mockReturnValue(skillDiagnostics),
+ })
+
+ expect(api.getSkillsState()).toEqual({ skills, skillDiagnostics })
+ })
+
+ it("returns empty arrays when the skills manager is unavailable", () => {
+ mockGetSkillsManager.mockReturnValue(undefined)
+
+ expect(api.getSkillsState()).toEqual({ skills: [], skillDiagnostics: [] })
+ })
+})
diff --git a/src/extension/api.ts b/src/extension/api.ts
index b57dc89b74..80c2e90864 100644
--- a/src/extension/api.ts
+++ b/src/extension/api.ts
@@ -250,6 +250,17 @@ export class API extends EventEmitter implements RooCodeAPI {
}
}
+ public getSkillsState() {
+ const skillsManager = this.sidebarProvider.getSkillsManager()
+ if (!skillsManager) {
+ return { skills: [], skillDiagnostics: [] }
+ }
+ return {
+ skills: skillsManager.getSkillsMetadata(),
+ skillDiagnostics: skillsManager.getSkillDiagnostics(),
+ }
+ }
+
public getCurrentTaskStack() {
return this.sidebarProvider.getCurrentTaskStack()
}
diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts
index 0959b977c9..bdf3ee5f93 100644
--- a/src/services/skills/SkillsManager.ts
+++ b/src/services/skills/SkillsManager.ts
@@ -6,7 +6,7 @@ import matter from "gray-matter"
import type { ClineProvider } from "../../core/webview/ClineProvider"
import { getGlobalRooDirectory, getGlobalAgentsDirectory, getProjectAgentsDirectoryForCwd } from "../roo-config"
import { directoryExists, fileExists } from "../roo-config"
-import { SkillMetadata, SkillContent } from "../../shared/skills"
+import { SkillMetadata, SkillContent, SkillDiagnostic } from "../../shared/skills"
import { modes, getAllModes } from "../../shared/modes"
import {
validateSkillName as validateSkillNameShared,
@@ -16,13 +16,49 @@ import {
import { t } from "../../i18n"
// Re-export for convenience
-export type { SkillMetadata, SkillContent }
+export type { SkillMetadata, SkillContent, SkillDiagnostic }
+
+/**
+ * Extract the raw top-level `key: ...` line from the frontmatter block of a
+ * SKILL.md file, along with its 1-based line number in the file. Used to
+ * point users at the exact line when YAML parsing fails (see issue #859).
+ * Returns undefined when the file has no frontmatter block or no matching
+ * top-level line.
+ */
+function getFrontmatterLine(fileContent: string, key: string): { line: string; lineNumber: number } | undefined {
+ const match = fileContent.match(/^---\r?\n([\s\S]*?)\r?\n---/)
+ if (!match) {
+ return undefined
+ }
+ const linePattern = new RegExp(`^${key}\\s*:`)
+ const frontmatterLines = match[1].split(/\r?\n/)
+ const index = frontmatterLines.findIndex((line) => linePattern.test(line))
+ if (index === -1) {
+ return undefined
+ }
+ // The opening `---` occupies file line 1, so frontmatter index 0 is file line 2.
+ return { line: frontmatterLines[index], lineNumber: index + 2 }
+}
+
+// gray-matter's bundled typings predate its options passthrough: `stringify`
+// forwards its options object to js-yaml's safeDump, so js-yaml dump options
+// such as `lineWidth` are honored at runtime even though the declared
+// `GrayMatterOption` interface does not list them. This type names the
+// supported subset explicitly (see issue #859).
+type SkillYamlDumpOptions = matter.GrayMatterOption & { lineWidth: number }
+
+// `lineWidth: -1` keeps long plain scalars (e.g. descriptions) on a single
+// line instead of reflowing them into folded block scalars, keeping SKILL.md
+// files stable across edits.
+const SKILL_YAML_DUMP_OPTIONS: SkillYamlDumpOptions = { lineWidth: -1 }
export class SkillsManager {
private skills: Map = new Map()
+ private diagnostics: SkillDiagnostic[] = []
private providerRef: WeakRef
private disposables: vscode.Disposable[] = []
private isDisposed = false
+ private discoveryChain: Promise = Promise.resolve()
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
@@ -39,9 +75,21 @@ export class SkillsManager {
* Also supports symlinks:
* - .roo/skills can be a symlink to a directory containing skill subdirectories
* - .roo/skills/[dirname] can be a symlink to a skill directory
+ *
+ * Scans are serialized so overlapping watcher-triggered runs never
+ * interleave: an older scan must not commit state after a newer scan has
+ * already observed the (possibly repaired) files.
*/
- async discoverSkills(): Promise {
+ discoverSkills(): Promise {
+ const run = this.discoveryChain.then(() => this.performDiscovery())
+ // Keep the chain alive even if a scan rejects so later scans still run.
+ this.discoveryChain = run.catch(() => undefined)
+ return run
+ }
+
+ private async performDiscovery(): Promise {
this.skills.clear()
+ this.diagnostics = []
const skillsDirs = await this.getSkillsDirectories()
for (const { dir, source, mode } of skillsDirs) {
@@ -100,9 +148,45 @@ export class SkillsManager {
try {
const fileContent = await fs.readFile(skillMdPath, "utf-8")
+ let parsed: ReturnType
+
+ // Parse errors are handled separately from field validation so that
+ // YAML syntax problems (e.g. unescaped double quotes in the
+ // description) report the actual cause instead of a misleading
+ // "missing required field" message (see issue #859).
+ //
+ // The `{}` options argument is deliberate: gray-matter keeps a global
+ // content-keyed cache and populates it *before* parsing, so a
+ // frontmatter that throws on first parse is cached with an empty data
+ // object and every later parse of the same content silently returns
+ // that empty object instead of re-throwing. Passing options disables
+ // the cache for this call, keeping the parse deterministic.
+ try {
+ parsed = matter(fileContent, {})
+ } catch (error) {
+ this.recordDiagnostic(skillMdPath, source, error)
+ console.error(`Failed to parse skill at ${skillDir}:`, error)
+ // The most common cause is unescaped double quotes in the
+ // description value. Only hint at that when the parser error is
+ // located on the description line itself, so a valid quoted
+ // description plus an unrelated YAML error elsewhere does not
+ // produce a misleading hint (see issue #859).
+ const description = getFrontmatterLine(fileContent, "description")
+ const errorMark = (error as { mark?: { line?: unknown } }).mark
+ if (
+ description?.line.includes('"') &&
+ typeof errorMark?.line === "number" &&
+ errorMark.line + 1 === description.lineNumber
+ ) {
+ console.error(
+ `Hint: the "description" value in ${skillMdPath} contains unescaped double quotes. ` +
+ "Wrap the value in single quotes (or escape the double quotes) and save.",
+ )
+ }
+ return
+ }
- // Use gray-matter to parse frontmatter
- const { data: frontmatter, content: body } = matter(fileContent)
+ const { data: frontmatter } = parsed
// Validate required fields (only name and description for now)
if (!frontmatter.name || typeof frontmatter.name !== "string") {
@@ -175,6 +259,20 @@ export class SkillsManager {
}
}
+ private recordDiagnostic(path: string, source: "global" | "project", error: unknown): void {
+ const yamlError = error as {
+ reason?: unknown
+ message?: unknown
+ mark?: { line?: unknown; column?: unknown }
+ }
+ const reason = typeof yamlError.reason === "string" ? yamlError.reason : undefined
+ const errorMessage = error instanceof Error ? error.message.split("\n", 1)[0] : String(error)
+ const line = typeof yamlError.mark?.line === "number" ? yamlError.mark.line + 1 : undefined
+ const column = typeof yamlError.mark?.column === "number" ? yamlError.mark.column + 1 : undefined
+
+ this.diagnostics.push({ path, source, message: reason ?? errorMessage, line, column })
+ }
+
/**
* Get skills available for the current mode.
* Resolves overrides: project > global, mode-specific > generic.
@@ -290,6 +388,10 @@ export class SkillsManager {
return this.getAllSkills()
}
+ getSkillDiagnostics(): SkillDiagnostic[] {
+ return [...this.diagnostics]
+ }
+
/**
* Get a skill by name, source, and optionally mode
*/
@@ -394,25 +496,25 @@ export class SkillsManager {
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
- // Build frontmatter with optional modeSlugs
- const frontmatterLines = [`name: ${name}`, `description: ${trimmedDescription}`]
- if (modeSlugs && modeSlugs.length > 0) {
- frontmatterLines.push(`modeSlugs:`)
- for (const slug of modeSlugs) {
- frontmatterLines.push(` - ${slug}`)
- }
- }
-
- const skillContent = `---
-${frontmatterLines.join("\n")}
----
-
+ // Build the frontmatter as data and serialize it with gray-matter so
+ // values containing YAML special characters (e.g. double quotes in the
+ // description) are quoted/escaped automatically. Hand-built frontmatter
+ // produced unparseable SKILL.md files that silently failed to load
+ // (see issue #859). lineWidth: -1 keeps plain values on a single line,
+ // matching the previous output format.
+ const frontmatter = {
+ name,
+ description: trimmedDescription,
+ ...(modeSlugs && modeSlugs.length > 0 ? { modeSlugs } : {}),
+ }
+ const body = `
# ${titleName}
## Instructions
Add your skill instructions here.
`
+ const skillContent = matter.stringify(body, frontmatter, SKILL_YAML_DUMP_OPTIONS)
// Write the SKILL.md file
await fs.writeFile(skillMdPath, skillContent, "utf-8")
@@ -553,8 +655,10 @@ Add your skill instructions here.
delete frontmatter.mode
}
- // Serialize back to SKILL.md format
- const newContent = matter.stringify(body, frontmatter)
+ // Serialize back to SKILL.md format. lineWidth: -1 prevents long
+ // values from being reflowed into folded block scalars, keeping the
+ // file stable (see issue #859).
+ const newContent = matter.stringify(body, frontmatter, SKILL_YAML_DUMP_OPTIONS)
await fs.writeFile(skill.path, newContent, "utf-8")
// Refresh skills list
diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts
index d36582d893..1aac22732f 100644
--- a/src/services/skills/__tests__/SkillsManager.spec.ts
+++ b/src/services/skills/__tests__/SkillsManager.spec.ts
@@ -1,4 +1,5 @@
import * as path from "path"
+import matter from "gray-matter"
// Use vi.hoisted to ensure mocks are available during hoisting
const {
@@ -67,6 +68,20 @@ vi.mock("os", () => ({
homedir: mockHomedir,
}))
+// Keep the real gray-matter parser by default, but let individual tests script
+// the failure shape (e.g. a non-Error throw) to exercise recordDiagnostic's
+// defensive fallbacks (see issue #859). vi.importActual resolves the CJS module
+// to a namespace that exposes the parser as `default`, which the module's
+// declared `export =` type does not carry, so the namespace is bridged through
+// `unknown` once; the double assertion then bridges the mock back to the
+// module's declared type.
+vi.mock("gray-matter", async () => {
+ const actual = await vi.importActual("gray-matter")
+ const realParse = (actual as { default: typeof matter }).default
+ const parse = Object.assign(vi.fn(realParse), actual) as unknown as typeof matter
+ return { default: parse }
+})
+
// Mock vscode
vi.mock("vscode", () => ({
workspace: {
@@ -391,6 +406,162 @@ description: Name doesn't match directory
expect(skills).toHaveLength(0)
})
+ it("should skip skills with invalid YAML frontmatter and log the real cause (issue #859)", async () => {
+ const badSkillDir = p(globalSkillsDir, "test-skill")
+ const badSkillMd = p(badSkillDir, "SKILL.md")
+
+ mockDirectoryExists.mockImplementation(async (dir: string) => {
+ return dir === globalSkillsDir
+ })
+ mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+ mockReaddir.mockImplementation(async (dir: string) => {
+ return dir === globalSkillsDir ? ["test-skill"] : []
+ })
+ mockStat.mockImplementation(async (pathArg: string) => {
+ if (pathArg === badSkillDir) {
+ return { isDirectory: () => true }
+ }
+ throw new Error("Not found")
+ })
+ mockFileExists.mockImplementation(async (file: string) => file === badSkillMd)
+ mockReadFile.mockImplementation(async (file: string) => {
+ if (file === badSkillMd) {
+ return `---
+name: test-skill
+description: Use when implementing features. Triggers on: "TDD", "test-driven development"
+---
+
+# Test Skill
+
+Instructions here.`
+ }
+ throw new Error("File not found")
+ })
+
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+
+ try {
+ await skillsManager.discoverSkills()
+
+ // The skill is skipped...
+ expect(skillsManager.getAllSkills()).toHaveLength(0)
+
+ // ...and the real cause (a YAML syntax error) is reported instead
+ // of the misleading "missing required 'name' field" message.
+ const logged = consoleErrorSpy.mock.calls.map((call) => call.join(" "))
+ expect(logged.some((line) => line.includes("YAMLException"))).toBe(true)
+ expect(logged.some((line) => line.includes("missing required 'name' field"))).toBe(false)
+ // The unescaped-double-quotes hint points at the exact problem.
+ expect(logged.some((line) => line.includes("unescaped double quotes"))).toBe(true)
+
+ // The structured diagnostic captures the parse failure location.
+ const diagnostics = skillsManager.getSkillDiagnostics()
+ expect(diagnostics).toHaveLength(1)
+ expect(diagnostics[0].path).toBe(badSkillMd)
+ expect(diagnostics[0].source).toBe("global")
+ expect(typeof diagnostics[0].message).toBe("string")
+ expect(diagnostics[0].line).toBe(3)
+ } finally {
+ consoleErrorSpy.mockRestore()
+ }
+ })
+
+ it("should log a distinct YAML error for other malformed frontmatter (no quote hint)", async () => {
+ const badSkillDir = p(globalSkillsDir, "broken-skill")
+ const badSkillMd = p(badSkillDir, "SKILL.md")
+
+ mockDirectoryExists.mockImplementation(async (dir: string) => {
+ return dir === globalSkillsDir
+ })
+ mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+ mockReaddir.mockImplementation(async (dir: string) => {
+ return dir === globalSkillsDir ? ["broken-skill"] : []
+ })
+ mockStat.mockImplementation(async (pathArg: string) => {
+ if (pathArg === badSkillDir) {
+ return { isDirectory: () => true }
+ }
+ throw new Error("Not found")
+ })
+ mockFileExists.mockImplementation(async (file: string) => file === badSkillMd)
+ mockReadFile.mockImplementation(async (file: string) => {
+ if (file === badSkillMd) {
+ return `---
+name broken-skill
+description: Missing colon makes this invalid YAML
+---
+
+# Broken skill`
+ }
+ throw new Error("File not found")
+ })
+
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+
+ try {
+ await skillsManager.discoverSkills()
+
+ expect(skillsManager.getAllSkills()).toHaveLength(0)
+
+ const logged = consoleErrorSpy.mock.calls.map((call) => call.join(" "))
+ expect(logged.some((line) => line.includes("YAMLException"))).toBe(true)
+ expect(skillsManager.getSkillDiagnostics()).toHaveLength(1)
+ // The unescaped-quotes hint only applies when the description line
+ // actually contains double quotes.
+ expect(logged.some((line) => line.includes("unescaped double quotes"))).toBe(false)
+ } finally {
+ consoleErrorSpy.mockRestore()
+ }
+ })
+
+ it("should skip skills with unterminated frontmatter and a dangling quote (no line hint)", async () => {
+ const badSkillDir = p(globalSkillsDir, "unclosed-skill")
+ const badSkillMd = p(badSkillDir, "SKILL.md")
+
+ mockDirectoryExists.mockImplementation(async (dir: string) => {
+ return dir === globalSkillsDir
+ })
+ mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+ mockReaddir.mockImplementation(async (dir: string) => {
+ return dir === globalSkillsDir ? ["unclosed-skill"] : []
+ })
+ mockStat.mockImplementation(async (pathArg: string) => {
+ if (pathArg === badSkillDir) {
+ return { isDirectory: () => true }
+ }
+ throw new Error("Not found")
+ })
+ mockFileExists.mockImplementation(async (file: string) => file === badSkillMd)
+ mockReadFile.mockImplementation(async (file: string) => {
+ if (file === badSkillMd) {
+ // Unterminated frontmatter: the opening --- is never closed, and the
+ // dangling double quote makes gray-matter throw. The raw-line lookup
+ // cannot find a closing delimiter, so no quote hint is possible.
+ return `---
+name: unclosed-skill
+description: "unclosed quote
+rest of the file`
+ }
+ throw new Error("File not found")
+ })
+
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+
+ try {
+ await skillsManager.discoverSkills()
+
+ expect(skillsManager.getAllSkills()).toHaveLength(0)
+
+ const logged = consoleErrorSpy.mock.calls.map((call) => call.join(" "))
+ expect(logged.some((line) => line.includes("YAMLException"))).toBe(true)
+ expect(skillsManager.getSkillDiagnostics()).toHaveLength(1)
+ // Without a closing frontmatter delimiter there is no line to hint at.
+ expect(logged.some((line) => line.includes("unescaped double quotes"))).toBe(false)
+ } finally {
+ consoleErrorSpy.mockRestore()
+ }
+ })
+
it("should skip skills with invalid name formats (spec compliance)", async () => {
const invalidNames = [
"PDF-processing", // uppercase
@@ -1233,6 +1404,47 @@ Instructions`)
expect(writeCall[1]).toContain("- code")
})
+ it("round-trips the issue description and mode slugs through YAML frontmatter", async () => {
+ mockDirectoryExists.mockResolvedValue(false)
+ mockRealpath.mockImplementation(async (p: string) => p)
+ mockReaddir.mockResolvedValue([])
+ mockFileExists.mockResolvedValue(false)
+ mockMkdir.mockResolvedValue(undefined)
+ mockWriteFile.mockResolvedValue(undefined)
+ const description = 'Triggers on: "TDD", "test-driven development"'
+
+ await skillsManager.createSkill("tdd-skill", "global", description, ["code", "debug"])
+
+ const generatedContent = mockWriteFile.mock.calls[0][1] as string
+ const parsed = matter(generatedContent)
+ expect(parsed.data).toEqual({
+ name: "tdd-skill",
+ description,
+ modeSlugs: ["code", "debug"],
+ })
+ expect(parsed.content).toBe("\n# Tdd Skill\n\n## Instructions\n\nAdd your skill instructions here.\n")
+ })
+
+ it.each([
+ ["colon-space", "Use when: tests fail"],
+ ["quotes", "Use \"red-green-refactor\" and 'small steps'"],
+ ["hash", "Use tests # not comments"],
+ ["leading punctuation", "- Start from a failing test"],
+ ["multiline", "First line\nSecond line: with # and quotes"],
+ ])("preserves YAML-sensitive %s descriptions", async (_caseName, description) => {
+ mockDirectoryExists.mockResolvedValue(false)
+ mockRealpath.mockImplementation(async (p: string) => p)
+ mockReaddir.mockResolvedValue([])
+ mockFileExists.mockResolvedValue(false)
+ mockMkdir.mockResolvedValue(undefined)
+ mockWriteFile.mockResolvedValue(undefined)
+
+ await skillsManager.createSkill("yaml-sensitive", "global", description)
+
+ const generatedContent = mockWriteFile.mock.calls[0][1] as string
+ expect(matter(generatedContent).data.description).toBe(description)
+ })
+
it("should create a project skill", async () => {
mockDirectoryExists.mockResolvedValue(false)
mockRealpath.mockImplementation(async (p: string) => p)
@@ -1246,6 +1458,120 @@ Instructions`)
expect(createdPath).toBe(p(PROJECT_DIR, ".roo", "skills", "project-skill", "SKILL.md"))
})
+ it("should escape double quotes in the description so the created skill loads (issue #859)", async () => {
+ mockDirectoryExists.mockResolvedValue(false)
+ mockRealpath.mockImplementation(async (p: string) => p)
+ mockReaddir.mockResolvedValue([])
+ mockFileExists.mockResolvedValue(false)
+ mockMkdir.mockResolvedValue(undefined)
+ mockWriteFile.mockResolvedValue(undefined)
+
+ const description = 'Use when implementing features. Triggers on: "TDD", "test-driven development"'
+ const createdPath = await skillsManager.createSkill("test-skill", "global", description)
+
+ // The written frontmatter must be valid YAML that round-trips the
+ // description, with the raw value quoted instead of unescaped.
+ const writtenContent = String(mockWriteFile.mock.calls[0][1])
+ const parsed = matter(writtenContent)
+ expect(parsed.data.name).toBe("test-skill")
+ expect(parsed.data.description).toBe(description)
+ expect(writtenContent).toContain(`description: '${description}'`)
+
+ // Re-discovery loads the created skill instead of silently skipping it.
+ mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir)
+ mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["test-skill"] : []))
+ mockStat.mockImplementation(async (pathArg: string) => {
+ if (pathArg === p(globalSkillsDir, "test-skill")) {
+ return { isDirectory: () => true }
+ }
+ throw new Error("Not found")
+ })
+ mockFileExists.mockImplementation(async (file: string) => file === createdPath)
+ mockReadFile.mockImplementation(async (file: string) =>
+ file === createdPath ? writtenContent : Promise.reject(new Error("File not found")),
+ )
+
+ await skillsManager.discoverSkills()
+
+ const skills = skillsManager.getAllSkills()
+ expect(skills).toHaveLength(1)
+ expect(skills[0].name).toBe("test-skill")
+ expect(skills[0].description).toBe(description)
+ })
+
+ it("should quote descriptions that would otherwise parse as YAML non-strings", async () => {
+ mockDirectoryExists.mockResolvedValue(false)
+ mockRealpath.mockImplementation(async (p: string) => p)
+ mockReaddir.mockResolvedValue([])
+ mockFileExists.mockResolvedValue(false)
+ mockMkdir.mockResolvedValue(undefined)
+ mockWriteFile.mockResolvedValue(undefined)
+
+ await skillsManager.createSkill("flag-skill", "global", "yes")
+
+ const writtenContent = String(mockWriteFile.mock.calls[0][1])
+ const parsed = matter(writtenContent)
+ // Without quoting, js-yaml resolves "yes" to a boolean and the skill
+ // is rejected with a misleading "missing required 'description' field".
+ expect(typeof parsed.data.description).toBe("string")
+ expect(parsed.data.description).toBe("yes")
+ })
+
+ it("should rewrite modeSlugs via gray-matter serialization without reflowing the description", async () => {
+ const skillDir = p(globalSkillsDir, "modes-skill")
+ const skillMd = p(skillDir, "SKILL.md")
+ const description = 'Use when implementing features. Triggers on: "TDD", "test-driven development"'
+ const originalContent = `---
+name: modes-skill
+description: '${description}'
+---
+
+# Modes Skill
+
+## Instructions
+
+Body here.`
+
+ // Initial discovery so the skill is registered.
+ mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir)
+ mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["modes-skill"] : []))
+ mockStat.mockImplementation(async (pathArg: string) => {
+ if (pathArg === skillDir) {
+ return { isDirectory: () => true }
+ }
+ throw new Error("Not found")
+ })
+ mockFileExists.mockImplementation(async (file: string) => file === skillMd)
+ mockReadFile.mockResolvedValue(originalContent)
+ mockWriteFile.mockResolvedValue(undefined)
+
+ await skillsManager.discoverSkills()
+ expect(skillsManager.getAllSkills()).toHaveLength(1)
+
+ // Apply a mode restriction; the file is rewritten with the new slugs.
+ await skillsManager.updateSkillModes("modes-skill", "global", ["code"])
+
+ expect(mockWriteFile).toHaveBeenCalledTimes(1)
+ const rewritten = String(mockWriteFile.mock.calls[0][1])
+ const parsed = matter(rewritten)
+ // The description survives the round-trip exactly and stays quoted.
+ expect(parsed.data.description).toBe(description)
+ expect(rewritten).toContain(`description: '${description}'`)
+ // The new modeSlugs were written and the body is preserved.
+ expect(parsed.data.modeSlugs).toEqual(["code"])
+ expect(rewritten).toContain("Body here.")
+ // lineWidth: -1 keeps the value on a single line (no folded block scalar).
+ expect(rewritten).not.toContain("> ")
+
+ // Clearing the restriction removes modeSlugs from the frontmatter.
+ await skillsManager.updateSkillModes("modes-skill", "global", [])
+
+ const rewritten2 = String(mockWriteFile.mock.calls[1][1])
+ const parsed2 = matter(rewritten2)
+ expect(parsed2.data.modeSlugs).toBeUndefined()
+ expect(parsed2.data.description).toBe(description)
+ })
+
it("should throw error for invalid skill name", async () => {
await expect(skillsManager.createSkill("Invalid-Name", "global", "Description")).rejects.toThrow(
"Skill name must be lowercase letters/numbers/hyphens only",
@@ -1299,6 +1625,197 @@ Instructions`)
})
})
+ describe("skill diagnostics", () => {
+ it("reports malformed YAML with location while retaining valid skills", async () => {
+ const validSkillDir = p(globalSkillsDir, "valid-skill")
+ const malformedSkillDir = p(globalSkillsDir, "malformed-skill")
+ const validSkillPath = p(validSkillDir, "SKILL.md")
+ const malformedSkillPath = p(malformedSkillDir, "SKILL.md")
+ mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir)
+ mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+ mockReaddir.mockImplementation(async (dir: string) =>
+ dir === globalSkillsDir ? ["valid-skill", "malformed-skill"] : [],
+ )
+ mockStat.mockResolvedValue({ isDirectory: () => true })
+ mockFileExists.mockImplementation(async (file: string) =>
+ [validSkillPath, malformedSkillPath].includes(file),
+ )
+ mockReadFile.mockImplementation(async (file: string) => {
+ if (file === validSkillPath) {
+ return "---\nname: valid-skill\ndescription: Still visible\n---\nValid instructions"
+ }
+ return '---\nname: malformed-skill\ndescription: Triggers on: "TDD"\n---\nBroken instructions'
+ })
+
+ await skillsManager.discoverSkills()
+
+ expect(skillsManager.getSkillsMetadata()).toEqual([
+ expect.objectContaining({ name: "valid-skill", description: "Still visible" }),
+ ])
+ expect(skillsManager.getSkillDiagnostics()).toEqual([
+ expect.objectContaining({
+ path: malformedSkillPath,
+ source: "global",
+ message: expect.any(String),
+ line: 3,
+ column: expect.any(Number),
+ }),
+ ])
+ })
+
+ it("serializes overlapping scans so an older scan cannot append stale diagnostics", async () => {
+ const skillDir = p(globalSkillsDir, "flaky-skill")
+ const skillPath = p(skillDir, "SKILL.md")
+ const staleContent = `---
+name: flaky-skill
+description: "Broken "quote" frontmatter
+---
+
+Body.`
+ const goodContent = `---
+name: flaky-skill
+description: Healthy after repair
+---
+
+Body.`
+
+ mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir)
+ mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+ mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["flaky-skill"] : []))
+ mockStat.mockResolvedValue({ isDirectory: () => true })
+ mockFileExists.mockImplementation(async (file: string) => file === skillPath)
+
+ // The first scan's read is delayed (as when a watcher fires while the
+ // file is still being written) and observes malformed content. A second
+ // scan started before that read resolves must run afterwards, and its
+ // result must win.
+ let resolveFirstRead!: (content: string) => void
+ const firstRead = new Promise((resolve) => {
+ resolveFirstRead = resolve
+ })
+ let reads = 0
+ mockReadFile.mockImplementation(async () => {
+ reads += 1
+ return reads === 1 ? firstRead : goodContent
+ })
+
+ const first = skillsManager.discoverSkills()
+ const second = skillsManager.discoverSkills()
+ resolveFirstRead(staleContent)
+ await Promise.all([first, second])
+
+ expect(skillsManager.getSkillsMetadata()).toEqual([
+ expect.objectContaining({ name: "flaky-skill", description: "Healthy after repair" }),
+ ])
+ expect(skillsManager.getSkillDiagnostics()).toEqual([])
+ })
+
+ it("does not hint at unescaped quotes when the parse error is on another line", async () => {
+ const skillDir = p(globalSkillsDir, "hint-skill")
+ const skillPath = p(skillDir, "SKILL.md")
+
+ mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir)
+ mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+ mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["hint-skill"] : []))
+ mockStat.mockResolvedValue({ isDirectory: () => true })
+ mockFileExists.mockImplementation(async (file: string) => file === skillPath)
+ // The description is a valid single-quoted value that itself contains
+ // double quotes; the YAML error is on the unclosed name line instead,
+ // so the unescaped-quotes hint must not fire.
+ mockReadFile.mockResolvedValue(`---
+name: "unclosed
+description: 'Triggers on "TDD" - valid quotes'
+---
+
+Body.`)
+
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+
+ try {
+ await skillsManager.discoverSkills()
+
+ const logged = consoleErrorSpy.mock.calls.map((call) => call.join(" "))
+ expect(logged.some((line) => line.includes("unescaped double quotes"))).toBe(false)
+ expect(skillsManager.getSkillDiagnostics()).toEqual([
+ expect.objectContaining({ path: skillPath, source: "global", line: 4 }),
+ ])
+ } finally {
+ consoleErrorSpy.mockRestore()
+ }
+ })
+
+ it("keeps reporting the same malformed skill on a re-scan (gray-matter cache must not swallow the error)", async () => {
+ const skillDir = p(globalSkillsDir, "cached-bad-skill")
+ const skillPath = p(skillDir, "SKILL.md")
+
+ mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir)
+ mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+ mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["cached-bad-skill"] : []))
+ mockStat.mockResolvedValue({ isDirectory: () => true })
+ mockFileExists.mockImplementation(async (file: string) => file === skillPath)
+ mockReadFile.mockResolvedValue(`---
+name: cached-bad-skill
+description: "Broken "quote" description
+---
+
+Body.`)
+
+ await skillsManager.discoverSkills()
+ expect(skillsManager.getSkillsMetadata()).toEqual([])
+ expect(skillsManager.getSkillDiagnostics()).toHaveLength(1)
+
+ // A re-scan of the identical (unchanged) content must keep reporting the
+ // parse failure. gray-matter keeps a global content-keyed cache that it
+ // populates before parsing, so without bypassing it the first throw is
+ // cached with an empty data object and every later parse of the same
+ // content silently returns that object instead of re-throwing - which
+ // would resurface the misleading "missing required 'name' field" symptom
+ // from issue #859.
+ await skillsManager.discoverSkills()
+ expect(skillsManager.getSkillsMetadata()).toEqual([])
+ expect(skillsManager.getSkillDiagnostics()).toHaveLength(1)
+ expect(skillsManager.getSkillDiagnostics()[0]).toEqual(
+ expect.objectContaining({ path: skillPath, source: "global" }),
+ )
+ })
+
+ it("records a diagnostic from a non-Error parse failure without location details", async () => {
+ const skillDir = p(globalSkillsDir, "raw-error-skill")
+ const skillPath = p(skillDir, "SKILL.md")
+
+ mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir)
+ mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+ mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["raw-error-skill"] : []))
+ mockStat.mockResolvedValue({ isDirectory: () => true })
+ mockFileExists.mockImplementation(async (file: string) => file === skillPath)
+ mockReadFile.mockResolvedValue(`---
+name: raw-error-skill
+description: Healthy
+---
+
+Body.`)
+ // Script a parse failure that is a plain string (no YAML reason, no
+ // mark, not an Error) so every defensive fallback in recordDiagnostic
+ // is exercised.
+ vi.mocked(matter).mockImplementationOnce(() => {
+ throw "gray-matter exploded"
+ })
+
+ await skillsManager.discoverSkills()
+
+ expect(skillsManager.getSkillsMetadata()).toEqual([])
+ expect(skillsManager.getSkillDiagnostics()).toEqual([
+ {
+ path: skillPath,
+ source: "global",
+ message: "gray-matter exploded",
+ line: undefined,
+ column: undefined,
+ },
+ ])
+ })
+ })
+
describe("deleteSkill", () => {
it("should delete an existing skill", async () => {
const testSkillDir = p(globalSkillsDir, "test-skill")
diff --git a/src/shared/skills.ts b/src/shared/skills.ts
index f5151181f6..6dc2cbf8ad 100644
--- a/src/shared/skills.ts
+++ b/src/shared/skills.ts
@@ -20,6 +20,15 @@ export interface SkillMetadata {
modeSlugs?: string[]
}
+/** A user-actionable problem found while loading a SKILL.md file. */
+export interface SkillDiagnostic {
+ path: string
+ source: "global" | "project"
+ message: string
+ line?: number
+ column?: number
+}
+
/**
* Full skill content (loaded on activation)
*/
diff --git a/webview-ui/src/components/settings/SkillsSettings.tsx b/webview-ui/src/components/settings/SkillsSettings.tsx
index bf81d0c6c7..9c250e8514 100644
--- a/webview-ui/src/components/settings/SkillsSettings.tsx
+++ b/webview-ui/src/components/settings/SkillsSettings.tsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback } from "react"
-import { Plus, Globe, Folder, Edit, Trash2, Settings } from "lucide-react"
+import { Plus, Globe, Folder, Edit, Trash2, Settings, TriangleAlert } from "lucide-react"
import { Trans } from "react-i18next"
import type { SkillMetadata } from "@roo-code/types"
@@ -35,8 +35,9 @@ import { CreateSkillDialog } from "./CreateSkillDialog"
export const SkillsSettings: React.FC = () => {
const { t } = useAppTranslation()
- const { cwd, skills: rawSkills, customModes } = useExtensionState()
+ const { cwd, skills: rawSkills, skillDiagnostics: rawSkillDiagnostics, customModes } = useExtensionState()
const skills = useMemo(() => rawSkills ?? [], [rawSkills])
+ const skillDiagnostics = useMemo(() => rawSkillDiagnostics ?? [], [rawSkillDiagnostics])
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [skillToDelete, setSkillToDelete] = useState(null)
@@ -232,6 +233,34 @@ export const SkillsSettings: React.FC = () => {
}}
/>
+ {skillDiagnostics.length > 0 && (
+
+
+
+
{t("settings:skills.diagnostics.title")}
+
+ {t("settings:skills.diagnostics.description")}
+
+
+ {skillDiagnostics.map((diagnostic) => {
+ const location = diagnostic.line
+ ? `:${diagnostic.line}${diagnostic.column ? `:${diagnostic.column}` : ""}`
+ : ""
+ return (
+ -
+
+ {diagnostic.path + location}
+
+ : {diagnostic.message}
+
+ )
+ })}
+
+
+
+ )}
{/* Add Skill button */}