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
10 changes: 9 additions & 1 deletion src/parsers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
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[] {
Expand Down
7 changes: 7 additions & 0 deletions tests/claude-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -25,6 +26,7 @@ const [
invalidCommandPathRoot,
invalidHooksPathRoot,
invalidMcpPathRoot,
defaultDirDeclaredRoot,
] = fixtures.map((fixture) => fixture.root)
const tempRoots: string[] = []

Expand Down Expand Up @@ -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"])
Comment on lines +195 to +197

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add the skills field to the real Claude manifest

This new regression only exercises a temporary fixture with skills: "./skills/"; it never checks the actual .claude-plugin/plugin.json, which still has no skills field in this commit. If the goal is to stop Claude installs of the root plugin from discovering fixture SKILL.md files, users remain on the old manifest behavior until the real Claude manifest declares the skills directory, so please update that manifest or assert it here against compoundPluginRoot.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@danikdanik address

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for flagging this; it was correct for the original PR scope, but that scope has since been dropped.

The original PR added "skills": "./skills/" to .claude-plugin/plugin.json to prevent fixture SKILL.md files from loading as real skills. That change (and the recursive-scan claim behind it) was removed after @tmchow's review proved the Claude Code loader scans skills/<name>/SKILL.md one level deep regardless of whether the skills field is present, so fixture SKILL.md files under tests/fixtures/ are never discovered. I verified this on Claude Code 2.1.220 with compound-engineering@3.20.0 installed (no skills field; all 6 fixture SKILL.md files present in the Claude Code plugin cache): a fresh claude -p session listed 24 real CE skills and zero fixtures.

The current PR is purely the parser dedup: when a manifest does declare the default dir explicitly, resolveComponentDirs no longer scans it twice. The regression test exercises that via a fixture because the real .claude-plugin/plugin.json intentionally has no skills field and doesn't need one. A contract test asserting the real manifest omits skills isn't applicable here: that absence is an intentional manifest design choice (rely on default loading), not a parser behavior, and the loader's one-level scan makes it irrelevant to skill discovery either way.

})

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.",
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/default-dir-declared/claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "default-dir-declared",
"version": "1.0.0",
"skills": "./skills/"
}
6 changes: 6 additions & 0 deletions tests/fixtures/default-dir-declared/skills/skill-a/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
name: skill-a
description: Skill A
---

Skill A body.
6 changes: 6 additions & 0 deletions tests/fixtures/default-dir-declared/skills/skill-b/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
name: skill-b
description: Skill B
---

Skill B body.