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", () => {