From bdda198a1f1daf3cf64455ab879e5525dbb5a8ef Mon Sep 17 00:00:00 2001 From: Dani Kamanovsky Date: Wed, 29 Jul 2026 15:26:07 +0300 Subject: [PATCH] fix(parser): deduplicate component dirs when manifest declares the default resolveComponentDirs always included the default dir (root/skills) AND any manifest-declared dir. When a manifest declares the default dir explicitly (e.g. `"skills": "./skills/"`), this scanned it twice and double-counted skills. Deduplicate by resolved path. Adds a regression fixture (default-dir-declared) with a manifest that declares `"skills": "./skills/"` and two skills, asserting the parser returns 2 skills, not 4. --- src/parsers/claude.ts | 10 +++++++++- tests/claude-parser.test.ts | 7 +++++++ .../default-dir-declared/claude-plugin/plugin.json | 5 +++++ .../default-dir-declared/skills/skill-a/SKILL.md | 6 ++++++ .../default-dir-declared/skills/skill-b/SKILL.md | 6 ++++++ 5 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/default-dir-declared/claude-plugin/plugin.json create mode 100644 tests/fixtures/default-dir-declared/skills/skill-a/SKILL.md create mode 100644 tests/fixtures/default-dir-declared/skills/skill-b/SKILL.md 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.