Skip to content

Commit b464cd9

Browse files
wan-huiyanclaude
andauthored
fix(plugin): restructure to canonical plugins/<name>/ layout (#6)
v1.3.0 → v1.4.0. Fixes 3-layer plugin install bug (lesson #133): Layer 2 — marketplace.json path Moved marketplace.json from repo root to .claude-plugin/marketplace.json. Root-level file was silently ignored by 'claude plugin marketplace add', leaving the marketplace unregistered. Layer 3 — plugin source path Restructured to canonical plugins/<name>/ layout. 'source': '.' returned 'Invalid schema: plugins.0.source'. - plugins/plan-review-integrator/.claude-plugin/plugin.json (moved from .claude-plugin/) - plugins/plan-review-integrator/SKILL.md (moved from repo root) - 'source': './plugins/plan-review-integrator' (was: '.') - Added $schema and top-level description to marketplace.json Test suite Updated manifest-consistency.test.mjs and trigger-classification.test.mjs with canonical discovery helpers from wan-huiyan/causal-impact-campaign. All tests pass locally. Reference: wan-huiyan/causal-impact-campaign#11 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3166a95 commit b464cd9

7 files changed

Lines changed: 139 additions & 557 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
3+
"name": "wan-huiyan-plan-review-integrator",
4+
"description": "Single-plugin marketplace for plan-review-integrator — Claude Code skill: plan-review-integrator",
5+
"owner": {
6+
"name": "wan-huiyan"
7+
},
8+
"plugins": [
9+
{
10+
"name": "plan-review-integrator",
11+
"source": "./plugins/plan-review-integrator",
12+
"description": "Claude Code skill: plan-review-integrator",
13+
"version": "1.4.0"
14+
}
15+
]
16+
}

marketplace.json

Lines changed: 0 additions & 23 deletions
This file was deleted.

.claude-plugin/plugin.json renamed to plugins/plan-review-integrator/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "plan-review-integrator",
3-
"version": "1.3.0",
3+
"version": "1.4.0",
44
"author": "wan-huiyan",
55
"description": "Integrate structured review panel findings into implementation plan documents with full traceability.",
66
"keywords": [
File renamed without changes.

skills/plan-review-integrator/SKILL.md

Lines changed: 0 additions & 504 deletions
This file was deleted.

tests/manifest-consistency.test.mjs

Lines changed: 71 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,29 @@ function extractFrontmatter(md) {
3737

3838
const files = {};
3939

40-
const pluginJsonPath = resolve(ROOT, ".claude-plugin/plugin.json");
41-
if (existsSync(pluginJsonPath)) {
40+
// The canonical Claude Code plugin layout nests each plugin inside a
41+
// plugins/<name>/ subdirectory, with its manifest at
42+
// plugins/<name>/.claude-plugin/plugin.json. We discover the first plugin
43+
// manifest anywhere under plugins/ and fall back to the legacy
44+
// .claude-plugin/plugin.json location for backwards compatibility.
45+
function findPluginJson() {
46+
// Prefer the canonical plugins/<name>/.claude-plugin/plugin.json layout
47+
const pluginsRoot = resolve(ROOT, "plugins");
48+
if (existsSync(pluginsRoot)) {
49+
for (const entry of readdirSync(pluginsRoot, { withFileTypes: true })) {
50+
if (entry.isDirectory()) {
51+
const candidate = resolve(pluginsRoot, entry.name, ".claude-plugin/plugin.json");
52+
if (existsSync(candidate)) return candidate;
53+
}
54+
}
55+
}
56+
// Legacy fallback: plugin.json at marketplace root
57+
const legacy = resolve(ROOT, ".claude-plugin/plugin.json");
58+
return existsSync(legacy) ? legacy : null;
59+
}
60+
61+
const pluginJsonPath = findPluginJson();
62+
if (pluginJsonPath && existsSync(pluginJsonPath)) {
4263
files.pluginJson = JSON.parse(readFileSync(pluginJsonPath, "utf-8"));
4364
}
4465

@@ -57,27 +78,53 @@ if (existsSync(packageJsonPath)) {
5778
files.packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
5879
}
5980

60-
const rootSkillMdPath = resolve(ROOT, "SKILL.md");
61-
if (existsSync(rootSkillMdPath)) {
81+
// Discover the plugin's SKILL.md. In the canonical layout it lives at
82+
// plugins/<name>/SKILL.md (next to the plugin's .claude-plugin/plugin.json).
83+
// Fall back to a root SKILL.md for repos using the legacy flat layout.
84+
function findRootSkillMd() {
85+
// Canonical: plugins/<name>/SKILL.md
86+
const pluginsRoot = resolve(ROOT, "plugins");
87+
if (existsSync(pluginsRoot)) {
88+
for (const entry of readdirSync(pluginsRoot, { withFileTypes: true })) {
89+
if (entry.isDirectory()) {
90+
const candidate = resolve(pluginsRoot, entry.name, "SKILL.md");
91+
if (existsSync(candidate)) return candidate;
92+
}
93+
}
94+
}
95+
// Legacy: SKILL.md at the repo root
96+
const legacy = resolve(ROOT, "SKILL.md");
97+
return existsSync(legacy) ? legacy : null;
98+
}
99+
100+
const rootSkillMdPath = findRootSkillMd();
101+
if (rootSkillMdPath && existsSync(rootSkillMdPath)) {
62102
files.rootSkillMd = readFileSync(rootSkillMdPath, "utf-8");
63103
}
64104

65-
// Find nested SKILL.md in skills/ directory
66-
const skillsDir = resolve(ROOT, "skills");
105+
// Optional secondary SKILL.md: some plugins also expose a nested
106+
// plugins/<name>/skills/<skill>/SKILL.md. Skip if the layout doesn't use it.
67107
let nestedSkillMdPath = null;
68-
if (existsSync(skillsDir)) {
69-
try {
70-
const subdirs = readdirSync(skillsDir, { withFileTypes: true })
71-
.filter((d) => d.isDirectory());
72-
for (const subdir of subdirs) {
73-
const candidate = resolve(skillsDir, subdir.name, "SKILL.md");
74-
if (existsSync(candidate)) {
75-
nestedSkillMdPath = candidate;
76-
files.nestedSkillMd = readFileSync(candidate, "utf-8");
77-
break;
108+
const pluginsRootForSkills = resolve(ROOT, "plugins");
109+
if (existsSync(pluginsRootForSkills)) {
110+
for (const pluginEntry of readdirSync(pluginsRootForSkills, { withFileTypes: true })) {
111+
if (!pluginEntry.isDirectory()) continue;
112+
const skillsDir = resolve(pluginsRootForSkills, pluginEntry.name, "skills");
113+
if (!existsSync(skillsDir)) continue;
114+
try {
115+
const subdirs = readdirSync(skillsDir, { withFileTypes: true })
116+
.filter((d) => d.isDirectory());
117+
for (const subdir of subdirs) {
118+
const candidate = resolve(skillsDir, subdir.name, "SKILL.md");
119+
if (existsSync(candidate)) {
120+
nestedSkillMdPath = candidate;
121+
files.nestedSkillMd = readFileSync(candidate, "utf-8");
122+
break;
123+
}
78124
}
79-
}
80-
} catch { /* no skills/ dir */ }
125+
} catch { /* ignore */ }
126+
if (nestedSkillMdPath) break;
127+
}
81128
}
82129

83130
// Derive the canonical skill name from plugin.json (authoritative source)
@@ -121,8 +168,12 @@ describe("Manifest consistency", () => {
121168
assert.ok(plugin.source, "plugin must have source");
122169
});
123170

124-
it("name matches plugin.json", () => {
125-
assert.equal(files.marketplaceJson.name, SKILL_NAME);
171+
it("first plugin entry name matches plugin.json", () => {
172+
// The marketplace's own `name` can be anything (e.g. owner-prefixed
173+
// "wan-huiyan-causal-impact-campaign"). The real invariant is that the
174+
// first plugin entry must match the plugin.json name, because that's
175+
// what users type in `claude plugin install <plugin-name>@<marketplace>`.
176+
assert.equal(files.marketplaceJson.plugins[0].name, SKILL_NAME);
126177
});
127178

128179
if (files.marketplaceJson.plugins[0]?.version) {

tests/trigger-classification.test.mjs

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,22 +33,64 @@ if (!existsSync(evalSuitePath)) {
3333
it("eval-suite has no triggers — skipping", { skip: "no triggers array" }, () => {});
3434
});
3535
} else {
36-
// Find the best SKILL.md to extract trigger keywords from
36+
// Find the best SKILL.md to extract trigger keywords from.
37+
// Priority:
38+
// 1. plugins/<name>/SKILL.md (canonical plugin layout)
39+
// 2. plugins/<name>/skills/<skill>/SKILL.md (skill-within-plugin layout)
40+
// 3. skills/<name>/SKILL.md (legacy flat-skill layout)
41+
// 4. SKILL.md at repo root (legacy single-file layout)
3742
let skillMdContent = "";
38-
const skillsDir = resolve(ROOT, "skills");
39-
if (existsSync(skillsDir)) {
43+
44+
// 1. Canonical plugin layout
45+
const pluginsRoot = resolve(ROOT, "plugins");
46+
if (existsSync(pluginsRoot)) {
4047
try {
41-
const subdirs = readdirSync(skillsDir, { withFileTypes: true })
42-
.filter((d) => d.isDirectory());
43-
for (const subdir of subdirs) {
44-
const candidate = resolve(skillsDir, subdir.name, "SKILL.md");
45-
if (existsSync(candidate)) {
46-
skillMdContent = readFileSync(candidate, "utf-8");
48+
for (const entry of readdirSync(pluginsRoot, { withFileTypes: true })) {
49+
if (!entry.isDirectory()) continue;
50+
const direct = resolve(pluginsRoot, entry.name, "SKILL.md");
51+
if (existsSync(direct)) {
52+
skillMdContent = readFileSync(direct, "utf-8");
4753
break;
4854
}
55+
// 2. skills-within-plugin layout
56+
const skillsDirInPlugin = resolve(pluginsRoot, entry.name, "skills");
57+
if (existsSync(skillsDirInPlugin)) {
58+
try {
59+
const subdirs = readdirSync(skillsDirInPlugin, { withFileTypes: true })
60+
.filter((d) => d.isDirectory());
61+
for (const subdir of subdirs) {
62+
const candidate = resolve(skillsDirInPlugin, subdir.name, "SKILL.md");
63+
if (existsSync(candidate)) {
64+
skillMdContent = readFileSync(candidate, "utf-8");
65+
break;
66+
}
67+
}
68+
} catch { /* ignore */ }
69+
}
70+
if (skillMdContent) break;
4971
}
5072
} catch { /* fallback below */ }
5173
}
74+
75+
// 3. Legacy flat-skill layout
76+
if (!skillMdContent) {
77+
const skillsDir = resolve(ROOT, "skills");
78+
if (existsSync(skillsDir)) {
79+
try {
80+
const subdirs = readdirSync(skillsDir, { withFileTypes: true })
81+
.filter((d) => d.isDirectory());
82+
for (const subdir of subdirs) {
83+
const candidate = resolve(skillsDir, subdir.name, "SKILL.md");
84+
if (existsSync(candidate)) {
85+
skillMdContent = readFileSync(candidate, "utf-8");
86+
break;
87+
}
88+
}
89+
} catch { /* fallback below */ }
90+
}
91+
}
92+
93+
// 4. Legacy single-file layout
5294
if (!skillMdContent) {
5395
const rootSkillMd = resolve(ROOT, "SKILL.md");
5496
if (existsSync(rootSkillMd)) {

0 commit comments

Comments
 (0)