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.
This commit is contained in:
roboomp
2026-07-05 12:16:02 +00:00
parent f276d80fc4
commit c42d2b2907
2 changed files with 70 additions and 4 deletions
@@ -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<LoadResult<SlashComm
false,
);
const commandResults = await Promise.all(
commandsDirs.map(dir =>
loadFilesFromDir<SlashCommand>(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<SlashCommand>(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<LoadResult<SlashComm
_source: source,
};
},
}),
),
});
}),
);
return { commandResults, resolveWarnings };
}),
@@ -693,6 +693,48 @@ describe("listClaudePluginRoots", () => {
]);
});
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<SlashCommand>("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");