From a459813888a7143ae145d810009f58d2c9b1c5dc Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 5 Feb 2026 02:36:43 +0900 Subject: [PATCH] Fix skill discovery priority and deduplication tests --- .../opencode-skill-loader/loader.test.ts | 122 +++++++++++++++--- src/features/opencode-skill-loader/loader.ts | 4 +- 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/features/opencode-skill-loader/loader.test.ts b/src/features/opencode-skill-loader/loader.test.ts index 6fd6cdc0f..dc37f1675 100644 --- a/src/features/opencode-skill-loader/loader.test.ts +++ b/src/features/opencode-skill-loader/loader.test.ts @@ -389,44 +389,132 @@ Skill body. }) describe("deduplication", () => { - it("deduplicates skills with same name, keeping higher priority (opencode-project > opencode)", async () => { - // given: same skill name in both opencode-project and opencode scopes + it("deduplicates skills by name across scopes, keeping higher priority (opencode-project > opencode > project)", async () => { + const originalCwd = process.cwd() + const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR + const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + + // given: same skill name in multiple scopes const opencodeProjectSkillsDir = join(TEST_DIR, ".opencode", "skills") + const opencodeConfigDir = join(TEST_DIR, "opencode-global") + const opencodeGlobalSkillsDir = join(opencodeConfigDir, "skills") + const projectClaudeSkillsDir = join(TEST_DIR, ".claude", "skills") + + process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir + process.env.CLAUDE_CONFIG_DIR = join(TEST_DIR, "claude-user") mkdirSync(join(opencodeProjectSkillsDir, "duplicate-skill"), { recursive: true }) + mkdirSync(join(opencodeGlobalSkillsDir, "duplicate-skill"), { recursive: true }) + mkdirSync(join(projectClaudeSkillsDir, "duplicate-skill"), { recursive: true }) writeFileSync( join(opencodeProjectSkillsDir, "duplicate-skill", "SKILL.md"), `--- name: duplicate-skill -description: From opencode-project (higher priority) +description: From opencode-project (highest priority) --- -Project skill body. +opencode-project body. ` ) - // when: use discoverSkills which performs actual deduplication + writeFileSync( + join(opencodeGlobalSkillsDir, "duplicate-skill", "SKILL.md"), + `--- +name: duplicate-skill +description: From opencode-global (middle priority) +--- +opencode-global body. +` + ) + + writeFileSync( + join(projectClaudeSkillsDir, "duplicate-skill", "SKILL.md"), + `--- +name: duplicate-skill +description: From claude project (lowest priority among these) +--- +claude project body. +` + ) + + // when const { discoverSkills } = await import("./loader") - const originalCwd = process.cwd() process.chdir(TEST_DIR) try { - // discoverSkills with includeClaudeCodePaths: false only loads opencode-project and opencode-global - const skills = await discoverSkills({ includeClaudeCodePaths: false }) - const duplicateSkills = skills.filter(s => s.name === "duplicate-skill") + const skills = await discoverSkills() + const duplicates = skills.filter(s => s.name === "duplicate-skill") - // then: should have exactly one skill (deduplicated) - expect(duplicateSkills).toHaveLength(1) - // and it should be from opencode-project (higher priority) - expect(duplicateSkills[0]?.definition.description).toContain("opencode-project") - expect(duplicateSkills[0]?.scope).toBe("opencode-project") + // then + expect(duplicates).toHaveLength(1) + expect(duplicates[0]?.scope).toBe("opencode-project") + expect(duplicates[0]?.definition.description).toContain("opencode-project") } finally { process.chdir(originalCwd) + process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + } + }) + + it("prioritizes OpenCode global skills over legacy Claude project skills", async () => { + const originalCwd = process.cwd() + const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR + const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + + const opencodeConfigDir = join(TEST_DIR, "opencode-global") + const opencodeGlobalSkillsDir = join(opencodeConfigDir, "skills") + const projectClaudeSkillsDir = join(TEST_DIR, ".claude", "skills") + + process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir + process.env.CLAUDE_CONFIG_DIR = join(TEST_DIR, "claude-user") + + mkdirSync(join(opencodeGlobalSkillsDir, "global-over-project"), { recursive: true }) + mkdirSync(join(projectClaudeSkillsDir, "global-over-project"), { recursive: true }) + + writeFileSync( + join(opencodeGlobalSkillsDir, "global-over-project", "SKILL.md"), + `--- +name: global-over-project +description: From opencode-global (should win) +--- +opencode-global body. +` + ) + + writeFileSync( + join(projectClaudeSkillsDir, "global-over-project", "SKILL.md"), + `--- +name: global-over-project +description: From claude project (should lose) +--- +claude project body. +` + ) + + const { discoverSkills } = await import("./loader") + process.chdir(TEST_DIR) + + try { + const skills = await discoverSkills() + const matches = skills.filter(s => s.name === "global-over-project") + + expect(matches).toHaveLength(1) + expect(matches[0]?.scope).toBe("opencode") + expect(matches[0]?.definition.description).toContain("opencode-global") + } finally { + process.chdir(originalCwd) + process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir } }) it("returns no duplicates from discoverSkills", async () => { - // given: create skill in opencode-project + const originalCwd = process.cwd() + const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR + + process.env.OPENCODE_CONFIG_DIR = join(TEST_DIR, "opencode-global") + + // given const skillContent = `--- name: unique-test-skill description: A unique skill for dedup test @@ -437,18 +525,18 @@ Skill body. // when const { discoverSkills } = await import("./loader") - const originalCwd = process.cwd() process.chdir(TEST_DIR) try { const skills = await discoverSkills({ includeClaudeCodePaths: false }) - // then: no duplicate names + // then const names = skills.map(s => s.name) const uniqueNames = [...new Set(names)] expect(names.length).toBe(uniqueNames.length) } finally { process.chdir(originalCwd) + process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir } }) }) diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index fe42a9423..595224751 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -251,10 +251,10 @@ function deduplicateSkills(skills: LoadedSkill[]): LoadedSkill[] { } export async function discoverAllSkills(): Promise { - const [opencodeProjectSkills, projectSkills, opencodeGlobalSkills, userSkills] = await Promise.all([ + const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills] = await Promise.all([ discoverOpencodeProjectSkills(), - discoverProjectClaudeSkills(), discoverOpencodeGlobalSkills(), + discoverProjectClaudeSkills(), discoverUserClaudeSkills(), ])