diff --git a/src/services/roo-config/__tests__/index.spec.ts b/src/services/roo-config/__tests__/index.spec.ts index c060cdcb5a4..1775d5502cd 100644 --- a/src/services/roo-config/__tests__/index.spec.ts +++ b/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) diff --git a/src/services/roo-config/index.ts b/src/services/roo-config/index.ts index 166617834da..b97e01f5b5d 100644 --- a/src/services/roo-config/index.ts +++ b/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 * diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index ac1473748b9..f6f86e85736 100644 --- a/src/services/skills/SkillsManager.ts +++ b/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}`)) } } diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts index 8d1e1e9113c..b0fee079bb8 100644 --- a/src/services/skills/__tests__/SkillsManager.spec.ts +++ b/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", () => {