From c92cf0a9054e2384cb3224ec49c79535a35d9f47 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 5 Jul 2026 12:23:18 +0000 Subject: [PATCH] fix(discovery): load Claude plugin skill paths that point at a SKILL.md directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `//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, `/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. --- .../src/discovery/claude-plugins.ts | 1 + .../coding-agent/src/discovery/helpers.ts | 17 +++++++- .../test/discovery/claude-plugins.test.ts | 39 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/discovery/claude-plugins.ts b/packages/coding-agent/src/discovery/claude-plugins.ts index 24e938ce7..7cfd8ad83 100644 --- a/packages/coding-agent/src/discovery/claude-plugins.ts +++ b/packages/coding-agent/src/discovery/claude-plugins.ts @@ -169,6 +169,7 @@ async function loadSkills(ctx: LoadContext): Promise> { dir, providerId: PROVIDER_ID, level: root.scope, + includeSelf: true, }), ), ); diff --git a/packages/coding-agent/src/discovery/helpers.ts b/packages/coding-agent/src/discovery/helpers.ts index d1e6e7c91..8535fa0ad 100644 --- a/packages/coding-agent/src/discovery/helpers.ts +++ b/packages/coding-agent/src/discovery/helpers.ts @@ -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 `//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[] = []; + 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; diff --git a/packages/coding-agent/test/discovery/claude-plugins.test.ts b/packages/coding-agent/test/discovery/claude-plugins.test.ts index a48ca31d3..b7afdd239 100644 --- a/packages/coding-agent/test/discovery/claude-plugins.test.ts +++ b/packages/coding-agent/test/discovery/claude-plugins.test.ts @@ -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("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.