mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
a510223a09
commit
343d5e9dcb
11 changed files with 1162 additions and 2 deletions
|
|
@ -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
src/core/prompts/sections/skills.ts
Normal file
71
src/core/prompts/sections/skills.ts
Normal 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
|
||||
`
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3538,6 +3538,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
},
|
||||
undefined, // todoList
|
||||
this.api.getModel().id,
|
||||
provider.getSkillsManager(),
|
||||
)
|
||||
})()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ function makeProviderStub() {
|
|||
rooIgnoreController: { getInstructions: () => undefined },
|
||||
}),
|
||||
getMcpHub: () => undefined,
|
||||
getSkillsManager: () => undefined,
|
||||
// State must enable browser tool and provide apiConfiguration
|
||||
getState: async () => ({
|
||||
apiConfiguration: {
|
||||
|
|
|
|||
|
|
@ -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
src/services/skills/SkillsManager.ts
Normal file
329
src/services/skills/SkillsManager.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
715
src/services/skills/__tests__/SkillsManager.spec.ts
Normal file
715
src/services/skills/__tests__/SkillsManager.spec.ts
Normal 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
18
src/shared/skills.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue