Преглед изворни кода

Add support for skills (#10335)

* Add support for skills

* fix: use type-only import for ClineProvider and relative paths in skills section

---------

Co-authored-by: Roo Code <[email protected]>
Matt Rubens пре 1 месец
родитељ
комит
343d5e9dcb

+ 1 - 0
src/core/prompts/sections/index.ts

@@ -8,3 +8,4 @@ export { getToolUseGuidelinesSection } from "./tool-use-guidelines"
 export { getCapabilitiesSection } from "./capabilities"
 export { getModesSection } from "./modes"
 export { markdownFormattingSection } from "./markdown-formatting"
+export { getSkillsSection } from "./skills"

+ 71 - 0
src/core/prompts/sections/skills.ts

@@ -0,0 +1,71 @@
+import { SkillsManager, SkillMetadata } from "../../../services/skills/SkillsManager"
+
+/**
+ * Get a display-friendly relative path for a skill.
+ * Converts absolute paths to relative paths to avoid leaking sensitive filesystem info.
+ *
+ * @param skill - The skill metadata
+ * @returns A relative path like ".roo/skills/name/SKILL.md" or "~/.roo/skills/name/SKILL.md"
+ */
+function getDisplayPath(skill: SkillMetadata): string {
+	const basePath = skill.source === "project" ? ".roo" : "~/.roo"
+	const skillsDir = skill.mode ? `skills-${skill.mode}` : "skills"
+	return `${basePath}/${skillsDir}/${skill.name}/SKILL.md`
+}
+
+/**
+ * Generate the skills section for the system prompt.
+ * Only includes skills relevant to the current mode.
+ * Format matches the modes section style.
+ *
+ * @param skillsManager - The SkillsManager instance
+ * @param currentMode - The current mode slug (e.g., 'code', 'architect')
+ */
+export async function getSkillsSection(
+	skillsManager: SkillsManager | undefined,
+	currentMode: string | undefined,
+): Promise<string> {
+	if (!skillsManager || !currentMode) return ""
+
+	// Get skills filtered by current mode (with override resolution)
+	const skills = skillsManager.getSkillsForMode(currentMode)
+	if (skills.length === 0) return ""
+
+	// Separate generic and mode-specific skills for display
+	const genericSkills = skills.filter((s) => !s.mode)
+	const modeSpecificSkills = skills.filter((s) => s.mode === currentMode)
+
+	let skillsList = ""
+
+	if (modeSpecificSkills.length > 0) {
+		skillsList += modeSpecificSkills
+			.map(
+				(skill) =>
+					`  * "${skill.name}" skill (${currentMode} mode) - ${skill.description} [${getDisplayPath(skill)}]`,
+			)
+			.join("\n")
+	}
+
+	if (genericSkills.length > 0) {
+		if (skillsList) skillsList += "\n"
+		skillsList += genericSkills
+			.map((skill) => `  * "${skill.name}" skill - ${skill.description} [${getDisplayPath(skill)}]`)
+			.join("\n")
+	}
+
+	return `====
+
+AVAILABLE SKILLS
+
+Skills are pre-packaged instructions for specific tasks. When a user request matches a skill description, read the full SKILL.md file to get detailed instructions.
+
+- These are the currently available skills for "${currentMode}" mode:
+${skillsList}
+
+To use a skill:
+1. Identify which skill matches the user's request based on the description
+2. Use read_file to load the full SKILL.md file from the path shown in brackets
+3. Follow the instructions in the skill file
+4. Access any bundled files (scripts, references, assets) as needed
+`
+}

+ 8 - 2
src/core/prompts/system.ts

@@ -18,6 +18,7 @@ import { isEmpty } from "../../utils/object"
 
 import { McpHub } from "../../services/mcp/McpHub"
 import { CodeIndexManager } from "../../services/code-index/manager"
+import { SkillsManager } from "../../services/skills/SkillsManager"
 
 import { PromptVariables, loadSystemPromptFile } from "./sections/custom-system-prompt"
 
@@ -34,6 +35,7 @@ import {
 	getModesSection,
 	addCustomInstructions,
 	markdownFormattingSection,
+	getSkillsSection,
 } from "./sections"
 
 // Helper function to get prompt component, filtering out empty objects
@@ -69,6 +71,7 @@ async function generatePrompt(
 	settings?: SystemPromptSettings,
 	todoList?: TodoItem[],
 	modelId?: string,
+	skillsManager?: SkillsManager,
 ): Promise<string> {
 	if (!context) {
 		throw new Error("Extension context is required for generating system prompt")
@@ -91,7 +94,7 @@ async function generatePrompt(
 	// Determine the effective protocol (defaults to 'xml')
 	const effectiveProtocol = getEffectiveProtocol(settings?.toolProtocol)
 
-	const [modesSection, mcpServersSection] = await Promise.all([
+	const [modesSection, mcpServersSection, skillsSection] = await Promise.all([
 		getModesSection(context),
 		shouldIncludeMcp
 			? getMcpServersSection(
@@ -101,6 +104,7 @@ async function generatePrompt(
 					!isNativeProtocol(effectiveProtocol),
 				)
 			: Promise.resolve(""),
+		getSkillsSection(skillsManager, mode as string),
 	])
 
 	// Build tools catalog section only for XML protocol
@@ -147,7 +151,7 @@ ${mcpServersSection}
 ${getCapabilitiesSection(cwd, shouldIncludeMcp ? mcpHub : undefined)}
 
 ${modesSection}
-
+${skillsSection ? `\n${skillsSection}` : ""}
 ${getRulesSection(cwd, settings)}
 
 ${getSystemInfoSection(cwd)}
@@ -183,6 +187,7 @@ export const SYSTEM_PROMPT = async (
 	settings?: SystemPromptSettings,
 	todoList?: TodoItem[],
 	modelId?: string,
+	skillsManager?: SkillsManager,
 ): Promise<string> => {
 	if (!context) {
 		throw new Error("Extension context is required for generating system prompt")
@@ -255,5 +260,6 @@ ${customInstructions}`
 		settings,
 		todoList,
 		modelId,
+		skillsManager,
 	)
 }

+ 1 - 0
src/core/task/Task.ts

@@ -3538,6 +3538,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 				},
 				undefined, // todoList
 				this.api.getModel().id,
+				provider.getSkillsManager(),
 			)
 		})()
 	}

+ 1 - 0
src/core/task/__tests__/Task.spec.ts

@@ -976,6 +976,7 @@ describe("Cline", () => {
 						apiConfiguration: mockApiConfig,
 					}),
 					getMcpHub: vi.fn().mockReturnValue(undefined),
+					getSkillsManager: vi.fn().mockReturnValue(undefined),
 					say: vi.fn(),
 					postStateToWebview: vi.fn().mockResolvedValue(undefined),
 					postMessageToWebview: vi.fn().mockResolvedValue(undefined),

+ 14 - 0
src/core/webview/ClineProvider.ts

@@ -71,6 +71,7 @@ import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckp
 import { CodeIndexManager } from "../../services/code-index/manager"
 import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager"
 import { MdmService } from "../../services/mdm/MdmService"
+import { SkillsManager } from "../../services/skills/SkillsManager"
 
 import { fileExistsAtPath } from "../../utils/fs"
 import { setTtsEnabled, setTtsSpeed } from "../../utils/tts"
@@ -137,6 +138,7 @@ export class ClineProvider
 	private codeIndexManager?: CodeIndexManager
 	private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class
 	protected mcpHub?: McpHub // Change from private to protected
+	protected skillsManager?: SkillsManager
 	private marketplaceManager: MarketplaceManager
 	private mdmService?: MdmService
 	private taskCreationCallback: (task: Task) => void
@@ -197,6 +199,12 @@ export class ClineProvider
 				this.log(`Failed to initialize MCP Hub: ${error}`)
 			})
 
+		// Initialize Skills Manager for skill discovery
+		this.skillsManager = new SkillsManager(this)
+		this.skillsManager.initialize().catch((error) => {
+			this.log(`Failed to initialize Skills Manager: ${error}`)
+		})
+
 		this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager)
 
 		// Forward <most> task events to the provider.
@@ -603,6 +611,8 @@ export class ClineProvider
 		this._workspaceTracker = undefined
 		await this.mcpHub?.unregisterClient()
 		this.mcpHub = undefined
+		await this.skillsManager?.dispose()
+		this.skillsManager = undefined
 		this.marketplaceManager?.cleanup()
 		this.customModesManager?.dispose()
 		this.log("Disposed all disposables")
@@ -2440,6 +2450,10 @@ export class ClineProvider
 		return this.mcpHub
 	}
 
+	public getSkillsManager(): SkillsManager | undefined {
+		return this.skillsManager
+	}
+
 	/**
 	 * Check if the current state is compliant with MDM policy
 	 * @returns true if compliant or no MDM policy exists, false if MDM policy exists and user is non-compliant

+ 1 - 0
src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts

@@ -49,6 +49,7 @@ function makeProviderStub() {
 			rooIgnoreController: { getInstructions: () => undefined },
 		}),
 		getMcpHub: () => undefined,
+		getSkillsManager: () => undefined,
 		// State must enable browser tool and provide apiConfiguration
 		getState: async () => ({
 			apiConfiguration: {

+ 3 - 0
src/core/webview/generateSystemPrompt.ts

@@ -99,6 +99,9 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web
 			toolProtocol,
 			isStealthModel: modelInfo?.isStealthModel,
 		},
+		undefined, // todoList
+		undefined, // modelId
+		provider.getSkillsManager(),
 	)
 
 	return systemPrompt

+ 329 - 0
src/services/skills/SkillsManager.ts

@@ -0,0 +1,329 @@
+import * as fs from "fs/promises"
+import * as path from "path"
+import * as vscode from "vscode"
+import matter from "gray-matter"
+
+import type { ClineProvider } from "../../core/webview/ClineProvider"
+import { getGlobalRooDirectory } from "../roo-config"
+import { directoryExists, fileExists } from "../roo-config"
+import { SkillMetadata, SkillContent } from "../../shared/skills"
+import { modes, getAllModes } from "../../shared/modes"
+
+// Re-export for convenience
+export type { SkillMetadata, SkillContent }
+
+export class SkillsManager {
+	private skills: Map<string, SkillMetadata> = new Map()
+	private providerRef: WeakRef<ClineProvider>
+	private disposables: vscode.Disposable[] = []
+	private isDisposed = false
+
+	constructor(provider: ClineProvider) {
+		this.providerRef = new WeakRef(provider)
+	}
+
+	async initialize(): Promise<void> {
+		await this.discoverSkills()
+		await this.setupFileWatchers()
+	}
+
+	/**
+	 * Discover all skills from global and project directories.
+	 * Supports both generic skills (skills/) and mode-specific skills (skills-{mode}/).
+	 * Also supports symlinks:
+	 * - .roo/skills can be a symlink to a directory containing skill subdirectories
+	 * - .roo/skills/[dirname] can be a symlink to a skill directory
+	 */
+	async discoverSkills(): Promise<void> {
+		this.skills.clear()
+		const skillsDirs = await this.getSkillsDirectories()
+
+		for (const { dir, source, mode } of skillsDirs) {
+			await this.scanSkillsDirectory(dir, source, mode)
+		}
+	}
+
+	/**
+	 * Scan a skills directory for skill subdirectories.
+	 * Handles two symlink cases:
+	 * 1. The skills directory itself is a symlink (resolved by directoryExists using realpath)
+	 * 2. Individual skill subdirectories are symlinks
+	 */
+	private async scanSkillsDirectory(dirPath: string, source: "global" | "project", mode?: string): Promise<void> {
+		if (!(await directoryExists(dirPath))) {
+			return
+		}
+
+		try {
+			// Get the real path (resolves if dirPath is a symlink)
+			const realDirPath = await fs.realpath(dirPath)
+
+			// Read directory entries
+			const entries = await fs.readdir(realDirPath)
+
+			for (const entryName of entries) {
+				const entryPath = path.join(realDirPath, entryName)
+
+				// Check if this entry is a directory (follows symlinks automatically)
+				const stats = await fs.stat(entryPath).catch(() => null)
+				if (!stats?.isDirectory()) continue
+
+				// Load skill metadata - the skill name comes from the entry name (symlink name if symlinked)
+				await this.loadSkillMetadata(entryPath, source, mode, entryName)
+			}
+		} catch {
+			// Directory doesn't exist or can't be read - this is fine
+		}
+	}
+
+	/**
+	 * Load skill metadata from a skill directory.
+	 * @param skillDir - The resolved path to the skill directory (target of symlink if symlinked)
+	 * @param source - Whether this is a global or project skill
+	 * @param mode - The mode this skill is specific to (undefined for generic skills)
+	 * @param skillName - The skill name (from symlink name if symlinked, otherwise from directory name)
+	 */
+	private async loadSkillMetadata(
+		skillDir: string,
+		source: "global" | "project",
+		mode?: string,
+		skillName?: string,
+	): Promise<void> {
+		const skillMdPath = path.join(skillDir, "SKILL.md")
+		if (!(await fileExists(skillMdPath))) return
+
+		try {
+			const fileContent = await fs.readFile(skillMdPath, "utf-8")
+
+			// Use gray-matter to parse frontmatter
+			const { data: frontmatter, content: body } = matter(fileContent)
+
+			// Validate required fields (only name and description for now)
+			if (!frontmatter.name || typeof frontmatter.name !== "string") {
+				console.error(`Skill at ${skillDir} is missing required 'name' field`)
+				return
+			}
+			if (!frontmatter.description || typeof frontmatter.description !== "string") {
+				console.error(`Skill at ${skillDir} is missing required 'description' field`)
+				return
+			}
+
+			// Validate that frontmatter name matches the skill name (directory name or symlink name)
+			// Per the Agent Skills spec: "name field must match the parent directory name"
+			const effectiveSkillName = skillName || path.basename(skillDir)
+			if (frontmatter.name !== effectiveSkillName) {
+				console.error(`Skill name "${frontmatter.name}" doesn't match directory "${effectiveSkillName}"`)
+				return
+			}
+
+			// Create unique key combining name, source, and mode for override resolution
+			const skillKey = this.getSkillKey(effectiveSkillName, source, mode)
+
+			this.skills.set(skillKey, {
+				name: effectiveSkillName,
+				description: frontmatter.description,
+				path: skillMdPath,
+				source,
+				mode, // undefined for generic skills, string for mode-specific
+			})
+		} catch (error) {
+			console.error(`Failed to load skill at ${skillDir}:`, error)
+		}
+	}
+
+	/**
+	 * Get skills available for the current mode.
+	 * Resolves overrides: project > global, mode-specific > generic.
+	 *
+	 * @param currentMode - The current mode slug (e.g., 'code', 'architect')
+	 */
+	getSkillsForMode(currentMode: string): SkillMetadata[] {
+		const resolvedSkills = new Map<string, SkillMetadata>()
+
+		for (const skill of this.skills.values()) {
+			// Skip mode-specific skills that don't match current mode
+			if (skill.mode && skill.mode !== currentMode) continue
+
+			const existingSkill = resolvedSkills.get(skill.name)
+
+			if (!existingSkill) {
+				resolvedSkills.set(skill.name, skill)
+				continue
+			}
+
+			// Apply override rules
+			const shouldOverride = this.shouldOverrideSkill(existingSkill, skill)
+			if (shouldOverride) {
+				resolvedSkills.set(skill.name, skill)
+			}
+		}
+
+		return Array.from(resolvedSkills.values())
+	}
+
+	/**
+	 * Determine if newSkill should override existingSkill based on priority rules.
+	 * Priority: project > global, mode-specific > generic
+	 */
+	private shouldOverrideSkill(existing: SkillMetadata, newSkill: SkillMetadata): boolean {
+		// Project always overrides global
+		if (newSkill.source === "project" && existing.source === "global") return true
+		if (newSkill.source === "global" && existing.source === "project") return false
+
+		// Same source: mode-specific overrides generic
+		if (newSkill.mode && !existing.mode) return true
+		if (!newSkill.mode && existing.mode) return false
+
+		// Same source and same mode-specificity: keep existing (first wins)
+		return false
+	}
+
+	/**
+	 * Get all skills (for UI display, debugging, etc.)
+	 */
+	getAllSkills(): SkillMetadata[] {
+		return Array.from(this.skills.values())
+	}
+
+	async getSkillContent(name: string, currentMode?: string): Promise<SkillContent | null> {
+		// If mode is provided, try to find the best matching skill
+		let skill: SkillMetadata | undefined
+
+		if (currentMode) {
+			const modeSkills = this.getSkillsForMode(currentMode)
+			skill = modeSkills.find((s) => s.name === name)
+		} else {
+			// Fall back to any skill with this name
+			skill = Array.from(this.skills.values()).find((s) => s.name === name)
+		}
+
+		if (!skill) return null
+
+		const fileContent = await fs.readFile(skill.path, "utf-8")
+		const { content: body } = matter(fileContent)
+
+		return {
+			...skill,
+			instructions: body.trim(),
+		}
+	}
+
+	/**
+	 * Get all skills directories to scan, including mode-specific directories.
+	 */
+	private async getSkillsDirectories(): Promise<
+		Array<{
+			dir: string
+			source: "global" | "project"
+			mode?: string
+		}>
+	> {
+		const dirs: Array<{ dir: string; source: "global" | "project"; mode?: string }> = []
+		const globalRooDir = getGlobalRooDirectory()
+		const provider = this.providerRef.deref()
+		const projectRooDir = provider?.cwd ? path.join(provider.cwd, ".roo") : null
+
+		// Get list of modes to check for mode-specific skills
+		const modesList = await this.getAvailableModes()
+
+		// Global directories
+		dirs.push({ dir: path.join(globalRooDir, "skills"), source: "global" })
+		for (const mode of modesList) {
+			dirs.push({ dir: path.join(globalRooDir, `skills-${mode}`), source: "global", mode })
+		}
+
+		// Project directories
+		if (projectRooDir) {
+			dirs.push({ dir: path.join(projectRooDir, "skills"), source: "project" })
+			for (const mode of modesList) {
+				dirs.push({ dir: path.join(projectRooDir, `skills-${mode}`), source: "project", mode })
+			}
+		}
+
+		return dirs
+	}
+
+	/**
+	 * Get list of available modes (built-in + custom)
+	 */
+	private async getAvailableModes(): Promise<string[]> {
+		const provider = this.providerRef.deref()
+		const builtInModeSlugs = modes.map((m) => m.slug)
+
+		if (!provider) {
+			return builtInModeSlugs
+		}
+
+		try {
+			const customModes = await provider.customModesManager.getCustomModes()
+			const allModes = getAllModes(customModes)
+			return allModes.map((m) => m.slug)
+		} catch {
+			return builtInModeSlugs
+		}
+	}
+
+	private getSkillKey(name: string, source: string, mode?: string): string {
+		return `${source}:${mode || "generic"}:${name}`
+	}
+
+	private async setupFileWatchers(): Promise<void> {
+		// Skip if test environment is detected or VSCode APIs are not available
+		if (process.env.NODE_ENV === "test" || !vscode.workspace.createFileSystemWatcher) {
+			return
+		}
+
+		const provider = this.providerRef.deref()
+		if (!provider?.cwd) return
+
+		// Watch for changes in skills directories
+		const globalSkillsDir = path.join(getGlobalRooDirectory(), "skills")
+		const projectSkillsDir = path.join(provider.cwd, ".roo", "skills")
+
+		// Watch global skills directory
+		this.watchDirectory(globalSkillsDir)
+
+		// Watch project skills directory
+		this.watchDirectory(projectSkillsDir)
+
+		// Watch mode-specific directories for all available modes
+		const modesList = await this.getAvailableModes()
+		for (const mode of modesList) {
+			this.watchDirectory(path.join(getGlobalRooDirectory(), `skills-${mode}`))
+			this.watchDirectory(path.join(provider.cwd, ".roo", `skills-${mode}`))
+		}
+	}
+
+	private watchDirectory(dirPath: string): void {
+		if (process.env.NODE_ENV === "test" || !vscode.workspace.createFileSystemWatcher) {
+			return
+		}
+
+		const pattern = new vscode.RelativePattern(dirPath, "**/SKILL.md")
+		const watcher = vscode.workspace.createFileSystemWatcher(pattern)
+
+		watcher.onDidChange(async (uri) => {
+			if (this.isDisposed) return
+			await this.discoverSkills()
+		})
+
+		watcher.onDidCreate(async (uri) => {
+			if (this.isDisposed) return
+			await this.discoverSkills()
+		})
+
+		watcher.onDidDelete(async (uri) => {
+			if (this.isDisposed) return
+			await this.discoverSkills()
+		})
+
+		this.disposables.push(watcher)
+	}
+
+	async dispose(): Promise<void> {
+		this.isDisposed = true
+		this.disposables.forEach((d) => d.dispose())
+		this.disposables = []
+		this.skills.clear()
+	}
+}

+ 715 - 0
src/services/skills/__tests__/SkillsManager.spec.ts

@@ -0,0 +1,715 @@
+import * as path from "path"
+
+// Use vi.hoisted to ensure mocks are available during hoisting
+const { mockStat, mockReadFile, mockReaddir, mockHomedir, mockDirectoryExists, mockFileExists, mockRealpath } =
+	vi.hoisted(() => ({
+		mockStat: vi.fn(),
+		mockReadFile: vi.fn(),
+		mockReaddir: vi.fn(),
+		mockHomedir: vi.fn(),
+		mockDirectoryExists: vi.fn(),
+		mockFileExists: vi.fn(),
+		mockRealpath: vi.fn(),
+	}))
+
+// Platform-agnostic test paths
+// Use forward slashes for consistency, then normalize with path.normalize
+const HOME_DIR = process.platform === "win32" ? "C:\\Users\\testuser" : "/home/user"
+const PROJECT_DIR = process.platform === "win32" ? "C:\\test\\project" : "/test/project"
+const SHARED_DIR = process.platform === "win32" ? "C:\\shared\\skills" : "/shared/skills"
+
+// Helper to create platform-appropriate paths
+const p = (...segments: string[]) => path.join(...segments)
+
+// Mock fs/promises module
+vi.mock("fs/promises", () => ({
+	default: {
+		stat: mockStat,
+		readFile: mockReadFile,
+		readdir: mockReaddir,
+		realpath: mockRealpath,
+	},
+	stat: mockStat,
+	readFile: mockReadFile,
+	readdir: mockReaddir,
+	realpath: mockRealpath,
+}))
+
+// Mock os module
+vi.mock("os", () => ({
+	homedir: mockHomedir,
+}))
+
+// Mock vscode
+vi.mock("vscode", () => ({
+	workspace: {
+		createFileSystemWatcher: vi.fn(() => ({
+			onDidChange: vi.fn(),
+			onDidCreate: vi.fn(),
+			onDidDelete: vi.fn(),
+			dispose: vi.fn(),
+		})),
+	},
+	RelativePattern: vi.fn(),
+}))
+
+// Global roo directory - computed once
+const GLOBAL_ROO_DIR = p(HOME_DIR, ".roo")
+
+// Mock roo-config
+vi.mock("../../roo-config", () => ({
+	getGlobalRooDirectory: () => GLOBAL_ROO_DIR,
+	directoryExists: mockDirectoryExists,
+	fileExists: mockFileExists,
+}))
+
+import { SkillsManager } from "../SkillsManager"
+import { ClineProvider } from "../../../core/webview/ClineProvider"
+
+describe("SkillsManager", () => {
+	let skillsManager: SkillsManager
+	let mockProvider: Partial<ClineProvider>
+
+	// Pre-computed paths for tests
+	const globalSkillsDir = p(GLOBAL_ROO_DIR, "skills")
+	const globalSkillsCodeDir = p(GLOBAL_ROO_DIR, "skills-code")
+	const globalSkillsArchitectDir = p(GLOBAL_ROO_DIR, "skills-architect")
+	const projectRooDir = p(PROJECT_DIR, ".roo")
+	const projectSkillsDir = p(projectRooDir, "skills")
+
+	beforeEach(() => {
+		vi.clearAllMocks()
+		mockHomedir.mockReturnValue(HOME_DIR)
+
+		// Create mock provider
+		mockProvider = {
+			cwd: PROJECT_DIR,
+			customModesManager: {
+				getCustomModes: vi.fn().mockResolvedValue([]),
+			} as any,
+		}
+
+		skillsManager = new SkillsManager(mockProvider as ClineProvider)
+	})
+
+	afterEach(async () => {
+		await skillsManager.dispose()
+	})
+
+	describe("discoverSkills", () => {
+		it("should discover skills from global directory", async () => {
+			const pdfSkillDir = p(globalSkillsDir, "pdf-processing")
+			const pdfSkillMd = p(pdfSkillDir, "SKILL.md")
+
+			// Setup mocks
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return dir === globalSkillsDir
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === globalSkillsDir) {
+					return ["pdf-processing"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === pdfSkillDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockImplementation(async (file: string) => {
+				return file === pdfSkillMd
+			})
+
+			mockReadFile.mockImplementation(async (file: string) => {
+				if (file === pdfSkillMd) {
+					return `---
+name: pdf-processing
+description: Extract text and tables from PDF files
+---
+
+# PDF Processing
+
+Instructions here...`
+				}
+				throw new Error("File not found")
+			})
+
+			await skillsManager.discoverSkills()
+
+			const skills = skillsManager.getAllSkills()
+			expect(skills).toHaveLength(1)
+			expect(skills[0].name).toBe("pdf-processing")
+			expect(skills[0].description).toBe("Extract text and tables from PDF files")
+			expect(skills[0].source).toBe("global")
+		})
+
+		it("should discover skills from project directory", async () => {
+			const codeReviewDir = p(projectSkillsDir, "code-review")
+			const codeReviewMd = p(codeReviewDir, "SKILL.md")
+
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return dir === projectSkillsDir
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === projectSkillsDir) {
+					return ["code-review"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === codeReviewDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockImplementation(async (file: string) => {
+				return file === codeReviewMd
+			})
+
+			mockReadFile.mockImplementation(async (file: string) => {
+				if (file === codeReviewMd) {
+					return `---
+name: code-review
+description: Review code for best practices
+---
+
+# Code Review
+
+Instructions here...`
+				}
+				throw new Error("File not found")
+			})
+
+			await skillsManager.discoverSkills()
+
+			const skills = skillsManager.getAllSkills()
+			expect(skills).toHaveLength(1)
+			expect(skills[0].name).toBe("code-review")
+			expect(skills[0].source).toBe("project")
+		})
+
+		it("should discover mode-specific skills", async () => {
+			const refactoringDir = p(globalSkillsCodeDir, "refactoring")
+			const refactoringMd = p(refactoringDir, "SKILL.md")
+
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return dir === globalSkillsCodeDir
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === globalSkillsCodeDir) {
+					return ["refactoring"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === refactoringDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockImplementation(async (file: string) => {
+				return file === refactoringMd
+			})
+
+			mockReadFile.mockImplementation(async (file: string) => {
+				if (file === refactoringMd) {
+					return `---
+name: refactoring
+description: Refactor code for better maintainability
+---
+
+# Refactoring
+
+Instructions here...`
+				}
+				throw new Error("File not found")
+			})
+
+			await skillsManager.discoverSkills()
+
+			const skills = skillsManager.getAllSkills()
+			expect(skills).toHaveLength(1)
+			expect(skills[0].name).toBe("refactoring")
+			expect(skills[0].mode).toBe("code")
+		})
+
+		it("should skip skills with missing required fields", async () => {
+			const invalidSkillDir = p(globalSkillsDir, "invalid-skill")
+			const invalidSkillMd = p(invalidSkillDir, "SKILL.md")
+
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return dir === globalSkillsDir
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === globalSkillsDir) {
+					return ["invalid-skill"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === invalidSkillDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockImplementation(async (file: string) => {
+				return file === invalidSkillMd
+			})
+
+			mockReadFile.mockImplementation(async (file: string) => {
+				if (file === invalidSkillMd) {
+					return `---
+name: invalid-skill
+---
+
+# Missing description field`
+				}
+				throw new Error("File not found")
+			})
+
+			await skillsManager.discoverSkills()
+
+			const skills = skillsManager.getAllSkills()
+			expect(skills).toHaveLength(0)
+		})
+
+		it("should skip skills where name doesn't match directory", async () => {
+			const mySkillDir = p(globalSkillsDir, "my-skill")
+			const mySkillMd = p(mySkillDir, "SKILL.md")
+
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return dir === globalSkillsDir
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === globalSkillsDir) {
+					return ["my-skill"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === mySkillDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockImplementation(async (file: string) => {
+				return file === mySkillMd
+			})
+
+			mockReadFile.mockImplementation(async (file: string) => {
+				if (file === mySkillMd) {
+					return `---
+name: different-name
+description: Name doesn't match directory
+---
+
+# Mismatched name`
+				}
+				throw new Error("File not found")
+			})
+
+			await skillsManager.discoverSkills()
+
+			const skills = skillsManager.getAllSkills()
+			expect(skills).toHaveLength(0)
+		})
+
+		it("should handle symlinked skills directory", async () => {
+			const sharedSkillDir = p(SHARED_DIR, "shared-skill")
+			const sharedSkillMd = p(sharedSkillDir, "SKILL.md")
+
+			// Simulate .roo/skills being a symlink to /shared/skills
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return dir === globalSkillsDir
+			})
+
+			// realpath resolves the symlink to the actual directory
+			mockRealpath.mockImplementation(async (pathArg: string) => {
+				if (pathArg === globalSkillsDir) {
+					return SHARED_DIR
+				}
+				return pathArg
+			})
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === SHARED_DIR) {
+					return ["shared-skill"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === sharedSkillDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockImplementation(async (file: string) => {
+				return file === sharedSkillMd
+			})
+
+			mockReadFile.mockImplementation(async (file: string) => {
+				if (file === sharedSkillMd) {
+					return `---
+name: shared-skill
+description: A skill from a symlinked directory
+---
+
+# Shared Skill
+
+Instructions here...`
+				}
+				throw new Error("File not found")
+			})
+
+			await skillsManager.discoverSkills()
+
+			const skills = skillsManager.getAllSkills()
+			expect(skills).toHaveLength(1)
+			expect(skills[0].name).toBe("shared-skill")
+			expect(skills[0].source).toBe("global")
+		})
+
+		it("should handle symlinked skill subdirectory", async () => {
+			const myAliasDir = p(globalSkillsDir, "my-alias")
+			const myAliasMd = p(myAliasDir, "SKILL.md")
+
+			// Simulate .roo/skills/my-alias being a symlink to /external/actual-skill
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return dir === globalSkillsDir
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === globalSkillsDir) {
+					return ["my-alias"]
+				}
+				return []
+			})
+
+			// fs.stat follows symlinks, so it returns the target directory info
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === myAliasDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockImplementation(async (file: string) => {
+				return file === myAliasMd
+			})
+
+			// The skill name in frontmatter must match the symlink name (my-alias)
+			mockReadFile.mockImplementation(async (file: string) => {
+				if (file === myAliasMd) {
+					return `---
+name: my-alias
+description: A skill accessed via symlink
+---
+
+# My Alias Skill
+
+Instructions here...`
+				}
+				throw new Error("File not found")
+			})
+
+			await skillsManager.discoverSkills()
+
+			const skills = skillsManager.getAllSkills()
+			expect(skills).toHaveLength(1)
+			expect(skills[0].name).toBe("my-alias")
+			expect(skills[0].source).toBe("global")
+		})
+	})
+
+	describe("getSkillsForMode", () => {
+		it("should return skills filtered by mode", async () => {
+			const genericSkillDir = p(globalSkillsDir, "generic-skill")
+			const codeSkillDir = p(globalSkillsCodeDir, "code-skill")
+
+			// Setup skills for testing
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return [globalSkillsDir, globalSkillsCodeDir].includes(dir)
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === globalSkillsDir) {
+					return ["generic-skill"]
+				}
+				if (dir === globalSkillsCodeDir) {
+					return ["code-skill"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === genericSkillDir || pathArg === codeSkillDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockResolvedValue(true)
+
+			mockReadFile.mockImplementation(async (file: string) => {
+				if (file.includes("generic-skill")) {
+					return `---
+name: generic-skill
+description: Generic skill
+---
+Instructions`
+				}
+				if (file.includes("code-skill")) {
+					return `---
+name: code-skill
+description: Code skill
+---
+Instructions`
+				}
+				throw new Error("File not found")
+			})
+
+			await skillsManager.discoverSkills()
+
+			const codeSkills = skillsManager.getSkillsForMode("code")
+
+			// Should include both generic and code-specific skills
+			expect(codeSkills.length).toBe(2)
+			expect(codeSkills.map((s) => s.name)).toContain("generic-skill")
+			expect(codeSkills.map((s) => s.name)).toContain("code-skill")
+		})
+
+		it("should apply project > global override", async () => {
+			const globalSharedSkillDir = p(globalSkillsDir, "shared-skill")
+			const projectSharedSkillDir = p(projectSkillsDir, "shared-skill")
+
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return [globalSkillsDir, projectSkillsDir].includes(dir)
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === globalSkillsDir) {
+					return ["shared-skill"]
+				}
+				if (dir === projectSkillsDir) {
+					return ["shared-skill"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === globalSharedSkillDir || pathArg === projectSharedSkillDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockResolvedValue(true)
+
+			mockReadFile.mockResolvedValue(`---
+name: shared-skill
+description: Shared skill
+---
+Instructions`)
+
+			await skillsManager.discoverSkills()
+
+			const skills = skillsManager.getSkillsForMode("code")
+			const sharedSkill = skills.find((s) => s.name === "shared-skill")
+
+			// Project skill should override global
+			expect(sharedSkill?.source).toBe("project")
+		})
+
+		it("should apply mode-specific > generic override", async () => {
+			const genericTestSkillDir = p(globalSkillsDir, "test-skill")
+			const codeTestSkillDir = p(globalSkillsCodeDir, "test-skill")
+
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return [globalSkillsDir, globalSkillsCodeDir].includes(dir)
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === globalSkillsDir) {
+					return ["test-skill"]
+				}
+				if (dir === globalSkillsCodeDir) {
+					return ["test-skill"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === genericTestSkillDir || pathArg === codeTestSkillDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockResolvedValue(true)
+
+			mockReadFile.mockResolvedValue(`---
+name: test-skill
+description: Test skill
+---
+Instructions`)
+
+			await skillsManager.discoverSkills()
+
+			const skills = skillsManager.getSkillsForMode("code")
+			const testSkill = skills.find((s) => s.name === "test-skill")
+
+			// Mode-specific should override generic
+			expect(testSkill?.mode).toBe("code")
+		})
+
+		it("should not include mode-specific skills for other modes", async () => {
+			const architectOnlyDir = p(globalSkillsArchitectDir, "architect-only")
+
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return dir === globalSkillsArchitectDir
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === globalSkillsArchitectDir) {
+					return ["architect-only"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === architectOnlyDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockResolvedValue(true)
+
+			mockReadFile.mockResolvedValue(`---
+name: architect-only
+description: Only for architect mode
+---
+Instructions`)
+
+			await skillsManager.discoverSkills()
+
+			const codeSkills = skillsManager.getSkillsForMode("code")
+			const architectSkill = codeSkills.find((s) => s.name === "architect-only")
+
+			expect(architectSkill).toBeUndefined()
+		})
+	})
+
+	describe("getSkillContent", () => {
+		it("should return full skill content", async () => {
+			const testSkillDir = p(globalSkillsDir, "test-skill")
+			const testSkillMd = p(testSkillDir, "SKILL.md")
+
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return dir === globalSkillsDir
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === globalSkillsDir) {
+					return ["test-skill"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === testSkillDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockImplementation(async (file: string) => {
+				return file === testSkillMd
+			})
+
+			const skillContent = `---
+name: test-skill
+description: A test skill
+---
+
+# Test Skill
+
+## Instructions
+
+1. Do this
+2. Do that`
+
+			mockReadFile.mockResolvedValue(skillContent)
+
+			await skillsManager.discoverSkills()
+
+			const content = await skillsManager.getSkillContent("test-skill")
+
+			expect(content).not.toBeNull()
+			expect(content?.name).toBe("test-skill")
+			expect(content?.instructions).toContain("# Test Skill")
+			expect(content?.instructions).toContain("1. Do this")
+		})
+
+		it("should return null for non-existent skill", async () => {
+			mockDirectoryExists.mockResolvedValue(false)
+			mockRealpath.mockImplementation(async (p: string) => p)
+			mockReaddir.mockResolvedValue([])
+
+			await skillsManager.discoverSkills()
+
+			const content = await skillsManager.getSkillContent("non-existent")
+
+			expect(content).toBeNull()
+		})
+	})
+
+	describe("dispose", () => {
+		it("should clean up resources", async () => {
+			await skillsManager.dispose()
+
+			const skills = skillsManager.getAllSkills()
+			expect(skills).toHaveLength(0)
+		})
+	})
+})

+ 18 - 0
src/shared/skills.ts

@@ -0,0 +1,18 @@
+/**
+ * Skill metadata for discovery (loaded at startup)
+ * Only name and description are required for now
+ */
+export interface SkillMetadata {
+	name: string // Required: skill identifier
+	description: string // Required: when to use this skill
+	path: string // Absolute path to SKILL.md
+	source: "global" | "project" // Where the skill was discovered
+	mode?: string // If set, skill is only available in this mode
+}
+
+/**
+ * Full skill content (loaded on activation)
+ */
+export interface SkillContent extends SkillMetadata {
+	instructions: string // Full markdown body
+}