Przeglądaj źródła

feat: add .agents/skills directory support for skill discovery (#9074)

* feat: add .agents/skills directory support for skill discovery

Add compatibility for the standardized .agents/skills directory pattern,
both globally (~/.agents/skills) and locally (.agents/skills in workspace).

* feat: make .agents/skills the default for new skills

New skills are now created in .agents/skills (local) and ~/.agents/skills
(global) by default. These directories also have highest priority in
skill discovery, overriding skills with the same name from other locations.

* docs: update skills documentation for .agents/skills directories

* refactor skills directory helpers
Robin Newhouse 1 tydzień temu
rodzic
commit
e85332319e

+ 5 - 0
.changeset/dark-hotels-care.md

@@ -0,0 +1,5 @@
+---
+"claude-dev": patch
+---
+
+Add .agents/skills as default skill directory (global and local)

+ 5 - 4
docs/features/skills.mdx

@@ -62,15 +62,16 @@ The description is critical because it's how Cline decides whether to activate a
 Skills can be stored in two locations:
 
 **Global Skills** apply to all your projects:
-- **macOS/Linux:** `~/.cline/skills/`
-- **Windows:** `C:\Users\USERNAME\.cline\skills\`
+- **macOS/Linux:** `~/.agents/skills/` (recommended) or `~/.cline/skills/`
+- **Windows:** `C:\Users\USERNAME\.agents\skills\` (recommended) or `C:\Users\USERNAME\.cline\skills\`
 
 **Project Skills** apply only to the current workspace:
-- `.cline/skills/` (recommended)
+- `.agents/skills/` (recommended)
+- `.cline/skills/`
 - `.clinerules/skills/`
 - `.claude/skills/` (for Claude Code compatibility)
 
-When a global skill and project skill have the same name, the global skill takes precedence. This lets you customize skills for your personal workflow while still using project defaults.
+When a global skill and project skill have the same name, the global skill takes precedence. Skills in `.agents/skills` directories take precedence over other locations with the same name, letting you customize skills for your personal workflow while still using project defaults.
 
 ## Managing Skills
 

+ 31 - 5
src/core/context/instructions/user-instructions/__tests__/skills.test.ts

@@ -21,7 +21,6 @@ describe("Skills Utility Functions", () => {
 	let readdirStub: sinon.SinonStub
 	let statStub: sinon.SinonStub
 	let readFileStub: sinon.SinonStub
-	let ensureSkillsDirStub: sinon.SinonStub
 
 	// Use path.join for OS-independent paths
 	const TEST_CWD = path.join("/test", "project")
@@ -39,10 +38,14 @@ describe("Skills Utility Functions", () => {
 		readdirStub = sandbox.stub(fs.promises, "readdir")
 		statStub = sandbox.stub(fs.promises, "stat")
 		readFileStub = sandbox.stub(fs.promises, "readFile")
-		ensureSkillsDirStub = sandbox.stub(disk, "ensureSkillsDirectoryExists")
-
-		// Default: global skills dir
-		ensureSkillsDirStub.resolves(GLOBAL_SKILLS_DIR)
+		sandbox.stub(disk, "getSkillsDirectoriesForScan").returns([
+			{ path: path.join(TEST_CWD, ".clinerules", "skills"), source: "project" },
+			{ path: path.join(TEST_CWD, ".cline", "skills"), source: "project" },
+			{ path: path.join(TEST_CWD, ".claude", "skills"), source: "project" },
+			{ path: path.join(TEST_CWD, ".agents", "skills"), source: "project" },
+			{ path: GLOBAL_SKILLS_DIR, source: "global" },
+			{ path: path.join("/home", "user", ".agents", "skills"), source: "global" },
+		])
 
 		// Default: no directories exist
 		fileExistsStub.resolves(false)
@@ -146,6 +149,29 @@ Follow best practices.`)
 			expect(skills[0].source).to.equal("project")
 		})
 
+		it("should discover skills from project .agents/skills directory", async () => {
+			const agentsSkillsDir = path.join(TEST_CWD, ".agents", "skills")
+			const skillDir = path.join(agentsSkillsDir, "testing")
+			const skillMdPath = path.join(skillDir, "SKILL.md")
+
+			fileExistsStub.withArgs(agentsSkillsDir).resolves(true)
+			fileExistsStub.withArgs(skillMdPath).resolves(true)
+			isDirectoryStub.withArgs(agentsSkillsDir).resolves(true)
+			readdirStub.withArgs(agentsSkillsDir).resolves(["testing"])
+			statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
+			readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
+name: testing
+description: Write comprehensive tests
+---
+Always write tests.`)
+
+			const skills = await discoverSkills(TEST_CWD)
+
+			expect(skills).to.have.lengthOf(1)
+			expect(skills[0].name).to.equal("testing")
+			expect(skills[0].source).to.equal("project")
+		})
+
 		it("should handle empty skills directories gracefully", async () => {
 			fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
 			isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)

+ 6 - 16
src/core/context/instructions/user-instructions/skills.ts

@@ -1,4 +1,4 @@
-import { ensureSkillsDirectoryExists, GlobalFileNames } from "@core/storage/disk"
+import { getSkillsDirectoriesForScan } from "@core/storage/disk"
 import type { SkillContent, SkillMetadata } from "@shared/skills"
 import { fileExistsAtPath, isDirectory } from "@utils/fs"
 import * as fs from "fs/promises"
@@ -98,22 +98,12 @@ async function loadSkillMetadata(
 export async function discoverSkills(cwd: string): Promise<SkillMetadata[]> {
 	const skills: SkillMetadata[] = []
 
-	const globalSkillsDir = await ensureSkillsDirectoryExists()
-	const projectDirs = [
-		path.join(cwd, GlobalFileNames.clineruleSkillsDir),
-		path.join(cwd, GlobalFileNames.clineSkillsDir),
-		path.join(cwd, GlobalFileNames.claudeSkillsDir),
-	]
-
-	// Load project skills first (lower priority)
-	for (const dir of projectDirs) {
-		const projectSkills = await scanSkillsDirectory(dir, "project")
-		skills.push(...projectSkills)
-	}
+	const scanDirs = getSkillsDirectoriesForScan(cwd)
 
-	// Load global skills last (~/.cline/skills) - higher priority
-	const globalSkills = await scanSkillsDirectory(globalSkillsDir, "global")
-	skills.push(...globalSkills)
+	for (const dir of scanDirs) {
+		const dirSkills = await scanSkillsDirectory(dir.path, dir.source)
+		skills.push(...dirSkills)
+	}
 
 	return skills
 }

+ 8 - 5
src/core/controller/file/createSkillFile.ts

@@ -1,7 +1,7 @@
+import { ensureAgentSkillsDirectoryExists } from "@core/storage/disk"
 import { CreateSkillRequest, SkillsToggles } from "@shared/proto/cline/file"
 import fs from "fs/promises"
 import path from "path"
-import { ensureSkillsDirectoryExists } from "@/core/storage/disk"
 import { HostProvider } from "@/hosts/host-provider"
 import { ShowMessageType } from "@/shared/proto/host/window"
 import { Logger } from "@/shared/services/Logger"
@@ -55,7 +55,8 @@ export async function createSkillFile(controller: Controller, request: CreateSki
 	let skillDir: string
 
 	if (isGlobal) {
-		const globalSkillsDir = await ensureSkillsDirectoryExists()
+		// Create in ~/.agents/skills using the unified helper
+		const globalSkillsDir = await ensureAgentSkillsDirectoryExists({ isGlobal: true })
 		skillDir = path.join(globalSkillsDir, sanitizedName)
 	} else {
 		const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
@@ -63,9 +64,11 @@ export async function createSkillFile(controller: Controller, request: CreateSki
 		if (!primaryWorkspace) {
 			throw new Error("No workspace folder open")
 		}
-		// Create in .cline/skills by default
-		const localSkillsDir = path.join(primaryWorkspace, ".cline", "skills")
-		await fs.mkdir(localSkillsDir, { recursive: true })
+		// Create in .agents/skills using the unified helper
+		const localSkillsDir = await ensureAgentSkillsDirectoryExists({
+			isGlobal: false,
+			workspacePath: primaryWorkspace,
+		})
 		skillDir = path.join(localSkillsDir, sanitizedName)
 	}
 

+ 22 - 20
src/core/controller/file/refreshSkills.ts

@@ -1,7 +1,7 @@
 import { RefreshedSkills, SkillInfo } from "@shared/proto/cline/file"
 import fs from "fs/promises"
 import path from "path"
-import { ensureSkillsDirectoryExists } from "@/core/storage/disk"
+import { getSkillsDirectoriesForScan } from "@/core/storage/disk"
 import { HostProvider } from "@/hosts/host-provider"
 import { fileExistsAtPath, isDirectory } from "@/utils/fs"
 import { Controller } from ".."
@@ -88,14 +88,31 @@ async function scanSkillsDirectory(dirPath: string): Promise<SkillInfo[]> {
  * Refreshes all skill toggles (discovers skills and their enabled state)
  */
 export async function refreshSkills(controller: Controller): Promise<RefreshedSkills> {
-	const globalSkillsDir = await ensureSkillsDirectoryExists()
-
 	// Get workspace paths for local skills
 	const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
 	const primaryWorkspace = workspacePaths.paths[0]
 
-	// Scan global skills
-	const globalSkills = await scanSkillsDirectory(globalSkillsDir)
+	const globalSkills: SkillInfo[] = []
+	const localSkills: SkillInfo[] = []
+
+	if (primaryWorkspace) {
+		const scanDirs = getSkillsDirectoriesForScan(primaryWorkspace)
+		for (const dir of scanDirs) {
+			const skills = await scanSkillsDirectory(dir.path)
+			if (dir.source === "global") {
+				globalSkills.push(...skills)
+			} else {
+				localSkills.push(...skills)
+			}
+		}
+	} else {
+		const scanDirs = getSkillsDirectoriesForScan("")
+		for (const dir of scanDirs) {
+			if (dir.source !== "global") continue
+			const skills = await scanSkillsDirectory(dir.path)
+			globalSkills.push(...skills)
+		}
+	}
 
 	// Get global toggles and apply them
 	const globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
@@ -103,21 +120,6 @@ export async function refreshSkills(controller: Controller): Promise<RefreshedSk
 		skill.enabled = globalToggles[skill.path] !== false
 	}
 
-	// Scan local skills from all possible directories
-	const localSkills: SkillInfo[] = []
-	if (primaryWorkspace) {
-		const localDirs = [
-			path.join(primaryWorkspace, ".clinerules", "skills"),
-			path.join(primaryWorkspace, ".cline", "skills"),
-			path.join(primaryWorkspace, ".claude", "skills"),
-		]
-
-		for (const dir of localDirs) {
-			const skills = await scanSkillsDirectory(dir)
-			localSkills.push(...skills)
-		}
-	}
-
 	// Get local toggles and apply them
 	const localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
 	for (const skill of localSkills) {

+ 41 - 6
src/core/storage/disk.ts

@@ -57,6 +57,7 @@ export const GlobalFileNames = {
 	clineruleSkillsDir: ".clinerules/skills",
 	clineSkillsDir: ".cline/skills",
 	claudeSkillsDir: ".claude/skills",
+	agentsSkillsDir: ".agents/skills",
 	cursorRulesDir: ".cursor/rules",
 	cursorRulesFile: ".cursorrules",
 	windsurfRules: ".windsurfrules",
@@ -164,18 +165,52 @@ export async function ensureHooksDirectoryExists(): Promise<string> {
 }
 
 /**
- * Returns the global skills directory path (~/.cline/skills).
+ * Returns the global skills directory path (~/.cline/skills) without creating it.
+ */
+function getClineSkillsDirectoryPath(): string {
+	return path.join(getClineHomePath(), "skills")
+}
+
+function getAgentSkillsDirectoryPath(): string {
+	return path.join(os.homedir(), ".agents", "skills")
+}
+
+/**
+ * Returns the global agent skills directory path (~/.agents/skills).
  * Creates the directory if it doesn't exist.
+ * This is the opinionated location for new global skills.
  */
-export async function ensureSkillsDirectoryExists(): Promise<string> {
-	const clineSkillsDir = path.join(getClineHomePath(), "skills")
+export async function ensureAgentSkillsDirectoryExists(options: { isGlobal: boolean; workspacePath?: string }): Promise<string> {
+	const agentSkillsDir = options.isGlobal
+		? getAgentSkillsDirectoryPath()
+		: path.join(options.workspacePath ?? "", GlobalFileNames.agentsSkillsDir)
 	try {
-		await fs.mkdir(clineSkillsDir, { recursive: true })
+		await fs.mkdir(agentSkillsDir, { recursive: true })
 	} catch (_error) {
 		// Fallback - return the path even if mkdir fails, we'll fail gracefully later
-		return clineSkillsDir
+		return agentSkillsDir
 	}
-	return clineSkillsDir
+	return agentSkillsDir
+}
+
+export type SkillsScanDirectory = {
+	path: string
+	source: "project" | "global"
+}
+
+/**
+ * Returns the list of skills directories to scan without creating them.
+ * Order is project directories first, then global directories.
+ */
+export function getSkillsDirectoriesForScan(cwd: string): SkillsScanDirectory[] {
+	return [
+		{ path: path.join(cwd, GlobalFileNames.clineruleSkillsDir), source: "project" },
+		{ path: path.join(cwd, GlobalFileNames.clineSkillsDir), source: "project" },
+		{ path: path.join(cwd, GlobalFileNames.claudeSkillsDir), source: "project" },
+		{ path: path.join(cwd, GlobalFileNames.agentsSkillsDir), source: "project" },
+		{ path: getClineSkillsDirectoryPath(), source: "global" },
+		{ path: getAgentSkillsDirectoryPath(), source: "global" },
+	]
 }
 
 export async function ensureSettingsDirectoryExists(): Promise<string> {