fix(discovery): load Claude plugin skill paths that point at a SKILL.md directory
Claude plugin manifests may declare a `skills` path that resolves directly to a directory whose `SKILL.md` IS the skill (e.g. `"skills": ["./"]` or a subdirectory containing only `SKILL.md`). `scanSkillsFromDir` only scanned `<dir>/<name>/SKILL.md` children, so the single-skill directory layout — the common shape the Claude plugins reference documents for plugins shipping one skill — silently dropped every array-form manifest entry that pointed at it. Add an opt-in `includeSelf` flag to `ScanSkillsFromDirOptions`: when set, `<dir>/SKILL.md` (if present) is loaded as a skill in addition to the existing child scan. The Claude plugin skills loader opts in; every other provider (agents, builtin, claude.ts, codex, github, omp-plugins, opencode) keeps the strict child-scan semantic they rely on. Frontmatter `name` still wins over the directory basename fallback. Regression test: `skills: ["./single"]` where `./single/SKILL.md` is the only skill file loads the skill under its frontmatter name.
This commit is contained in:
@@ -169,6 +169,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
dir,
|
||||
providerId: PROVIDER_ID,
|
||||
level: root.scope,
|
||||
includeSelf: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -312,6 +312,15 @@ export interface ScanSkillsFromDirOptions {
|
||||
providerId: string;
|
||||
level: "user" | "project";
|
||||
requireDescription?: boolean;
|
||||
/**
|
||||
* When true, treat a `SKILL.md` sitting directly under `dir` as a single skill in addition to
|
||||
* scanning `<dir>/<name>/SKILL.md` children. Matches the Claude plugin manifest convention
|
||||
* that lets a skill path point at a directory containing `SKILL.md` directly (e.g.
|
||||
* `"skills": ["./"]`), where the frontmatter `name` determines the invocation name and the
|
||||
* directory basename is the fallback. Default `false` preserves the strict child-scan
|
||||
* semantic every non-Claude provider relies on.
|
||||
*/
|
||||
includeSelf?: boolean;
|
||||
}
|
||||
|
||||
// Stable ordering used for skill lists in prompts: name (case-insensitive), then name, then path.
|
||||
@@ -368,7 +377,13 @@ export async function scanSkillsFromDir(
|
||||
}
|
||||
};
|
||||
|
||||
const work = [];
|
||||
const work: Promise<void>[] = [];
|
||||
if (options.includeSelf) {
|
||||
const selfSkillPath = path.join(dir, "SKILL.md");
|
||||
if (fs.existsSync(selfSkillPath)) {
|
||||
work.push(loadSkill(selfSkillPath));
|
||||
}
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue;
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
||||
|
||||
@@ -859,6 +859,45 @@ describe("listClaudePluginRoots", () => {
|
||||
expect(result.all.find(s => s.name === "extra-skill")).toBeDefined();
|
||||
});
|
||||
|
||||
test("array-form skills entry pointing at a directory containing SKILL.md loads the single skill", async () => {
|
||||
// Per Claude plugins reference: a skills path may point directly at a directory whose
|
||||
// SKILL.md is the skill (frontmatter name → invocation, directory basename → fallback).
|
||||
// Real plugins use `"skills": ["./"]` — that entry must not silently drop the skill.
|
||||
const pluginsDir = path.join(tempDir, ".claude", "plugins");
|
||||
const pluginPath = path.join(tempDir, "plugins", "manifest-skills-self");
|
||||
await fs.mkdir(pluginsDir, { recursive: true });
|
||||
await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true });
|
||||
await fs.mkdir(path.join(pluginPath, "single"), { recursive: true });
|
||||
|
||||
const registry = {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"manifest-skills-self@market": [
|
||||
{
|
||||
scope: "user",
|
||||
installPath: pluginPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2025-01-01T00:00:00Z",
|
||||
lastUpdated: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await fs.writeFile(path.join(pluginsDir, "installed_plugins.json"), JSON.stringify(registry));
|
||||
await fs.writeFile(
|
||||
path.join(pluginPath, ".claude-plugin", "plugin.json"),
|
||||
JSON.stringify({ skills: ["./single"] }),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(pluginPath, "single", "SKILL.md"),
|
||||
"---\nname: solo-skill\ndescription: Solo skill\n---\nBody\n",
|
||||
);
|
||||
|
||||
const result = await loadCapability<Skill>("skills", { cwd: tempDir });
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.all.find(s => s.name === "solo-skill")).toBeDefined();
|
||||
});
|
||||
|
||||
test("manifest commands field replaces default commands/ directory (Claude replace semantics)", async () => {
|
||||
// Per Claude plugins reference "Path behavior rules":
|
||||
// `commands` REPLACES the default `commands/` scan when the manifest key is set.
|
||||
|
||||
Reference in New Issue
Block a user