-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat: Support multiple context directories for skills (.kilocode, .claude) #5353
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
| } | ||
|
|
@@ -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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -819,6 +819,181 @@ description: A test skill | |
| }) | ||
| }) | ||
|
|
||
| // kilocode_change start - Tests for multi-directory skills support | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
|
|
||
There was a problem hiding this comment.
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