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 <roomote@roocode.com>
This commit is contained in:
Matt Rubens 2025-12-25 20:28:41 -08:00 committed by GitHub
parent a510223a09
commit 343d5e9dcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1162 additions and 2 deletions

View file

@ -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"

View file

@ -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
`
}

View file

@ -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,
)
}

View file

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

View file

@ -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),

View file

@ -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

View file

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

View file

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

View file

@ -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()
}
}

View file

@ -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
src/shared/skills.ts Normal file
View file

@ -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
}