diff --git a/src/core/prompts/sections/index.ts b/src/core/prompts/sections/index.ts index d06dbbfde1d..b88cddc15ce 100644 --- a/src/core/prompts/sections/index.ts +++ b/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" diff --git a/src/core/prompts/sections/skills.ts b/src/core/prompts/sections/skills.ts new file mode 100644 index 00000000000..999d4f135f6 --- /dev/null +++ b/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 { + 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 +` +} diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index cde80e11e23..040d7039292 100644 --- a/src/core/prompts/system.ts +++ b/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 { 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 => { if (!context) { throw new Error("Extension context is required for generating system prompt") @@ -255,5 +260,6 @@ ${customInstructions}` settings, todoList, modelId, + skillsManager, ) } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 5298dbfb03b..d721438589e 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3538,6 +3538,7 @@ export class Task extends EventEmitter implements TaskLike { }, undefined, // todoList this.api.getModel().id, + provider.getSkillsManager(), ) })() } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 7b2d0f3a36c..74ef1c6a78e 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/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), diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 781c6d8a316..cde79daffed 100644 --- a/src/core/webview/ClineProvider.ts +++ b/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 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") @@ -2443,6 +2453,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 diff --git a/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts index 9b3f94f309b..5aa2ea2c63a 100644 --- a/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts +++ b/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: { diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index e79aa707282..5045daf362c 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/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 diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts new file mode 100644 index 00000000000..267d0c21832 --- /dev/null +++ b/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 = new Map() + private providerRef: WeakRef + private disposables: vscode.Disposable[] = [] + private isDisposed = false + + constructor(provider: ClineProvider) { + this.providerRef = new WeakRef(provider) + } + + async initialize(): Promise { + 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 { + 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 { + 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 { + 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() + + 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 { + // 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 { + 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 { + // 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 { + this.isDisposed = true + this.disposables.forEach((d) => d.dispose()) + this.disposables = [] + this.skills.clear() + } +} diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts new file mode 100644 index 00000000000..e6f00e5aa48 --- /dev/null +++ b/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 + + // 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) + }) + }) +}) diff --git a/src/shared/skills.ts b/src/shared/skills.ts new file mode 100644 index 00000000000..7ed85816aa8 --- /dev/null +++ b/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 +}