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 <roomote@roocode.com>
This commit is contained in:
roomote[bot] 2026-02-05 16:40:06 -08:00 committed by GitHub
parent 590fef711c
commit a266834ee2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 333 additions and 11 deletions

View file

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

View file

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

View file

@ -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 skills directory
this.watchDirectory(globalSkillsDir)
// Watch global .roo skills directory
this.watchDirectory(path.join(globalRooDir, "skills"))
// Watch project skills directory
this.watchDirectory(projectSkillsDir)
// Watch global .agents skills directory
this.watchDirectory(path.join(globalAgentsDir, "skills"))
// Watch project .roo skills directory
this.watchDirectory(path.join(projectRooDir, "skills"))
// 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}`))
}
}

View file

@ -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", () => {