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 75a72accde..6b391db0fa 100644
--- a/packages/types/src/vscode-extension-host.ts
+++ b/packages/types/src/vscode-extension-host.ts
@@ -14,7 +14,7 @@ import type { GitCommit } from "./git.js"
import type { McpServer } from "./mcp.js"
import type { ModelRecord, RouterModels } from "./model.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"
@@ -180,6 +180,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..25e1b2b48c 100644
--- a/src/core/webview/__tests__/skillsMessageHandler.spec.ts
+++ b/src/core/webview/__tests__/skillsMessageHandler.spec.ts
@@ -47,6 +47,7 @@ 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()
@@ -57,6 +58,7 @@ describe("skillsMessageHandler", () => {
const skillsManager = hasSkillsManager
? {
getSkillsMetadata: mockGetSkillsMetadata,
+ getSkillDiagnostics: mockGetSkillDiagnostics,
createSkill: mockCreateSkill,
deleteSkill: mockDeleteSkill,
moveSkill: mockMoveSkill,
@@ -90,6 +92,7 @@ describe("skillsMessageHandler", () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockGetSkillDiagnostics.mockReturnValue([])
})
describe("handleRequestSkills", () => {
@@ -100,7 +103,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 +139,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 +156,11 @@ describe("skillsMessageHandler", () => {
expect(result).toEqual([])
expect(mockLog).toHaveBeenCalled()
- expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [] })
+ expect(mockPostMessageToWebview).toHaveBeenCalledWith({
+ type: "skills",
+ skills: [],
+ skillDiagnostics: [],
+ })
})
})
@@ -142,7 +180,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 +254,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 +326,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 () => {
diff --git a/src/core/webview/skillsMessageHandler.ts b/src/core/webview/skillsMessageHandler.ts
index 496ff70c24..6781082933 100644
--- a/src/core/webview/skillsMessageHandler.ts
+++ b/src/core/webview/skillsMessageHandler.ts
@@ -16,15 +16,16 @@ export async function handleRequestSkills(provider: ClineProvider): Promise = new Map()
+ private diagnostics: SkillDiagnostic[] = []
private providerRef: WeakRef
private disposables: vscode.Disposable[] = []
private isDisposed = false
@@ -42,6 +43,7 @@ export class SkillsManager {
*/
async discoverSkills(): Promise {
this.skills.clear()
+ this.diagnostics = []
const skillsDirs = await this.getSkillsDirectories()
for (const { dir, source, mode } of skillsDirs) {
@@ -100,9 +102,17 @@ export class SkillsManager {
try {
const fileContent = await fs.readFile(skillMdPath, "utf-8")
+ let parsed: ReturnType
- // Use gray-matter to parse frontmatter
- const { data: frontmatter, content: body } = matter(fileContent)
+ try {
+ parsed = matter(fileContent)
+ } catch (error) {
+ this.recordDiagnostic(skillMdPath, source, error)
+ console.error(`Failed to parse skill at ${skillDir}:`, error)
+ return
+ }
+
+ const { data: frontmatter } = parsed
// Validate required fields (only name and description for now)
if (!frontmatter.name || typeof frontmatter.name !== "string") {
@@ -175,6 +185,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 +314,10 @@ export class SkillsManager {
return this.getAllSkills()
}
+ getSkillDiagnostics(): SkillDiagnostic[] {
+ return [...this.diagnostics]
+ }
+
/**
* Get a skill by name, source, and optionally mode
*/
@@ -394,25 +422,19 @@ 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 frontmatter = {
+ name,
+ description: trimmedDescription,
+ ...(modeSlugs && modeSlugs.length > 0 ? { modeSlugs } : {}),
}
-
- const skillContent = `---
-${frontmatterLines.join("\n")}
----
-
+ const body = `
# ${titleName}
## Instructions
Add your skill instructions here.
`
+ const skillContent = matter.stringify(body, frontmatter)
// Write the SKILL.md file
await fs.writeFile(skillMdPath, skillContent, "utf-8")
diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts
index d36582d893..5dbedff8fa 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 {
@@ -1233,6 +1234,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)
@@ -1299,6 +1341,45 @@ 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),
+ }),
+ ])
+ })
+ })
+
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..e6480ba61d 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,7 +35,7 @@ import { CreateSkillDialog } from "./CreateSkillDialog"
export const SkillsSettings: React.FC = () => {
const { t } = useAppTranslation()
- const { cwd, skills: rawSkills, customModes } = useExtensionState()
+ const { cwd, skills: rawSkills, skillDiagnostics, customModes } = useExtensionState()
const skills = useMemo(() => rawSkills ?? [], [rawSkills])
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
@@ -232,6 +232,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 */}