diff --git a/src/parsers/claude.ts b/src/parsers/claude.ts index 5262e91d56..52611abf02 100644 --- a/src/parsers/claude.ts +++ b/src/parsers/claude.ts @@ -193,7 +193,15 @@ function resolveComponentDirs( for (const entry of toPathList(custom)) { dirs.push(resolveWithinRoot(root, entry, `${defaultDir} path`)) } - return dirs + // Deduplicate by resolved path so a manifest that declares the default + // directory explicitly (e.g. `"skills": "./skills/"`) does not scan it twice. + const seen = new Set() + return dirs.filter((dir) => { + const resolved = path.resolve(dir) + if (seen.has(resolved)) return false + seen.add(resolved) + return true + }) } function toPathList(value?: string | string[]): string[] { diff --git a/tests/claude-parser.test.ts b/tests/claude-parser.test.ts index 24f5b8fda3..4a0305e6d5 100644 --- a/tests/claude-parser.test.ts +++ b/tests/claude-parser.test.ts @@ -14,6 +14,7 @@ const fixtureNames = [ "invalid-command-path", "invalid-hooks-path", "invalid-mcp-path", + "default-dir-declared", ] as const const fixtures = fixtureNames.map((name) => materializeClaudePluginFixture(path.join(import.meta.dir, "fixtures", name)), @@ -25,6 +26,7 @@ const [ invalidCommandPathRoot, invalidHooksPathRoot, invalidMcpPathRoot, + defaultDirDeclaredRoot, ] = fixtures.map((fixture) => fixture.root) const tempRoots: string[] = [] @@ -190,6 +192,11 @@ describe("loadClaudePlugin", () => { expect(plugin.hooks?.hooks.PostToolUse?.[0]?.hooks[0]?.command).toBe("echo custom") }) + test("does not double-count skills when the manifest declares the default dir", async () => { + const plugin = await loadClaudePlugin(defaultDirDeclaredRoot) + expect(plugin.skills.map((skill) => skill.name).sort()).toEqual(["skill-a", "skill-b"]) + }) + test("rejects custom component paths that escape the plugin root", async () => { await expect(loadClaudePlugin(invalidCommandPathRoot)).rejects.toThrow( "Invalid commands path: ../outside-commands. Paths must stay within the plugin root.", diff --git a/tests/fixtures/default-dir-declared/claude-plugin/plugin.json b/tests/fixtures/default-dir-declared/claude-plugin/plugin.json new file mode 100644 index 0000000000..e5e04c84f6 --- /dev/null +++ b/tests/fixtures/default-dir-declared/claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "default-dir-declared", + "version": "1.0.0", + "skills": "./skills/" +} diff --git a/tests/fixtures/default-dir-declared/skills/skill-a/SKILL.md b/tests/fixtures/default-dir-declared/skills/skill-a/SKILL.md new file mode 100644 index 0000000000..c6b1c5c893 --- /dev/null +++ b/tests/fixtures/default-dir-declared/skills/skill-a/SKILL.md @@ -0,0 +1,6 @@ +--- +name: skill-a +description: Skill A +--- + +Skill A body. diff --git a/tests/fixtures/default-dir-declared/skills/skill-b/SKILL.md b/tests/fixtures/default-dir-declared/skills/skill-b/SKILL.md new file mode 100644 index 0000000000..ef1cb79c7a --- /dev/null +++ b/tests/fixtures/default-dir-declared/skills/skill-b/SKILL.md @@ -0,0 +1,6 @@ +--- +name: skill-b +description: Skill B +--- + +Skill B body.