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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user