diff --git a/.changeset/multi-directory-skills.md b/.changeset/multi-directory-skills.md new file mode 100644 index 00000000000..8f2c34103e6 --- /dev/null +++ b/.changeset/multi-directory-skills.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support multiple context directories for skills discovery. Skills can now be loaded from both `.kilocode/skills/` and `.claude/skills/` directories, allowing users with existing Claude configurations to reuse their skills. The `.kilocode` directory takes precedence when the same skill exists in both locations. diff --git a/src/services/marketplace/MarketplaceManager.ts b/src/services/marketplace/MarketplaceManager.ts index 055228af475..3fe7781abbb 100644 --- a/src/services/marketplace/MarketplaceManager.ts +++ b/src/services/marketplace/MarketplaceManager.ts @@ -286,26 +286,29 @@ export class MarketplaceManager { // File doesn't exist or can't be read, skip } - // kilocode_change start - Check skills in .kilocode/skills/ - const projectSkillsPath = path.join(workspaceFolder.uri.fsPath, ".kilocode", "skills") - try { - const entries = await fs.readdir(projectSkillsPath, { withFileTypes: true }) - for (const entry of entries) { - if (entry.isDirectory()) { - // Check if SKILL.md exists in the directory - const skillFilePath = path.join(projectSkillsPath, entry.name, "SKILL.md") - try { - await fs.access(skillFilePath) - metadata[entry.name] = { - type: "skill", + // kilocode_change start - Check skills in multiple context directories + const projectContextDirs = [".kilocode", ".claude"] + for (const contextDir of projectContextDirs) { + const projectSkillsPath = path.join(workspaceFolder.uri.fsPath, contextDir, "skills") + try { + const entries = await fs.readdir(projectSkillsPath, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory()) { + // Check if SKILL.md exists in the directory + const skillFilePath = path.join(projectSkillsPath, entry.name, "SKILL.md") + try { + await fs.access(skillFilePath) + metadata[entry.name] = { + type: "skill", + } + } catch { + // SKILL.md doesn't exist, skip } - } catch { - // SKILL.md doesn't exist, skip } } + } catch (error) { + // Directory doesn't exist or can't be read, skip } - } catch (error) { - // Directory doesn't exist or can't be read, skip } // kilocode_change end } catch (error) { diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index 00df898a0c7..b9bb295f381 100644 --- a/src/services/skills/SkillsManager.ts +++ b/src/services/skills/SkillsManager.ts @@ -10,6 +10,11 @@ import { SkillMetadata, SkillContent } from "../../shared/skills" import { modes, getAllModes } from "../../shared/modes" import { ConfigChangeNotifier } from "../config/ConfigChangeNotifier" // kilocode_change +// kilocode_change start - Support multiple context directories for skills +/** Context directories to check for project-level skills (in priority order) */ +const PROJECT_CONTEXT_DIRS = [".kilocode", ".claude"] as const +// kilocode_change end + // Re-export for convenience export type { SkillMetadata, SkillContent } @@ -156,13 +161,18 @@ export class SkillsManager { // 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, - path: skillMdPath, - source, - mode, // undefined for generic skills, string for mode-specific - }) + // kilocode_change start - Only add skill if not already present (first-wins for same key) + // This ensures .kilocode takes precedence over .claude since it's processed first + if (!this.skills.has(skillKey)) { + this.skills.set(skillKey, { + name: effectiveSkillName, + description, + path: skillMdPath, + source, + mode, // undefined for generic skills, string for mode-specific + }) + } + // kilocode_change end } catch (error) { console.error(`Failed to load skill at ${skillDir}:`, error) } @@ -258,7 +268,6 @@ export class SkillsManager { 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, ".kilocode") : null // Get list of modes to check for mode-specific skills const modesList = await this.getAvailableModes() @@ -269,13 +278,18 @@ export class SkillsManager { 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 }) + // kilocode_change start - Check multiple project context directories + // Project directories (check all supported context dirs in priority order) + if (provider?.cwd) { + for (const contextDir of PROJECT_CONTEXT_DIRS) { + const projectContextDir = path.join(provider.cwd, contextDir) + dirs.push({ dir: path.join(projectContextDir, "skills"), source: "project" }) + for (const mode of modesList) { + dirs.push({ dir: path.join(projectContextDir, `skills-${mode}`), source: "project", mode }) + } } } + // kilocode_change end return dirs } @@ -313,22 +327,23 @@ export class SkillsManager { 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, ".kilocode", "skills") - // Watch global skills directory - this.watchDirectory(globalSkillsDir) - - // Watch project skills directory - this.watchDirectory(projectSkillsDir) + this.watchDirectory(path.join(getGlobalRooDirectory(), "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, ".kilocode", `skills-${mode}`)) } + + // kilocode_change start - Watch all project context directories + for (const contextDir of PROJECT_CONTEXT_DIRS) { + this.watchDirectory(path.join(provider.cwd, contextDir, "skills")) + for (const mode of modesList) { + this.watchDirectory(path.join(provider.cwd, contextDir, `skills-${mode}`)) + } + } + // kilocode_change end } private watchDirectory(dirPath: string): void { diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts index d24151a7629..d032ad3966d 100644 --- a/src/services/skills/__tests__/SkillsManager.spec.ts +++ b/src/services/skills/__tests__/SkillsManager.spec.ts @@ -819,6 +819,181 @@ description: A test skill }) }) + // kilocode_change start - Tests for multi-directory skills support + describe("multi-directory skills support", () => { + const claudeSkillsDir = p(PROJECT_DIR, ".claude", "skills") + + it("should discover skills from .claude directory", async () => { + const claudeSkillDir = p(claudeSkillsDir, "claude-skill") + const claudeSkillMd = p(claudeSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === claudeSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === claudeSkillsDir) { + return ["claude-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === claudeSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === claudeSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === claudeSkillMd) { + return `--- +name: claude-skill +description: A skill from .claude directory +--- + +# Claude 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("claude-skill") + expect(skills[0].source).toBe("project") + }) + + it("should prefer .kilocode over .claude for same skill name", async () => { + const kilocodeSkillDir = p(projectSkillsDir, "shared-skill") + const kilocodeSkillMd = p(kilocodeSkillDir, "SKILL.md") + const claudeSkillDir = p(claudeSkillsDir, "shared-skill") + const claudeSkillMd = p(claudeSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === projectSkillsDir || dir === claudeSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === projectSkillsDir || dir === claudeSkillsDir) { + return ["shared-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === kilocodeSkillDir || pathArg === claudeSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === kilocodeSkillMd || file === claudeSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === kilocodeSkillMd) { + return `--- +name: shared-skill +description: Skill from .kilocode +--- + +# Kilocode version` + } + if (file === claudeSkillMd) { + return `--- +name: shared-skill +description: Skill from .claude +--- + +# Claude version` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getSkillsForMode("code") + const sharedSkill = skills.find((s) => s.name === "shared-skill") + + // .kilocode should take precedence (first-wins for same source) + expect(sharedSkill?.description).toBe("Skill from .kilocode") + }) + + it("should merge skills from both directories", async () => { + const kilocodeSkillDir = p(projectSkillsDir, "kilocode-only") + const kilocodeSkillMd = p(kilocodeSkillDir, "SKILL.md") + const claudeSkillDir = p(claudeSkillsDir, "claude-only") + const claudeSkillMd = p(claudeSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === projectSkillsDir || dir === claudeSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === projectSkillsDir) { + return ["kilocode-only"] + } + if (dir === claudeSkillsDir) { + return ["claude-only"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === kilocodeSkillDir || pathArg === claudeSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === kilocodeSkillMd || file === claudeSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === kilocodeSkillMd) { + return `--- +name: kilocode-only +description: Only in .kilocode +--- + +# Kilocode Only` + } + if (file === claudeSkillMd) { + return `--- +name: claude-only +description: Only in .claude +--- + +# Claude Only` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getSkillsForMode("code") + expect(skills).toHaveLength(2) + expect(skills.map((s) => s.name).sort()).toEqual(["claude-only", "kilocode-only"]) + }) + }) + // kilocode_change end + describe("dispose", () => { it("should clean up resources", async () => { await skillsManager.dispose()