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 e64406fa9c..5e200cdc12 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" import type { WebviewThemeFixture } from "./vscode-extension-host.js" export type RooCodeAPIEvents = RooCodeEvents @@ -58,6 +59,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[] + } /** * Checks for an ordered user turn and assistant tool call in persisted API history. * @param taskId The ID of the task. 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 dc407325d9..f07d2dd305 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" @@ -200,6 +200,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, makeClineProviderFactory(), 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 3a33510346..b12a03b381 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -300,6 +300,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(), + } + } + /** Checks persisted turn ordering without exposing conversation contents to tests. */ public async hasTaskApiConversationHistorySequence( taskId: string, diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index 92ff4e0e09..418a13e42e 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,11 +16,45 @@ import { import { t } from "../../i18n" // Re-export for convenience -export type { SkillMetadata, SkillContent } +export type { SkillMetadata, SkillContent, SkillDiagnostic } // Direct children (new skill folders or container symlinks) and their SKILL.md files. const SKILLS_WATCH_PATTERN = "{*,*/SKILL.md}" +/** + * 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 } + /** Mutable state shared by every scan in a single discovery pass. */ interface DiscoveryPass { /** Skills found in this pass. Published to the manager only when the pass ends. */ @@ -35,6 +69,8 @@ interface DiscoveryPass { * unreadable for a moment. Everything else is replaced by the results of the pass. */ failedPaths: string[] + /** Frontmatter parse failures recorded during this pass. Published to the manager only when the pass ends. */ + diagnostics: SkillDiagnostic[] } function isNotFoundError(error: unknown): boolean { @@ -57,8 +93,13 @@ function isHiddenEntry(name: string): boolean { return name.startsWith(".") } +// Stale-sidecar sweep: a matching temp file younger than this may belong to a live write in +// another process, so only older sidecars are treated as abandoned by a previous host. +const SKILL_TEMP_SIDECAR_STALE_MS = 10 * 60 * 1000 + export class SkillsManager { private skills: Map = new Map() + private diagnostics: SkillDiagnostic[] = [] // SKILL.md paths of skills that come from a symlinked container. These are read-only for move/delete. private containerSkillPaths: Set = new Set() // Published symlinked containers: real path -> the root entry that links to it @@ -73,14 +114,79 @@ export class SkillsManager { // Runs discovery passes one at a time, so an older pass can't finish after a newer one private discoveryQueue: Promise = Promise.resolve() private isDisposed = false + // Atomic SKILL.md writes in flight, tracked so dispose() can remove temp sidecars if the + // extension host stops between the staged write and the rename. + private activeTempFiles: Set = new Set() + // Per-directory serialization for SKILL.md sidecar cleanup and writes. The queue is static so + // every SkillsManager instance in this process shares it: a sweep cannot unlink a sidecar that + // another instance is still writing, and writes cannot interleave their validate/rename steps. + private static directoryQueues: Map> = new Map() constructor(provider: ClineProvider) { this.providerRef = new WeakRef(provider) } - async initialize(): Promise { - await this.discoverSkills() - await this.setupFileWatchers() + private initialization: Promise | null = null + + initialize(): Promise { + if (this.initialization) return this.initialization + this.initialization = (async () => { + // A previous extension-host stop can leave a staged SKILL.md temp sidecar behind; the + // in-memory tracking cannot survive that, so sweep the known skills directories once. + try { + await this.sweepStaleSkillTempFiles() + } catch { + // Stale-cleanup is best effort; discovery and watcher setup still run. + } + // Disposal can land during the sweep: stop before touching discovery or watchers. + if (this.isDisposed) return + await this.discoverSkills() + if (this.isDisposed) return + await this.setupFileWatchers() + })() + return this.initialization + } + + /** + * Remove SKILL.md temp sidecars left by a previous host: the name pattern is the one + * writeSkillFileAtomically generates, and only the skill directories under the known skills + * roots are scanned. + */ + private async sweepStaleSkillTempFiles(): Promise { + const tempPattern = /^\.SKILL\.md\.\d+\.[a-z0-9]+\.tmp$/ + const roots = (await this.getSkillsDirectories()).map((entry) => entry.dir) + for (const root of roots) { + let entries: unknown[] + try { + entries = await fs.readdir(root, { withFileTypes: true }) + } catch { + continue + } + const directories = [root] + for (const entry of entries) { + const dirent = entry as { name?: string; isDirectory?: () => boolean } + if (!dirent?.isDirectory?.()) continue + directories.push(path.join(root, String(dirent.name))) + } + for (const directory of directories) { + await this.withDirectoryLock(directory, async () => { + let files: string[] + try { + files = await fs.readdir(directory) + } catch { + return + } + for (const file of files) { + if (!tempPattern.test(file)) continue + const stalePath = path.join(directory, file) + const stats = await fs.stat(stalePath).catch(() => undefined) + // Leave fresh sidecars alone: they may belong to a write in another process. + if (!stats || Date.now() - stats.mtimeMs < SKILL_TEMP_SIDECAR_STALE_MS) continue + await fs.unlink(stalePath).catch(() => {}) + } + }) + } + } } /** @@ -108,6 +214,7 @@ export class SkillsManager { containerSkillPaths: new Set(), containers: new Map(), failedPaths: [], + diagnostics: [], } const skillsDirs = await this.getSkillsDirectories() @@ -148,6 +255,7 @@ export class SkillsManager { this.skills = pass.skills this.containerSkillPaths = pass.containerSkillPaths this.containers = pass.containers + this.diagnostics = pass.diagnostics } /** @@ -315,8 +423,44 @@ export class SkillsManager { } try { - // Use gray-matter to parse frontmatter - const { data: frontmatter } = matter(fileContent) + // Parse the frontmatter. 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. + let parsed: ReturnType + try { + parsed = matter(fileContent, {}) + } catch (error) { + this.recordDiagnostic(skillMdPath, source, error, pass.diagnostics) + 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 descriptionLine = getFrontmatterLine(fileContent, "description") + const errorMark = (error as { mark?: { line?: unknown } }).mark + if ( + descriptionLine?.line.includes('"') && + typeof errorMark?.line === "number" && + errorMark.line + 1 === descriptionLine.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 undefined + } + + const { data: frontmatter } = parsed // Validate required fields (only name and description for now) if (!frontmatter.name || typeof frontmatter.name !== "string") { @@ -391,6 +535,29 @@ export class SkillsManager { } } + /** + * Record a frontmatter parse failure so the user learns which SKILL.md is broken + * and why, with the parser's own reason and location when available (see issue #859). + */ + private recordDiagnostic( + path: string, + source: "global" | "project", + error: unknown, + diagnostics: SkillDiagnostic[], + ): 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 + + diagnostics.push({ path, source, message: reason ?? errorMessage, line, column }) + } + /** * Get skills available for the current mode. * Resolves overrides: project > global, mode-specific > generic. @@ -502,6 +669,10 @@ export class SkillsManager { * Get all skills metadata (for UI display) * Returns skills from all sources without content */ + getSkillDiagnostics(): SkillDiagnostic[] { + return [...this.diagnostics] + } + getSkillsMetadata(): SkillMetadata[] { return this.getAllSkills() } @@ -561,6 +732,77 @@ export class SkillsManager { * @param modeSlugs - Optional mode restrictions (undefined/empty = any mode) * @returns Path to created SKILL.md file */ + /** + * Write one SKILL.md atomically: stage the content in a same-directory temp file, + * await the complete write, then rename it over the target. A crash or failed write + * must not leave a truncated SKILL.md that the loader would silently misparse. + * The temp file is removed when the write or rename fails. + */ + private async withDirectoryLock(directory: string, operation: () => Promise): Promise { + // Normalize through realpath first so a symlinked alias shares the same queue key; fall + // back to the resolved path when the directory cannot be resolved (e.g. not created yet). + const key = await fs.realpath(directory).catch(() => path.resolve(directory)) + const previous = SkillsManager.directoryQueues.get(key) ?? Promise.resolve() + const run = previous.then(async () => { + // A manager disposed while this operation waited must not run at all. + if (this.isDisposed) throw new Error("SkillsManager is disposed") + return operation() + }) + const tracked = run.then( + () => undefined, + () => undefined, + ) + SkillsManager.directoryQueues.set(key, tracked) + try { + return await run + } finally { + if (SkillsManager.directoryQueues.get(key) === tracked) { + SkillsManager.directoryQueues.delete(key) + } + } + } + + private async writeSkillFileAtomically( + filePath: string, + content: string, + validateTarget?: (targetPath: string) => Promise, + ): Promise { + const directory = path.dirname(filePath) + await this.withDirectoryLock(directory, async () => { + const temporaryPath = path.join( + directory, + `.${path.basename(filePath)}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`, + ) + this.activeTempFiles.add(temporaryPath) + // Only a write that actually created the sidecar through exclusive creation may remove + // it: when wx fails because the path already exists, that file belongs to someone else. + let ownsTemporaryPath = false + try { + if (this.isDisposed) throw new Error("SkillsManager is disposed") + // Exclusive creation: a pre-created symlink at the generated name cannot be followed. + await fs.writeFile(temporaryPath, content, { encoding: "utf-8", flag: "wx" }) + ownsTemporaryPath = true + if (this.isDisposed) throw new Error("SkillsManager is disposed") + // Re-validate the final target immediately before the rename: the target (or an + // ancestor) can change between the caller's resolution and this publish. + await validateTarget?.(filePath) + if (this.isDisposed) throw new Error("SkillsManager is disposed") + await fs.rename(temporaryPath, filePath) + } catch (error) { + // Best effort: a partial temp file must not be left behind, but an EEXIST failure + // means exclusive creation never acquired the path and the existing file belongs to + // someone else. Any other failure may have created a partial sidecar and is cleaned + // up; the original failure is the one the caller needs to see. + if (ownsTemporaryPath || (error as { code?: string })?.code !== "EEXIST") { + await fs.unlink(temporaryPath).catch(() => {}) + } + throw error + } finally { + this.activeTempFiles.delete(temporaryPath) + } + }) + } + async createSkill( name: string, source: "global" | "project", @@ -601,8 +843,9 @@ export class SkillsManager { throw new Error(t("skills:errors.already_exists", { name, path: skillMdPath })) } - // Create the skill directory - await fs.mkdir(skillDir, { recursive: true }) + // Create the skill directory. mkdir reports the path it created (when any), so a + // failed persistence can remove exactly the directory this call introduced. + const createdDir = await fs.mkdir(skillDir, { recursive: true }) // Generate SKILL.md content with frontmatter const titleName = name @@ -610,28 +853,38 @@ 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}`) - } + // 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 skillContent = `--- -${frontmatterLines.join("\n")} ---- - + const body = ` # ${titleName} ## Instructions Add your skill instructions here. ` - - // Write the SKILL.md file - await fs.writeFile(skillMdPath, skillContent, "utf-8") + const skillContent = matter.stringify(body, frontmatter, SKILL_YAML_DUMP_OPTIONS) + // Publish the SKILL.md atomically, and leave no half-created skill directory behind + // when persistence fails. mkdir(recursive) can report the first ancestor it created + // (.roo or skills), so a recursive removal of that value could delete unrelated + // skills: only this call's own skill directory is removed, and only while it is + // empty. Any created ancestor stays behind like the empty directory it is. + try { + await this.writeSkillFileAtomically(skillMdPath, skillContent) + } catch (error) { + if (createdDir) { + await fs.rmdir(skillDir).catch(() => {}) + } + throw error + } // Refresh skills list await this.discoverSkills() @@ -927,6 +1180,26 @@ Add your skill instructions here. * @param source - Where the skill is located ("global" or "project") * @param newModeSlugs - New mode slugs (undefined/empty = any mode) */ + /** + * The resolved SKILL.md target must stay inside one of the configured skills roots for the + * source: a direct skill-directory symlink can point anywhere, and publishing through it + * would write outside the user selected skills folder. + */ + private async assertTargetWithinSkillsRoots(targetPath: string, source: "global" | "project"): Promise { + const roots = (await this.getSkillsDirectories()) + .filter((entry) => entry.source === source) + .map((entry) => entry.dir) + const inside = async (root: string): Promise => { + const resolvedRoot = await fs.realpath(root).catch(() => root) + const relative = path.relative(resolvedRoot, targetPath) + return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative) + } + for (const root of roots) { + if (await inside(root)) return + } + throw new Error(`Refusing to update SKILL.md outside the ${source} skills directories: ${targetPath}`) + } + async updateSkillModes(name: string, source: "global" | "project", newModeSlugs?: string[]): Promise { // Find any skill with this name and source (regardless of current mode) let skill: SkillMetadata | undefined @@ -958,9 +1231,22 @@ Add your skill instructions here. delete frontmatter.mode } - // Serialize back to SKILL.md format - const newContent = matter.stringify(body, frontmatter) - await fs.writeFile(skill.path, newContent, "utf-8") + // 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) + // Resolve the link target first: publishing through the temp+rename must update the file + // the skill was discovered through, not replace a symlinked SKILL.md with a regular file. + // A resolution failure propagates: publishing through the unresolved path could replace a + // symlink with a regular file (or write somewhere other than the target the caller saw). + const targetPath = await fs.realpath(skill.path) + await this.assertTargetWithinSkillsRoots(targetPath, source) + await this.writeSkillFileAtomically(targetPath, newContent, async (target) => { + // Fail closed: a resolution failure must propagate so the staged temp write is removed + // and the rename is skipped, rather than publishing through a stale path. + const freshTarget = await fs.realpath(target) + await this.assertTargetWithinSkillsRoots(freshTarget, source) + }) // Refresh skills list await this.discoverSkills() @@ -1051,6 +1337,7 @@ Add your skill instructions here. } private async setupFileWatchers(): Promise { + if (this.isDisposed) return // Skip if test environment is detected or VSCode APIs are not available if (process.env.NODE_ENV === "test" || !vscode.workspace.createFileSystemWatcher) { return @@ -1090,6 +1377,7 @@ Add your skill instructions here. } private watchDirectory(dirPath: string): void { + if (this.isDisposed) return const watcher = this.createSkillsWatcher(dirPath) if (watcher) { this.disposables.push(watcher) @@ -1141,6 +1429,11 @@ Add your skill instructions here. async dispose(): Promise { this.isDisposed = true + // Any sidecar still tracked is a write that never reached its rename. + for (const temporaryPath of this.activeTempFiles) { + await fs.unlink(temporaryPath).catch(() => {}) + } + this.activeTempFiles.clear() this.disposables.forEach((d) => d.dispose()) this.disposables = [] this.containerWatchers.forEach((w) => w.dispose()) diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts index 1d6727605e..15f5d3db47 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 { @@ -94,6 +95,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: { @@ -421,6 +436,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 @@ -2065,7 +2236,12 @@ Instructions`) mockMkdir.mockResolvedValue(undefined) mockWriteFile.mockResolvedValue(undefined) - const createdPath = await skillsManager.createSkill("new-skill", "global", "A new skill description") + // Longer than js-yaml's default 80-column fold width: createSkill must + // write it as a single description line, not a folded scalar. + const description = + 'Use when implementing features. Triggers on: "TDD", "test-driven development" and "refactoring" workflows' + + const createdPath = await skillsManager.createSkill("new-skill", "global", description) expect(createdPath).toBe(p(GLOBAL_ROO_DIR, "skills", "new-skill", "SKILL.md")) expect(mockMkdir).toHaveBeenCalledWith(p(GLOBAL_ROO_DIR, "skills", "new-skill"), { recursive: true }) @@ -2073,9 +2249,14 @@ Instructions`) // Verify the content written const writeCall = mockWriteFile.mock.calls[0] - expect(writeCall[0]).toBe(p(GLOBAL_ROO_DIR, "skills", "new-skill", "SKILL.md")) - expect(writeCall[1]).toContain("name: new-skill") - expect(writeCall[1]).toContain("description: A new skill description") + expect(String(writeCall[0])).toMatch(/[/\\]new-skill[/\\]\.SKILL\.md\.[^/\\]+\.tmp$/) + expect(mockRename).toHaveBeenCalledWith(writeCall[0], p(GLOBAL_ROO_DIR, "skills", "new-skill", "SKILL.md")) + const written = String(writeCall[1]) + expect(written).toContain("name: new-skill") + const descriptionLine = written.split("\n").find((line) => line.startsWith("description:")) + // Long descriptions are quoted (js-yaml quotes strings containing quotes). + expect(descriptionLine).toBe(`description: '${description}'`) + expect(written).not.toContain("> ") }) it("should create a mode-specific skill with modeSlugs array", async () => { @@ -2097,6 +2278,163 @@ 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("cleans the temp file and the newly created skill directory when persistence fails", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + mockFileExists.mockResolvedValue(false) + const createdDir = p(GLOBAL_ROO_DIR, "skills", "failing-skill") + mockMkdir.mockResolvedValue(createdDir) + mockWriteFile.mockRejectedValueOnce(Object.assign(new Error("ENOSPC"), { code: "ENOSPC" })) + mockUnlink.mockResolvedValue(undefined) + mockRm.mockResolvedValue(undefined) + mockRmdir.mockResolvedValue(undefined) + + await expect(skillsManager.createSkill("failing-skill", "global", "Description")).rejects.toThrow("ENOSPC") + + // The partially written temp file is removed and the directory this call created is + // removed only while empty (rmdir, never a recursive rm that could take ancestors + // reported by mkdir(recursive) with unrelated skills); the original persistence + // error is what the caller sees. + expect(mockUnlink).toHaveBeenCalledWith(expect.stringMatching(/\.SKILL\.md\.[^/\\]+\.tmp$/)) + expect(mockRmdir).toHaveBeenCalledWith(p(GLOBAL_ROO_DIR, "skills", "failing-skill")) + expect(mockRm).not.toHaveBeenCalled() + }) + + it("leaves a non-empty skill directory alone when the empty-only removal fails", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + mockFileExists.mockResolvedValue(false) + mockMkdir.mockResolvedValue(p(GLOBAL_ROO_DIR, "skills", "busy-skill")) + mockWriteFile.mockRejectedValueOnce(Object.assign(new Error("ENOSPC"), { code: "ENOSPC" })) + mockUnlink.mockResolvedValue(undefined) + mockRmdir.mockRejectedValueOnce(Object.assign(new Error("ENOTEMPTY"), { code: "ENOTEMPTY" })) + + // A failed cleanup (the temp file survived, another skill appeared, ...) must not + // mask or replace the persistence failure. + await expect(skillsManager.createSkill("busy-skill", "global", "Description")).rejects.toThrow("ENOSPC") + expect(mockRmdir).toHaveBeenCalledWith(p(GLOBAL_ROO_DIR, "skills", "busy-skill")) + expect(mockRm).not.toHaveBeenCalled() + }) + + it("never recursively removes an ancestor reported by recursive mkdir", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + mockFileExists.mockResolvedValue(false) + // mkdir(recursive) reports the first ancestor it created; removing that value + // recursively could take the sibling skill directories under it with it. + mockMkdir.mockResolvedValue(p(GLOBAL_ROO_DIR, "skills")) + mockWriteFile.mockRejectedValueOnce(Object.assign(new Error("ENOSPC"), { code: "ENOSPC" })) + mockUnlink.mockResolvedValue(undefined) + mockRm.mockResolvedValue(undefined) + mockRmdir.mockResolvedValue(undefined) + + await expect(skillsManager.createSkill("isolated-skill", "global", "Description")).rejects.toThrow("ENOSPC") + + // Only this skill's directory is considered, and only for an empty-only removal. + expect(mockRmdir).toHaveBeenCalledWith(p(GLOBAL_ROO_DIR, "skills", "isolated-skill")) + expect(mockRm).not.toHaveBeenCalled() + }) + + it("preserves an existing file when exclusive staging fails because the name exists", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + mockFileExists.mockResolvedValue(false) + mockMkdir.mockResolvedValue(p(GLOBAL_ROO_DIR, "skills", "exists")) + const existsError = Object.assign(new Error("EEXIST: file already exists"), { code: "EEXIST" }) + mockWriteFile.mockRejectedValueOnce(existsError) + mockUnlink.mockResolvedValue(undefined) + + await expect(skillsManager.createSkill("exists", "global", "Description")).rejects.toBe(existsError) + + // wx failed before this write owned the path: the existing file must not be unlinked. + expect(mockUnlink).not.toHaveBeenCalled() + expect(mockRename).not.toHaveBeenCalled() + expect(skillsManager["activeTempFiles"].size).toBe(0) + }) + + it("removes the temp file and rethrows the original error when the create rename fails", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + mockFileExists.mockResolvedValue(false) + mockMkdir.mockResolvedValue(p(GLOBAL_ROO_DIR, "skills", "rename-fails")) + mockWriteFile.mockResolvedValue(undefined) + const renameError = Object.assign(new Error("EEXIST: rename failed"), { code: "EEXIST" }) + mockRename.mockRejectedValueOnce(renameError) + mockUnlink.mockResolvedValue(undefined) + mockRmdir.mockResolvedValue(undefined) + + await expect(skillsManager.createSkill("rename-fails", "global", "Description")).rejects.toBe(renameError) + + const temporaryPath = String(mockWriteFile.mock.calls[0][0]) + expect(temporaryPath).toMatch(/\.SKILL\.md\.[^/\\]+\.tmp$/) + expect(mockUnlink).toHaveBeenCalledWith(temporaryPath) + expect(skillsManager["activeTempFiles"].size).toBe(0) + }) + + it("does not remove a pre-existing skill directory when persistence fails", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + mockFileExists.mockResolvedValue(false) + // mkdir resolves undefined when the directory already existed: it is not ours to remove. + mockMkdir.mockResolvedValue(undefined) + mockWriteFile.mockRejectedValueOnce(new Error("EACCES")) + mockUnlink.mockResolvedValue(undefined) + mockRm.mockResolvedValue(undefined) + + await expect(skillsManager.createSkill("owned-skill", "global", "Description")).rejects.toThrow("EACCES") + + expect(mockRm).not.toHaveBeenCalled() + expect(mockRmdir).not.toHaveBeenCalled() + expect(mockUnlink).toHaveBeenCalledWith(expect.stringMatching(/\.SKILL\.md\.[^/\\]+\.tmp$/)) + }) + it("should create a project skill", async () => { mockDirectoryExists.mockResolvedValue(false) mockRealpath.mockImplementation(async (p: string) => p) @@ -2110,6 +2448,266 @@ 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) + + // Deliberately longer than js-yaml's default 80-column fold width: without + // lineWidth: -1 the serializer wraps this into a rolled scalar. + const description = + 'Use when implementing features. Triggers on: "TDD", "test-driven development", "red-green-refactor" and "refactoring" workflows' + 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]) + // The rewrite lands through the same atomic temp+rename path as createSkill. + expect(String(mockWriteFile.mock.calls[0][0])).toMatch(/\.SKILL\.md\.[^/\\]+\.tmp$/) + expect(mockRename).toHaveBeenCalledWith(mockWriteFile.mock.calls[0][0], skillMd) + 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: without it js-yaml folds + // a description this long across several lines, so pin the exact line and + // the absence of any continuation. + const descriptionLine = rewritten.split("\n").find((line) => line.startsWith("description:")) + expect(descriptionLine).toBe(`description: '${description}'`) + 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("propagates a realpath failure instead of writing through the unresolved path", async () => { + const skillDir = p(globalSkillsDir, "unresolvable-skill") + const skillMd = p(skillDir, "SKILL.md") + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockReaddir.mockImplementation(async (dir: string) => + dir === globalSkillsDir ? ["unresolvable-skill"] : [], + ) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === skillDir) { + return { isDirectory: () => true } + } + throw enoent() + }) + mockFileExists.mockImplementation(async (file: string) => file === skillMd) + mockReadFile.mockResolvedValue("---\nname: unresolvable-skill\ndescription: A skill\n---\nInstructions") + + await skillsManager.discoverSkills() + // The discovered path resolves, but the publish-time resolution fails: the write must + // not fall back to the unresolved (potentially symlinked) path. + const resolutionError = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + mockRealpath.mockRejectedValueOnce(resolutionError) + + await expect(skillsManager.updateSkillModes("unresolvable-skill", "global", ["code"])).rejects.toBe( + resolutionError, + ) + expect(mockWriteFile).not.toHaveBeenCalled() + expect(mockRename).not.toHaveBeenCalled() + }) + + it("removes the temp file and rethrows the original error when the update rename fails", async () => { + const skillDir = p(globalSkillsDir, "rename-update") + const skillMd = p(skillDir, "SKILL.md") + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["rename-update"] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === skillDir) { + return { isDirectory: () => true } + } + throw enoent() + }) + mockFileExists.mockImplementation(async (file: string) => file === skillMd) + mockReadFile.mockResolvedValue("---\nname: rename-update\ndescription: A skill\n---\nInstructions") + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockWriteFile.mockResolvedValue(undefined) + const renameError = Object.assign(new Error("EIO: rename failed"), { code: "EIO" }) + mockRename.mockRejectedValueOnce(renameError) + mockUnlink.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + await expect(skillsManager.updateSkillModes("rename-update", "global", ["code"])).rejects.toBe(renameError) + + const temporaryPath = String(mockWriteFile.mock.calls[0][0]) + expect(temporaryPath).toMatch(/\.SKILL\.md\.[^/\\]+\.tmp$/) + expect(mockWriteFile.mock.calls[0][2]).toEqual(expect.objectContaining({ flag: "wx" })) + expect(mockUnlink).toHaveBeenCalledWith(temporaryPath) + // The existing SKILL.md was never replaced: the only write went to the temp sidecar and + // the failed rename never committed. + expect(mockWriteFile).toHaveBeenCalledTimes(1) + expect(mockRename).toHaveBeenCalledWith(temporaryPath, skillMd) + // The existing SKILL.md and its directory remain untouched. + expect(mockReadFile).toHaveBeenCalledWith(skillMd, "utf-8") + expect(mockWriteFile).not.toHaveBeenCalledWith(skillMd, expect.anything(), expect.anything()) + expect(mockRmdir).not.toHaveBeenCalled() + expect(mockRm).not.toHaveBeenCalled() + expect(skillsManager["activeTempFiles"].size).toBe(0) + }) + + it("fails closed when the pre-rename target resolution fails", async () => { + const skillDir = p(globalSkillsDir, "resolution-fails") + const skillMd = p(skillDir, "SKILL.md") + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["resolution-fails"] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === skillDir) { + return { isDirectory: () => true } + } + throw enoent() + }) + mockFileExists.mockImplementation(async (file: string) => file === skillMd) + mockReadFile.mockResolvedValue("---\nname: resolution-fails\ndescription: A skill\n---\nInstructions") + // The first resolution succeeds; the immediate pre-rename resolution fails and must + // propagate instead of publishing through the stale path. + let resolutions = 0 + mockRealpath.mockImplementation(async (pathArg: string) => { + if (pathArg === skillMd) { + resolutions += 1 + if (resolutions === 1) { + return skillMd + } + throw Object.assign(new Error("EIO: resolution failed"), { code: "EIO" }) + } + return pathArg + }) + mockWriteFile.mockResolvedValue(undefined) + mockUnlink.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + await expect(skillsManager.updateSkillModes("resolution-fails", "global", ["code"])).rejects.toThrow( + "EIO: resolution failed", + ) + const temporaryPath = String(mockWriteFile.mock.calls[0][0]) + expect(mockUnlink).toHaveBeenCalledWith(temporaryPath) + expect(mockRename).not.toHaveBeenCalled() + }) + + it("refuses to update a SKILL.md that resolves outside the skills roots", async () => { + const skillDir = p(globalSkillsDir, "escaping") + const skillMd = p(skillDir, "SKILL.md") + const outsidePath = p("/outside/root", "SKILL.md") + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["escaping"] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === skillDir) { + return { isDirectory: () => true } + } + throw enoent() + }) + mockFileExists.mockImplementation(async (file: string) => file === skillMd) + mockReadFile.mockResolvedValue("---\nname: escaping\ndescription: A skill\n---\nInstructions") + // Discovery saw the root path, but the publish-time resolution points outside every + // configured skills root: a direct skill-dir symlink must not let the write escape. + mockRealpath.mockImplementation(async (pathArg: string) => (pathArg === skillMd ? outsidePath : pathArg)) + mockWriteFile.mockResolvedValue(undefined) + mockRename.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + await expect(skillsManager.updateSkillModes("escaping", "global", ["code"])).rejects.toThrow( + /Refusing to update SKILL\.md outside the global skills directories/, + ) + expect(mockWriteFile).not.toHaveBeenCalled() + expect(mockRename).not.toHaveBeenCalled() + }) + 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", @@ -2189,7 +2787,248 @@ Instructions`) const created = await skillsManager.createSkill("my-skill", "global", "Description") expect(created).toBe(rooSkillMd) - expect(mockWriteFile).toHaveBeenCalledWith(rooSkillMd, expect.any(String), "utf-8") + const staged = mockWriteFile.mock.calls[0][0] + expect(String(staged)).toMatch(/\.SKILL\.md\.[^/\\]+\.tmp$/) + expect(mockRename).toHaveBeenCalledWith(staged, rooSkillMd) + }) + }) + + 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 expose a half-built snapshot to a reader while a scan is in progress", async () => { + const stablePath = p(globalSkillsDir, "stable-skill", "SKILL.md") + const slowPath = p(globalSkillsDir, "slow-skill", "SKILL.md") + const stableContent = `--- +name: stable-skill +description: Stable before the scan +--- + +Body.` + const slowContent = `--- +name: slow-skill +description: Loaded after the scan completes +--- + +Body.` + + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + // The second scan sees an extra skill, and that skill's read is parked, so + // the reader runs while the scan is mid-flight. Committing incrementally + // would let the reader see the scan after the clear but before the slow + // skill was loaded. + let entries = ["stable-skill"] + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? entries : [])) + mockStat.mockResolvedValue({ isDirectory: () => true }) + mockFileExists.mockImplementation(async (file: string) => file === stablePath || file === slowPath) + let resolveSlowRead!: (content: string) => void + const slowRead = new Promise((resolve) => { + resolveSlowRead = resolve + }) + mockReadFile.mockImplementation(async (file: string) => (file === slowPath ? slowRead : stableContent)) + + await skillsManager.discoverSkills() + expect(skillsManager.getSkillsMetadata()).toHaveLength(1) + + // Slow skill first: the reader runs after the scan started but before any of + // its own skills were added, so an incremental commit would show an empty list. + entries = ["slow-skill", "stable-skill"] + const scan = skillsManager.discoverSkills() + await vi.waitFor(() => expect(mockReadFile.mock.calls.map((c) => c[0])).toContain(slowPath)) + expect(skillsManager.getSkillsMetadata()).toHaveLength(1) + expect(skillsManager.getSkillDiagnostics()).toEqual([]) + + resolveSlowRead(slowContent) + await scan + + expect(skillsManager.getSkillsMetadata()).toHaveLength(2) + }) + 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, + }, + ]) }) }) @@ -3273,4 +4112,142 @@ Instructions`) }) }) }) + it("stops creating watchers once the manager is disposed", async () => { + const createWatcher = vi.mocked(vscode.workspace.createFileSystemWatcher) + const originalNodeEnv = process.env.NODE_ENV + process.env.NODE_ENV = "development" + try { + createWatcher.mockClear() + await skillsManager["setupFileWatchers"]() + expect(createWatcher).toHaveBeenCalled() + + createWatcher.mockClear() + await skillsManager.dispose() + await skillsManager["setupFileWatchers"]() + expect(createWatcher).not.toHaveBeenCalled() + } finally { + process.env.NODE_ENV = originalNodeEnv + } + }) + + it("sweeps only a staged sidecar left by a previous host", async () => { + const staleName = ".SKILL.md.123.abc.tmp" + const freshName = ".SKILL.md.456.def.tmp" + mockReaddir.mockImplementation(async (_dir: string, options?: { withFileTypes?: boolean }) => { + if (options?.withFileTypes) { + return [] + } + return [staleName, freshName] + }) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg.endsWith(staleName)) { + // Older than the stale threshold: abandoned by a previous host. + return { mtimeMs: Date.now() - 60 * 60 * 1000 } + } + if (pathArg.endsWith(freshName)) { + return { mtimeMs: Date.now() } + } + throw enoent() + }) + mockUnlink.mockResolvedValue(undefined) + + await skillsManager["sweepStaleSkillTempFiles"]() + + expect(mockUnlink).toHaveBeenCalledWith(p(globalSkillsDir, staleName)) + // A fresh matching sidecar may belong to a live write and must survive. + expect(mockUnlink).not.toHaveBeenCalledWith(p(globalSkillsDir, freshName)) + }) + + it("shares one directory queue across symlink aliases", async () => { + const order: string[] = [] + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + const alias = p("/alias", "skills") + mockRealpath.mockImplementation(async (pathArg: string) => (pathArg === alias ? globalSkillsDir : pathArg)) + + const first = skillsManager["withDirectoryLock"](alias, async () => { + order.push("first:start") + await firstGate + order.push("first:end") + }) + try { + // The lock key is resolved through realpath asynchronously before the operation runs. + await vi.waitFor(() => expect(order).toEqual(["first:start"])) + const second = skillsManager["withDirectoryLock"](globalSkillsDir, async () => { + order.push("second") + }) + await Promise.resolve() + // The alias resolves to the same real directory, so the second operation waits. + expect(order).toEqual(["first:start"]) + + releaseFirst() + await Promise.all([first, second]) + expect(order).toEqual(["first:start", "first:end", "second"]) + } finally { + // Never leave the shared queue holding a gate if an assertion above fails. + releaseFirst() + await first.catch(() => undefined) + } + }) + + it("does not start watchers when initialization runs after disposal", async () => { + const createWatcher = vi.mocked(vscode.workspace.createFileSystemWatcher) + await skillsManager.dispose() + createWatcher.mockClear() + + await skillsManager.initialize() + + expect(createWatcher).not.toHaveBeenCalled() + }) + + it("rejects a queued write once the manager is disposed", async () => { + await skillsManager.dispose() + mockWriteFile.mockResolvedValue(undefined) + mockUnlink.mockResolvedValue(undefined) + + await expect( + skillsManager["writeSkillFileAtomically"](p(globalSkillsDir, "SKILL.md"), "content"), + ).rejects.toThrow("SkillsManager is disposed") + expect(mockWriteFile).not.toHaveBeenCalled() + expect(mockRename).not.toHaveBeenCalled() + }) + + it("never runs a queued directory operation after the manager is disposed", async () => { + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + let firstStarted = false + let secondRan = false + + const first = skillsManager["withDirectoryLock"](globalSkillsDir, async () => { + firstStarted = true + await firstGate + }) + await vi.waitFor(() => expect(firstStarted).toBe(true)) + + // The second operation is queued behind the first and must be rejected after disposal + // without running its callback. + const second = skillsManager["withDirectoryLock"](globalSkillsDir, async () => { + secondRan = true + }) + await skillsManager.dispose() + releaseFirst() + + await first + await expect(second).rejects.toThrow("SkillsManager is disposed") + expect(secondRan).toBe(false) + }) + + it("removes tracked temp sidecars on dispose", async () => { + mockUnlink.mockResolvedValue(undefined) + skillsManager["activeTempFiles"].add("/mock/skills/foo/.SKILL.md.123.abc.tmp") + + await skillsManager.dispose() + + expect(mockUnlink).toHaveBeenCalledWith("/mock/skills/foo/.SKILL.md.123.abc.tmp") + expect(skillsManager["activeTempFiles"].size).toBe(0) + }) }) 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/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 85d6919db4..c44f2e2c66 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -438,4 +438,45 @@ export const stories: Record = { ) }, + + "skills-settings-diagnostics": async () => { + const [{ AppProviders }, { SkillsSettings }] = await Promise.all([ + import("../AppProviders"), + import("@/components/settings/SkillsSettings"), + ]) + // Deterministic diagnostics: one project skill with a YAML location, one global + // skill without position data, so the alert's path/location/message rendering is + // fully covered by the screenshot. + const skills = [ + { + name: "diagnostics-demo", + description: "Demo skill that loads successfully next to the broken ones", + path: "/workspace/.roo/skills/diagnostics-demo/SKILL.md", + source: "project" as const, + }, + ] + const skillDiagnostics = [ + { + path: "/workspace/.roo/skills/broken-skill/SKILL.md", + source: "project" as const, + message: "Invalid YAML frontmatter: unexpected end of the stream", + line: 3, + column: 12, + }, + { + path: "/home/user/.roo/skills/legacy-mode/SKILL.md", + source: "global" as const, + message: "Missing required 'description' field", + }, + ] + return ( + +
+ +
+
+ ) + }, } 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 */}