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:
roboomp
2026-07-05 12:23:18 +00:00
parent c42d2b2907
commit c92cf0a905
3 changed files with 56 additions and 1 deletions
@@ -169,6 +169,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
dir,
providerId: PROVIDER_ID,
level: root.scope,
includeSelf: true,
}),
),
);
+16 -1
View File
@@ -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.