From c42d2b290741e6c2abb637d44613b4b7cf1c1085 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 5 Jul 2026 12:16:02 +0000 Subject: [PATCH] fix(discovery): load file-form Claude plugin commands Claude plugin manifests allow command path entries to name either directories or flat `.md` command files. The array resolver was now preserving those file paths, but `loadSlashCommands` still sent every resolved entry through `loadFilesFromDir`, which only globs inside directories. A manifest such as `{"commands":["./custom/deploy.md"]}` therefore replaced the default scan and then loaded nothing. Teach the command loader to stat each resolved entry: `.md` files are read as single slash commands with the same plugin namespace and source metadata as directory-loaded files; directories continue through `loadFilesFromDir`. Missing entries keep the existing silent-empty behavior. Add a regression test covering a mixed array of a direct command file and a command directory while proving default `commands/` remains replaced unless listed explicitly. --- .../src/discovery/claude-plugins.ts | 32 ++++++++++++-- .../test/discovery/claude-plugins.test.ts | 42 +++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/discovery/claude-plugins.ts b/packages/coding-agent/src/discovery/claude-plugins.ts index f9647c124..24e938ce7 100644 --- a/packages/coding-agent/src/discovery/claude-plugins.ts +++ b/packages/coding-agent/src/discovery/claude-plugins.ts @@ -4,6 +4,7 @@ * Loads configuration from ~/.claude/plugins/cache/ based on installed_plugins.json registry. * Priority: 70 (below claude.ts at 80, so user overrides in .claude/ take precedence) */ +import * as fs from "node:fs/promises"; import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; import { registerProvider } from "../capability"; @@ -209,8 +210,31 @@ async function loadSlashCommands(ctx: LoadContext): Promise - loadFilesFromDir(ctx, dir, PROVIDER_ID, root.scope, { + commandsDirs.map(async dir => { + try { + const stats = await fs.stat(dir); + if (stats.isFile()) { + if (path.extname(dir) !== ".md") return { items: [], warnings: [] }; + const content = await readFile(dir); + if (content === null) return { items: [], warnings: [`Failed to read file: ${dir}`] }; + const cmdName = path.basename(dir).replace(/\.md$/, ""); + return { + items: [ + { + name: root.plugin ? `${root.plugin}:${cmdName}` : cmdName, + path: dir, + content, + level: root.scope, + _source: createSourceMeta(PROVIDER_ID, dir, root.scope), + }, + ], + warnings: [], + }; + } + } catch { + // Missing entries behave like missing directories: no items, no warning. + } + return loadFilesFromDir(ctx, dir, PROVIDER_ID, root.scope, { extensions: ["md"], transform: (name, content, filePath, source) => { const cmdName = name.replace(/\.md$/, ""); @@ -222,8 +246,8 @@ async function loadSlashCommands(ctx: LoadContext): Promise { ]); }); + test("reads slash commands from array-form manifest file entries", async () => { + // Claude plugins reference allows command paths to be either flat `.md` + // files or directories. A manifest-declared commands field still replaces + // default `commands/`; plugins that want defaults must list `./commands`. + const pluginsDir = path.join(tempDir, ".claude", "plugins"); + const pluginPath = path.join(tempDir, "plugins", "manifest-commands-files"); + await fs.mkdir(pluginsDir, { recursive: true }); + await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true }); + await fs.mkdir(path.join(pluginPath, "custom"), { recursive: true }); + await fs.mkdir(path.join(pluginPath, "ops"), { recursive: true }); + await fs.mkdir(path.join(pluginPath, "commands"), { recursive: true }); + + const registry = { + version: 2, + plugins: { + "manifest-commands-files@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({ commands: ["./custom/deploy.md", "./ops"] }), + ); + await fs.writeFile(path.join(pluginPath, "custom", "deploy.md"), "Deploy\n"); + await fs.writeFile(path.join(pluginPath, "ops", "rollback.md"), "Rollback\n"); + await fs.writeFile(path.join(pluginPath, "commands", "default.md"), "Default\n"); + + const result = await loadCapability("slash-commands", { cwd: tempDir }); + expect(result.warnings).toEqual([]); + expect(result.all.find(c => c.name === "manifest-commands-files:deploy")?.content).toBe("Deploy\n"); + expect(result.all.find(c => c.name === "manifest-commands-files:rollback")?.content).toBe("Rollback\n"); + expect(result.all.find(c => c.name === "manifest-commands-files:default")).toBeUndefined(); + }); + test("array-form commands warns on out-of-root entries while loading valid ones", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "manifest-commands-mixed");