fix(coding-agent): honor claude plugin manifest paths

This commit is contained in:
Parsifa1
2026-04-22 21:17:48 +08:00
committed by can1357
parent d0c40b1d5d
commit e56a33c857
2 changed files with 128 additions and 3 deletions
@@ -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", () => {