Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/multi-directory-skills.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 19 additions & 16 deletions src/services/marketplace/MarketplaceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what the point of this is, if we never install it there? I also suspect that if you install, manually move to claude, you will see it here, but delete won't work

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) {
Expand Down
59 changes: 37 additions & 22 deletions src/services/skills/SkillsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we order it the other way and override it? That would achieve the same but be simpler right?

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)
}
Expand Down Expand Up @@ -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()
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
175 changes: 175 additions & 0 deletions src/services/skills/__tests__/SkillsManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,181 @@ description: A test skill
})
})

// kilocode_change start - Tests for multi-directory skills support
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please try not to extend testfiles we share with roo, but instead make a new one next to it

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()
Expand Down