From e56a33c85729deb5c90ef4a72f91fae2f4e7584b Mon Sep 17 00:00:00 2001
From: Parsifa1
Date: Wed, 22 Apr 2026 21:17:48 +0800
Subject: [PATCH] fix(coding-agent): honor claude plugin manifest paths
---
.../src/discovery/claude-plugins.ts | 37 +++++++-
.../test/discovery/claude-plugins.test.ts | 94 ++++++++++++++++++-
2 files changed, 128 insertions(+), 3 deletions(-)
diff --git a/packages/coding-agent/src/discovery/claude-plugins.ts b/packages/coding-agent/src/discovery/claude-plugins.ts
index a56f3a1e9..6c6c6f046 100644
--- a/packages/coding-agent/src/discovery/claude-plugins.ts
+++ b/packages/coding-agent/src/discovery/claude-plugins.ts
@@ -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 {
+ 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 {
+ 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> {
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 {
- const commandsDir = path.join(root.path, "commands");
+ const commandsDir = await resolvePluginDir(root, "slash-commands", "commands");
return loadFilesFromDir(ctx, commandsDir, PROVIDER_ID, root.scope, {
extensions: ["md"],
transform: (name, content, filePath, source) => {
diff --git a/packages/coding-agent/test/discovery/claude-plugins.test.ts b/packages/coding-agent/test/discovery/claude-plugins.test.ts
index ffc8ba257..e425090d3 100644
--- a/packages/coding-agent/test/discovery/claude-plugins.test.ts
+++ b/packages/coding-agent/test/discovery/claude-plugins.test.ts
@@ -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", () => {