fix(coding-agent): honor claude plugin manifest paths
This commit is contained in:
@@ -28,6 +28,39 @@ const PROVIDER_ID = "claude-plugins";
|
||||
const DISPLAY_NAME = "Claude Code Marketplace";
|
||||
const PRIORITY = 70; // Below claude.ts (80) so user .claude/ overrides win
|
||||
|
||||
interface ClaudePluginManifest {
|
||||
skills?: string;
|
||||
"slash-commands"?: string;
|
||||
}
|
||||
|
||||
async function readPluginManifest(root: ClaudePluginRoot): Promise<ClaudePluginManifest | null> {
|
||||
const manifestPath = path.join(root.path, ".claude-plugin", "plugin.json");
|
||||
const raw = await readFile(manifestPath);
|
||||
if (raw === null) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
return parsed as ClaudePluginManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePluginDir(
|
||||
root: ClaudePluginRoot,
|
||||
manifestKey: keyof ClaudePluginManifest,
|
||||
fallback: string,
|
||||
): Promise<string> {
|
||||
const manifest = await readPluginManifest(root);
|
||||
const configured = manifest?.[manifestKey];
|
||||
if (typeof configured === "string" && configured.trim()) {
|
||||
return path.resolve(root.path, configured.trim());
|
||||
}
|
||||
|
||||
return path.join(root.path, fallback);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Skills
|
||||
// =============================================================================
|
||||
@@ -41,7 +74,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
|
||||
const results = await Promise.all(
|
||||
roots.map(async root => {
|
||||
const skillsDir = path.join(root.path, "skills");
|
||||
const skillsDir = await resolvePluginDir(root, "skills", "skills");
|
||||
const result = await scanSkillsFromDir(ctx, {
|
||||
dir: skillsDir,
|
||||
providerId: PROVIDER_ID,
|
||||
@@ -75,7 +108,7 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
|
||||
|
||||
const results = await Promise.all(
|
||||
roots.map(async root => {
|
||||
const commandsDir = path.join(root.path, "commands");
|
||||
const commandsDir = await resolvePluginDir(root, "slash-commands", "commands");
|
||||
return loadFilesFromDir<SlashCommand>(ctx, commandsDir, PROVIDER_ID, root.scope, {
|
||||
extensions: ["md"],
|
||||
transform: (name, content, filePath, source) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
parseClaudePluginsRegistry,
|
||||
} from "@oh-my-pi/pi-coding-agent/discovery/helpers";
|
||||
import { discoverAgents } from "@oh-my-pi/pi-coding-agent/task/discovery";
|
||||
import "@oh-my-pi/pi-coding-agent/discovery/claude-plugins";
|
||||
|
||||
describe("parseClaudePluginsRegistry", () => {
|
||||
test("parses valid registry", () => {
|
||||
@@ -55,15 +56,26 @@ describe("parseClaudePluginsRegistry", () => {
|
||||
|
||||
describe("listClaudePluginRoots", () => {
|
||||
let tempDir: string;
|
||||
let originalHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearClaudePluginRootsCache();
|
||||
clearFsCache();
|
||||
originalHome = process.env.HOME;
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "claude-plugins-test-"));
|
||||
process.env.HOME = tempDir;
|
||||
vi.spyOn(os, "homedir").mockReturnValue(tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
clearClaudePluginRootsCache();
|
||||
clearFsCache();
|
||||
vi.restoreAllMocks();
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -300,6 +312,86 @@ describe("listClaudePluginRoots", () => {
|
||||
expect(result.roots).toHaveLength(1);
|
||||
expect(result.roots[0].scope).toBe("user");
|
||||
});
|
||||
test("reads skills directory from plugin manifest skills field", async () => {
|
||||
const pluginsDir = path.join(tempDir, ".claude", "plugins");
|
||||
const pluginPath = path.join(tempDir, "plugins", "manifest-skills");
|
||||
await fs.mkdir(path.join(pluginsDir), { recursive: true });
|
||||
await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true });
|
||||
await fs.mkdir(path.join(pluginPath, ".claude", "skills", "manifest-skill"), { recursive: true });
|
||||
|
||||
const registry = {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"manifest-skills@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: "./.claude/skills" }),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(pluginPath, ".claude", "skills", "manifest-skill", "SKILL.md"),
|
||||
"---\nname: manifest-skill\ndescription: Manifest skill\n---\nBody\n",
|
||||
);
|
||||
|
||||
const { loadCapability } = await import("@oh-my-pi/pi-coding-agent/capability");
|
||||
const result = await loadCapability("skills", { cwd: tempDir });
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.all.length).toBeGreaterThan(0);
|
||||
const found = result.all.find(skill => skill.name === "manifest-skills:manifest-skill");
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.path).toContain(path.join(".claude", "skills", "manifest-skill", "SKILL.md"));
|
||||
});
|
||||
|
||||
test("reads slash commands directory from plugin manifest slash-commands field", async () => {
|
||||
const pluginsDir = path.join(tempDir, ".claude", "plugins");
|
||||
const pluginPath = path.join(tempDir, "plugins", "manifest-commands");
|
||||
await fs.mkdir(path.join(pluginsDir), { recursive: true });
|
||||
await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true });
|
||||
await fs.mkdir(path.join(pluginPath, ".claude", "commands"), { recursive: true });
|
||||
|
||||
const registry = {
|
||||
version: 2,
|
||||
plugins: {
|
||||
"manifest-commands@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({ "slash-commands": "./.claude/commands" }),
|
||||
);
|
||||
await fs.writeFile(path.join(pluginPath, ".claude", "commands", "ship.md"), "Ship it\n");
|
||||
|
||||
const { loadCapability } = await import("@oh-my-pi/pi-coding-agent/capability");
|
||||
const result = await loadCapability("slash-commands", { cwd: tempDir });
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.all.length).toBeGreaterThan(0);
|
||||
const found = result.all.find(command => command.name === "manifest-commands:ship");
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.path).toContain(path.join(".claude", "commands", "ship.md"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverAgents plugin precedence", () => {
|
||||
|
||||
Reference in New Issue
Block a user