Просмотр исходного кода

feat: add support for .agents/skills directory (#11181)

* feat: add support for .agents/skills directory

This change adds support for discovering skills from the .agents/skills
directory, following the Agent Skills convention for sharing skills
across different AI coding tools.

Priority order (later entries override earlier ones):
1. Global ~/.agents/skills (shared across AI coding tools, lowest priority)
2. Project .agents/skills
3. Global ~/.roo/skills (Roo-specific)
4. Project .roo/skills (highest priority)

Changes:
- Add getGlobalAgentsDirectory() and getProjectAgentsDirectoryForCwd()
  functions to roo-config
- Update SkillsManager.getSkillsDirectories() to include .agents/skills
- Update SkillsManager.setupFileWatchers() to watch .agents/skills
- Add tests for new functionality

* fix: clarify skill priority comment to match actual behavior

* fix: clarify skill priority comment to explain Map.set replacement mechanism

---------

Co-authored-by: Roo Code <[email protected]>
roomote[bot] 1 неделя назад
Родитель
Сommit
a266834ee2

+ 23 - 0
src/services/roo-config/__tests__/index.spec.ts

@@ -28,7 +28,9 @@ vi.mock("../../search/file-search", () => ({
 
 import {
 	getGlobalRooDirectory,
+	getGlobalAgentsDirectory,
 	getProjectRooDirectoryForCwd,
+	getProjectAgentsDirectoryForCwd,
 	directoryExists,
 	fileExists,
 	readFileIfExists,
@@ -70,6 +72,27 @@ describe("RooConfigService", () => {
 		})
 	})
 
+	describe("getGlobalAgentsDirectory", () => {
+		it("should return correct path for global .agents directory", () => {
+			const result = getGlobalAgentsDirectory()
+			expect(result).toBe(path.join("/mock/home", ".agents"))
+		})
+
+		it("should handle different home directories", () => {
+			mockHomedir.mockReturnValue("/different/home")
+			const result = getGlobalAgentsDirectory()
+			expect(result).toBe(path.join("/different/home", ".agents"))
+		})
+	})
+
+	describe("getProjectAgentsDirectoryForCwd", () => {
+		it("should return correct path for given cwd", () => {
+			const cwd = "/custom/project/path"
+			const result = getProjectAgentsDirectoryForCwd(cwd)
+			expect(result).toBe(path.join(cwd, ".agents"))
+		})
+	})
+
 	describe("directoryExists", () => {
 		it("should return true for existing directory", async () => {
 			mockStat.mockResolvedValue({ isDirectory: () => true } as any)

+ 44 - 0
src/services/roo-config/index.ts

@@ -28,6 +28,50 @@ export function getGlobalRooDirectory(): string {
 	return path.join(homeDir, ".roo")
 }
 
+/**
+ * Gets the global .agents directory path based on the current platform.
+ * This is a shared directory for agent skills across different AI coding tools.
+ *
+ * @returns The absolute path to the global .agents directory
+ *
+ * @example Platform-specific paths:
+ * ```
+ * // macOS/Linux: ~/.agents/
+ * // Example: /Users/john/.agents
+ *
+ * // Windows: %USERPROFILE%\.agents\
+ * // Example: C:\Users\john\.agents
+ * ```
+ *
+ * @example Usage:
+ * ```typescript
+ * const globalAgentsDir = getGlobalAgentsDirectory()
+ * // Returns: "/Users/john/.agents" (on macOS/Linux)
+ * // Returns: "C:\\Users\\john\\.agents" (on Windows)
+ * ```
+ */
+export function getGlobalAgentsDirectory(): string {
+	const homeDir = os.homedir()
+	return path.join(homeDir, ".agents")
+}
+
+/**
+ * Gets the project-local .agents directory path for a given cwd.
+ * This is a shared directory for agent skills across different AI coding tools.
+ *
+ * @param cwd - Current working directory (project path)
+ * @returns The absolute path to the project-local .agents directory
+ *
+ * @example
+ * ```typescript
+ * const projectAgentsDir = getProjectAgentsDirectoryForCwd('/Users/john/my-project')
+ * // Returns: "/Users/john/my-project/.agents"
+ * ```
+ */
+export function getProjectAgentsDirectoryForCwd(cwd: string): string {
+	return path.join(cwd, ".agents")
+}
+
 /**
  * Gets the project-local .roo directory path for a given cwd
  *

+ 48 - 11
src/services/skills/SkillsManager.ts

@@ -5,7 +5,7 @@ import * as vscode from "vscode"
 import matter from "gray-matter"
 
 import type { ClineProvider } from "../../core/webview/ClineProvider"
-import { getGlobalRooDirectory } from "../roo-config"
+import { getGlobalRooDirectory, getGlobalAgentsDirectory, getProjectAgentsDirectoryForCwd } from "../roo-config"
 import { directoryExists, fileExists } from "../roo-config"
 import { SkillMetadata, SkillContent } from "../../shared/skills"
 import { modes, getAllModes } from "../../shared/modes"
@@ -590,19 +590,44 @@ Add your skill instructions here.
 	> {
 		const dirs: Array<{ dir: string; source: "global" | "project"; mode?: string }> = []
 		const globalRooDir = getGlobalRooDirectory()
+		const globalAgentsDir = getGlobalAgentsDirectory()
 		const provider = this.providerRef.deref()
 		const projectRooDir = provider?.cwd ? path.join(provider.cwd, ".roo") : null
+		const projectAgentsDir = provider?.cwd ? getProjectAgentsDirectoryForCwd(provider.cwd) : null
 
 		// Get list of modes to check for mode-specific skills
 		const modesList = await this.getAvailableModes()
 
-		// Global directories
+		// Priority rules for skills with the same name:
+		// 1. Source level: project > global > built-in (handled by shouldOverrideSkill in getSkillsForMode)
+		// 2. Within the same source level: later-processed directories override earlier ones
+		//    (via Map.set replacement during discovery - same source+mode+name key gets replaced)
+		//
+		// Processing order (later directories override earlier ones at the same source level):
+		// - Global: .agents/skills first, then .roo/skills (so .roo wins)
+		// - Project: .agents/skills first, then .roo/skills (so .roo wins)
+
+		// Global .agents directories (lowest priority - shared across agents)
+		dirs.push({ dir: path.join(globalAgentsDir, "skills"), source: "global" })
+		for (const mode of modesList) {
+			dirs.push({ dir: path.join(globalAgentsDir, `skills-${mode}`), source: "global", mode })
+		}
+
+		// Project .agents directories
+		if (projectAgentsDir) {
+			dirs.push({ dir: path.join(projectAgentsDir, "skills"), source: "project" })
+			for (const mode of modesList) {
+				dirs.push({ dir: path.join(projectAgentsDir, `skills-${mode}`), source: "project", mode })
+			}
+		}
+
+		// Global .roo directories (Roo-specific, higher priority than .agents)
 		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
+		// Project .roo directories (highest priority)
 		if (projectRooDir) {
 			dirs.push({ dir: path.join(projectRooDir, "skills"), source: "project" })
 			for (const mode of modesList) {
@@ -647,20 +672,32 @@ Add your skill instructions here.
 		if (!provider?.cwd) return
 
 		// Watch for changes in skills directories
-		const globalSkillsDir = path.join(getGlobalRooDirectory(), "skills")
-		const projectSkillsDir = path.join(provider.cwd, ".roo", "skills")
+		const globalRooDir = getGlobalRooDirectory()
+		const globalAgentsDir = getGlobalAgentsDirectory()
+		const projectRooDir = path.join(provider.cwd, ".roo")
+		const projectAgentsDir = getProjectAgentsDirectoryForCwd(provider.cwd)
+
+		// Watch global .roo skills directory
+		this.watchDirectory(path.join(globalRooDir, "skills"))
+
+		// Watch global .agents skills directory
+		this.watchDirectory(path.join(globalAgentsDir, "skills"))
 
-		// Watch global skills directory
-		this.watchDirectory(globalSkillsDir)
+		// Watch project .roo skills directory
+		this.watchDirectory(path.join(projectRooDir, "skills"))
 
-		// Watch project skills directory
-		this.watchDirectory(projectSkillsDir)
+		// Watch project .agents skills directory
+		this.watchDirectory(path.join(projectAgentsDir, "skills"))
 
 		// 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}`))
+			// .roo mode-specific
+			this.watchDirectory(path.join(globalRooDir, `skills-${mode}`))
+			this.watchDirectory(path.join(projectRooDir, `skills-${mode}`))
+			// .agents mode-specific
+			this.watchDirectory(path.join(globalAgentsDir, `skills-${mode}`))
+			this.watchDirectory(path.join(projectAgentsDir, `skills-${mode}`))
 		}
 	}
 

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

@@ -82,10 +82,13 @@ vi.mock("vscode", () => ({
 
 // Global roo directory - computed once
 const GLOBAL_ROO_DIR = p(HOME_DIR, ".roo")
+const GLOBAL_AGENTS_DIR = p(HOME_DIR, ".agents")
 
 // Mock roo-config
 vi.mock("../../roo-config", () => ({
 	getGlobalRooDirectory: () => GLOBAL_ROO_DIR,
+	getGlobalAgentsDirectory: () => GLOBAL_AGENTS_DIR,
+	getProjectAgentsDirectoryForCwd: (cwd: string) => p(cwd, ".agents"),
 	directoryExists: mockDirectoryExists,
 	fileExists: mockFileExists,
 }))
@@ -127,6 +130,11 @@ describe("SkillsManager", () => {
 	const globalSkillsArchitectDir = p(GLOBAL_ROO_DIR, "skills-architect")
 	const projectRooDir = p(PROJECT_DIR, ".roo")
 	const projectSkillsDir = p(projectRooDir, "skills")
+	// .agents directory paths
+	const globalAgentsSkillsDir = p(GLOBAL_AGENTS_DIR, "skills")
+	const globalAgentsSkillsCodeDir = p(GLOBAL_AGENTS_DIR, "skills-code")
+	const projectAgentsDir = p(PROJECT_DIR, ".agents")
+	const projectAgentsSkillsDir = p(projectAgentsDir, "skills")
 
 	beforeEach(() => {
 		vi.clearAllMocks()
@@ -615,6 +623,216 @@ Instructions here...`
 			expect(skills[0].name).toBe("my-alias")
 			expect(skills[0].source).toBe("global")
 		})
+
+		it("should discover skills from global .agents directory", async () => {
+			const agentSkillDir = p(globalAgentsSkillsDir, "agent-skill")
+			const agentSkillMd = p(agentSkillDir, "SKILL.md")
+
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return dir === globalAgentsSkillsDir
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === globalAgentsSkillsDir) {
+					return ["agent-skill"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === agentSkillDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockImplementation(async (file: string) => {
+				return file === agentSkillMd
+			})
+
+			mockReadFile.mockImplementation(async (file: string) => {
+				if (file === agentSkillMd) {
+					return `---
+name: agent-skill
+description: A skill from .agents directory shared across AI coding tools
+---
+
+# Agent 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("agent-skill")
+			expect(skills[0].description).toBe("A skill from .agents directory shared across AI coding tools")
+			expect(skills[0].source).toBe("global")
+		})
+
+		it("should discover skills from project .agents directory", async () => {
+			const projectAgentSkillDir = p(projectAgentsSkillsDir, "project-agent-skill")
+			const projectAgentSkillMd = p(projectAgentSkillDir, "SKILL.md")
+
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return dir === projectAgentsSkillsDir
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === projectAgentsSkillsDir) {
+					return ["project-agent-skill"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === projectAgentSkillDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockImplementation(async (file: string) => {
+				return file === projectAgentSkillMd
+			})
+
+			mockReadFile.mockImplementation(async (file: string) => {
+				if (file === projectAgentSkillMd) {
+					return `---
+name: project-agent-skill
+description: A project-level skill from .agents directory
+---
+
+# Project Agent 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("project-agent-skill")
+			expect(skills[0].source).toBe("project")
+		})
+
+		it("should prioritize .roo skills over .agents skills with same name", async () => {
+			const agentSkillDir = p(globalAgentsSkillsDir, "common-skill")
+			const agentSkillMd = p(agentSkillDir, "SKILL.md")
+			const rooSkillDir = p(globalSkillsDir, "common-skill")
+			const rooSkillMd = p(rooSkillDir, "SKILL.md")
+
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return dir === globalAgentsSkillsDir || dir === globalSkillsDir
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === globalAgentsSkillsDir || dir === globalSkillsDir) {
+					return ["common-skill"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === agentSkillDir || pathArg === rooSkillDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockImplementation(async (file: string) => {
+				return file === agentSkillMd || file === rooSkillMd
+			})
+
+			mockReadFile.mockImplementation(async (file: string) => {
+				if (file === agentSkillMd) {
+					return `---
+name: common-skill
+description: Agent version (should be overridden)
+---
+
+# Agent Common Skill`
+				}
+				if (file === rooSkillMd) {
+					return `---
+name: common-skill
+description: Roo version (should take priority)
+---
+
+# Roo Common Skill`
+				}
+				throw new Error("File not found")
+			})
+
+			await skillsManager.discoverSkills()
+
+			const skills = skillsManager.getSkillsForMode("code")
+			const commonSkill = skills.find((s) => s.name === "common-skill")
+			expect(commonSkill).toBeDefined()
+			// .roo should override .agents
+			expect(commonSkill?.description).toBe("Roo version (should take priority)")
+		})
+
+		it("should discover mode-specific skills from .agents directory", async () => {
+			const agentCodeSkillDir = p(globalAgentsSkillsCodeDir, "agent-code-skill")
+			const agentCodeSkillMd = p(agentCodeSkillDir, "SKILL.md")
+
+			mockDirectoryExists.mockImplementation(async (dir: string) => {
+				return dir === globalAgentsSkillsCodeDir
+			})
+
+			mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
+
+			mockReaddir.mockImplementation(async (dir: string) => {
+				if (dir === globalAgentsSkillsCodeDir) {
+					return ["agent-code-skill"]
+				}
+				return []
+			})
+
+			mockStat.mockImplementation(async (pathArg: string) => {
+				if (pathArg === agentCodeSkillDir) {
+					return { isDirectory: () => true }
+				}
+				throw new Error("Not found")
+			})
+
+			mockFileExists.mockImplementation(async (file: string) => {
+				return file === agentCodeSkillMd
+			})
+
+			mockReadFile.mockImplementation(async (file: string) => {
+				if (file === agentCodeSkillMd) {
+					return `---
+name: agent-code-skill
+description: A code mode skill from .agents directory
+---
+
+# Agent Code 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("agent-code-skill")
+			expect(skills[0].mode).toBe("code")
+		})
 	})
 
 	describe("getSkillsForMode", () => {