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"; import { loadCapability } from "@oh-my-pi/pi-coding-agent/capability"; import { clearCache as clearFsCache } from "@oh-my-pi/pi-coding-agent/capability/fs"; import { clearClaudePluginRootsCache, listClaudePluginRoots, parseClaudePluginsRegistry, } from "@oh-my-pi/pi-coding-agent/discovery/helpers"; import { loadSlashCommands } from "@oh-my-pi/pi-coding-agent/extensibility/slash-commands"; import { discoverAgents } from "@oh-my-pi/pi-coding-agent/task/discovery"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; import "@oh-my-pi/pi-coding-agent/discovery/claude-plugins"; import { type MCPServer, mcpCapability } from "@oh-my-pi/pi-coding-agent/capability/mcp"; import type { Skill } from "@oh-my-pi/pi-coding-agent/capability/skill"; import type { SlashCommand } from "@oh-my-pi/pi-coding-agent/capability/slash-command"; describe("parseClaudePluginsRegistry", () => { test("parses valid registry", () => { const content = JSON.stringify({ version: 2, plugins: { "my-plugin@marketplace": [ { scope: "user", installPath: "/path/to/plugin", version: "1.0.0", installedAt: "2025-01-01T00:00:00Z", lastUpdated: "2025-01-01T00:00:00Z", }, ], }, }); const result = parseClaudePluginsRegistry(content); expect(result).not.toBeNull(); expect(result?.version).toBe(2); expect(result?.plugins["my-plugin@marketplace"]).toHaveLength(1); }); test("returns null for invalid JSON", () => { expect(parseClaudePluginsRegistry("not json")).toBeNull(); }); test("returns null for missing version", () => { const content = JSON.stringify({ plugins: {} }); expect(parseClaudePluginsRegistry(content)).toBeNull(); }); test("returns null for missing plugins", () => { const content = JSON.stringify({ version: 2 }); expect(parseClaudePluginsRegistry(content)).toBeNull(); }); test("returns null for null plugins", () => { const content = JSON.stringify({ version: 2, plugins: null }); expect(parseClaudePluginsRegistry(content)).toBeNull(); }); }); 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 removeWithRetries(tempDir); }); test("returns empty roots when no registry file exists", async () => { const result = await listClaudePluginRoots(tempDir); expect(result.roots).toEqual([]); expect(result.warnings).toEqual([]); }); test("parses plugin with user scope", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); await fs.mkdir(pluginsDir, { recursive: true }); const registry = { version: 2, plugins: { "test-plugin@test-market": [ { scope: "user", installPath: "/path/to/test-plugin", 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)); const result = await listClaudePluginRoots(tempDir); expect(result.roots).toHaveLength(1); expect(result.roots[0]).toEqual({ id: "test-plugin@test-market", marketplace: "test-market", plugin: "test-plugin", version: "1.0.0", path: "/path/to/test-plugin", scope: "user", }); }); test("isolates local plugins to their canonical project", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const projectA = path.join(tempDir, "project-a"); const projectB = path.join(tempDir, "project-b"); const projectBAlias = path.join(tempDir, "project-b-alias"); const projectBSubdir = path.join(projectB, "packages", "app"); await Promise.all([ fs.mkdir(pluginsDir, { recursive: true }), fs.mkdir(path.join(projectA, ".git"), { recursive: true }), fs.mkdir(path.join(projectB, ".git"), { recursive: true }), fs.mkdir(projectBSubdir, { recursive: true }), ]); await fs.symlink(projectB, projectBAlias, "dir"); const entry = (scope: "user" | "local", installPath: string, projectPath?: string) => ({ scope, installPath, projectPath, version: "1.0.0", installedAt: "2025-01-01T00:00:00Z", lastUpdated: "2025-01-01T00:00:00Z", }); const registry = { version: 2, plugins: { "user-plugin@market": [entry("user", "/plugins/user")], "active-plugin@market": [entry("local", "/plugins/active", projectB)], "foreign-plugin@market": [entry("local", "/plugins/foreign", projectA)], }, }; await fs.writeFile(path.join(pluginsDir, "installed_plugins.json"), JSON.stringify(registry)); const result = await listClaudePluginRoots(tempDir, path.join(projectBAlias, "packages", "app")); expect(result.roots.map(root => root.id)).toEqual(["user-plugin@market", "active-plugin@market"]); expect(result.roots.find(root => root.id === "active-plugin@market")?.scope).toBe("project"); }); test("parses plugin with project scope", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); await fs.mkdir(pluginsDir, { recursive: true }); const registry = { version: 2, plugins: { "project-plugin@market": [ { scope: "project", installPath: "/path/to/project-plugin", version: "2.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)); const result = await listClaudePluginRoots(tempDir); expect(result.roots).toHaveLength(1); expect(result.roots[0].scope).toBe("project"); }); test("handles multiple entries per plugin ID", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); await fs.mkdir(pluginsDir, { recursive: true }); const registry = { version: 2, plugins: { "multi-plugin@market": [ { scope: "user", installPath: "/path/to/v2", version: "2.0.0", installedAt: "2025-01-02T00:00:00Z", lastUpdated: "2025-01-02T00:00:00Z", }, { scope: "project", installPath: "/path/to/v1", 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)); const result = await listClaudePluginRoots(tempDir); // Should return both entries, not just the first one expect(result.roots).toHaveLength(2); expect(result.roots[0].version).toBe("2.0.0"); expect(result.roots[0].scope).toBe("user"); expect(result.roots[1].version).toBe("1.0.0"); expect(result.roots[1].scope).toBe("project"); }); test("warns on invalid plugin ID format", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); await fs.mkdir(pluginsDir, { recursive: true }); const registry = { version: 2, plugins: { "invalid-no-at-symbol": [ { scope: "user", installPath: "/path/to/invalid", 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)); const result = await listClaudePluginRoots(tempDir); expect(result.roots).toHaveLength(0); expect(result.warnings).toHaveLength(1); expect(result.warnings[0]).toContain("Invalid plugin ID format"); }); test("warns on entry without installPath", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); await fs.mkdir(pluginsDir, { recursive: true }); const registry = { version: 2, plugins: { "no-path@market": [ { scope: "user", 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)); const result = await listClaudePluginRoots(tempDir); expect(result.roots).toHaveLength(0); expect(result.warnings).toHaveLength(1); expect(result.warnings[0]).toContain("has no installPath"); }); test("caches results for same home directory", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); await fs.mkdir(pluginsDir, { recursive: true }); const registry: { version: number; plugins: Record< string, Array<{ scope: string; installPath: string; version: string; installedAt: string; lastUpdated: string }> >; } = { version: 2, plugins: { "cached-plugin@market": [ { scope: "user", installPath: "/path/to/cached", 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)); // First call const result1 = await listClaudePluginRoots(tempDir); expect(result1.roots).toHaveLength(1); // Modify the file registry.plugins["new-plugin@market"] = [ { scope: "user", installPath: "/path/to/new", 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)); // Second call should return cached result (still 1 plugin) const result2 = await listClaudePluginRoots(tempDir); expect(result2.roots).toHaveLength(1); // After clearing cache, should see new plugin clearClaudePluginRootsCache(); clearFsCache(); // Also clear fs cache so the file is re-read const result3 = await listClaudePluginRoots(tempDir); expect(result3.roots).toHaveLength(2); }); test("defaults scope to user when not specified", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); await fs.mkdir(pluginsDir, { recursive: true }); const registry = { version: 2, plugins: { "no-scope@market": [ { installPath: "/path/to/no-scope", 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)); const result = await listClaudePluginRoots(tempDir); 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 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-skill"); expect(found).toBeDefined(); expect(found?.path).toContain(path.join(".claude", "skills", "manifest-skill", "SKILL.md")); }); test("keeps plugin skills out of slash commands while loading them as skills", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "understand-anything"); await fs.mkdir(pluginsDir, { recursive: true }); await fs.mkdir(path.join(pluginPath, "skills", "understand"), { recursive: true }); const registry = { version: 2, plugins: { "understand-anything@understand-anything": [ { scope: "user", installPath: pluginPath, version: "2.7.7", installedAt: "2026-06-12T00:00:00Z", lastUpdated: "2026-06-12T00:00:00Z", }, ], }, }; await fs.writeFile(path.join(pluginsDir, "installed_plugins.json"), JSON.stringify(registry)); await fs.writeFile( path.join(pluginPath, "skills", "understand", "SKILL.md"), "---\nname: understand\ndescription: Build an understanding graph\n---\nAnalyze the project.\n", ); const commands = await loadSlashCommands({ cwd: tempDir }); const skills = await loadCapability("skills", { cwd: tempDir }); expect(commands.find(command => command.name === "understand")).toBeUndefined(); expect(skills.all.find(skill => skill.name === "understand")?.frontmatter?.description).toBe( "Build an understanding graph", ); }); test("expands env placeholders in marketplace plugin MCP url and headers", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "context7"); const originalApiKey = process.env.OMP_PLUGIN_MCP_API_KEY; const originalUrl = process.env.OMP_PLUGIN_MCP_URL; const envPlaceholder = (name: string): string => ["$", "{", name, ":-}"].join(""); process.env.OMP_PLUGIN_MCP_API_KEY = "ctx7sk-test-key"; process.env.OMP_PLUGIN_MCP_URL = "https://mcp.context7.example"; try { await fs.mkdir(pluginsDir, { recursive: true }); await fs.mkdir(pluginPath, { recursive: true }); await fs.writeFile( path.join(pluginsDir, "installed_plugins.json"), JSON.stringify({ version: 2, plugins: { "context7@claude-plugins-official": [ { scope: "user", installPath: pluginPath, version: "1.0.0", installedAt: "2026-06-01T00:00:00Z", lastUpdated: "2026-06-01T00:00:00Z", }, ], }, }), ); await fs.writeFile( path.join(pluginPath, ".mcp.json"), JSON.stringify({ context7: { type: "http", url: `${envPlaceholder("OMP_PLUGIN_MCP_URL")}/mcp`, headers: { CONTEXT7_API_KEY: envPlaceholder("OMP_PLUGIN_MCP_API_KEY"), }, }, }), ); const result = await loadCapability(mcpCapability.id, { cwd: tempDir, providers: ["claude-plugins"], }); const server = result.all.find(item => item.name === "context7:context7"); expect(server).toBeDefined(); expect(server?.url).toBe("https://mcp.context7.example/mcp"); expect(server?.headers).toEqual({ CONTEXT7_API_KEY: "ctx7sk-test-key" }); } finally { if (originalApiKey === undefined) delete process.env.OMP_PLUGIN_MCP_API_KEY; else process.env.OMP_PLUGIN_MCP_API_KEY = originalApiKey; if (originalUrl === undefined) delete process.env.OMP_PLUGIN_MCP_URL; else process.env.OMP_PLUGIN_MCP_URL = originalUrl; } }); test("uses OMP then Claude manifest mcpServers paths before .mcp.json", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const ompPluginPath = path.join(tempDir, "plugins", "omp-pointer"); const claudePluginPath = path.join(tempDir, "plugins", "claude-pointer"); await fs.mkdir(pluginsDir, { recursive: true }); await Promise.all([ fs.mkdir(path.join(ompPluginPath, ".omp-plugin"), { recursive: true }), fs.mkdir(path.join(ompPluginPath, ".claude-plugin"), { recursive: true }), fs.mkdir(path.join(claudePluginPath, ".claude-plugin"), { recursive: true }), ]); await fs.writeFile( path.join(pluginsDir, "installed_plugins.json"), JSON.stringify({ version: 2, plugins: { "omp-pointer@market": [ { scope: "user", installPath: ompPluginPath, version: "1.0.0", installedAt: "2026-07-28T00:00:00Z", lastUpdated: "2026-07-28T00:00:00Z", }, ], "claude-pointer@market": [ { scope: "user", installPath: claudePluginPath, version: "1.0.0", installedAt: "2026-07-28T00:00:00Z", lastUpdated: "2026-07-28T00:00:00Z", }, ], }, }), ); await Promise.all([ fs.writeFile( path.join(ompPluginPath, ".omp-plugin", "plugin.json"), JSON.stringify({ mcpServers: "./mcp-omp.json" }), ), fs.writeFile( path.join(ompPluginPath, ".claude-plugin", "plugin.json"), JSON.stringify({ mcpServers: "./mcp-claude.json" }), ), fs.writeFile(path.join(ompPluginPath, "mcp-omp.json"), JSON.stringify({ "from-omp": { command: "omp" } })), fs.writeFile( path.join(ompPluginPath, "mcp-claude.json"), JSON.stringify({ "from-claude": { command: "claude" } }), ), fs.writeFile(path.join(ompPluginPath, ".mcp.json"), JSON.stringify({ "from-root": { command: "root" } })), fs.writeFile( path.join(claudePluginPath, ".claude-plugin", "plugin.json"), JSON.stringify({ mcpServers: "./mcp-claude.json" }), ), fs.writeFile( path.join(claudePluginPath, "mcp-claude.json"), JSON.stringify({ "from-claude": { command: "claude" } }), ), fs.writeFile(path.join(claudePluginPath, ".mcp.json"), JSON.stringify({ "from-root": { command: "root" } })), ]); const result = await loadCapability(mcpCapability.id, { cwd: tempDir, providers: ["claude-plugins"], }); expect(result.warnings).toEqual([]); expect(result.all.map(server => server.name).sort()).toEqual([ "claude-pointer:from-claude", "omp-pointer:from-omp", ]); }); test("loads inline manifest mcpServers object and roots relative stdio at plugin root", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "inline-mcp"); await fs.mkdir(pluginsDir, { recursive: true }); await fs.mkdir(path.join(pluginPath, ".omp-plugin"), { recursive: true }); await fs.writeFile( path.join(pluginsDir, "installed_plugins.json"), JSON.stringify({ version: 2, plugins: { "inline-mcp@market": [ { scope: "user", installPath: pluginPath, version: "1.0.0", installedAt: "2026-07-28T00:00:00Z", lastUpdated: "2026-07-28T00:00:00Z", }, ], }, }), ); // Inline object form: the manifest carries the server map directly, and no // root .mcp.json exists, so the pre-fix fallback would register nothing. await fs.writeFile( path.join(pluginPath, ".omp-plugin", "plugin.json"), JSON.stringify({ mcpServers: { local: { command: "./bin/server", args: ["run"] } } }), ); const result = await loadCapability(mcpCapability.id, { cwd: tempDir, providers: ["claude-plugins"], }); expect(result.warnings).toEqual([]); const server = result.all.find(item => item.name === "inline-mcp:local"); expect(server).toBeDefined(); expect(server?.command).toBe(path.join(pluginPath, "bin", "server")); expect(server?.args).toEqual(["run"]); }); test("warns when a manifest mcpServers pointer names a missing file", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "broken-pointer"); await fs.mkdir(pluginsDir, { recursive: true }); await fs.mkdir(path.join(pluginPath, ".omp-plugin"), { recursive: true }); await fs.writeFile( path.join(pluginsDir, "installed_plugins.json"), JSON.stringify({ version: 2, plugins: { "broken-pointer@market": [ { scope: "user", installPath: pluginPath, version: "1.0.0", installedAt: "2026-07-28T00:00:00Z", lastUpdated: "2026-07-28T00:00:00Z", }, ], }, }), ); // Pointer names a file the plugin never shipped: discovery must say so // instead of silently registering nothing. await fs.writeFile( path.join(pluginPath, ".omp-plugin", "plugin.json"), JSON.stringify({ mcpServers: "./mcp-omp.json" }), ); await fs.writeFile(path.join(pluginPath, ".mcp.json"), JSON.stringify({ "from-root": { command: "root" } })); const result = await loadCapability(mcpCapability.id, { cwd: tempDir, providers: ["claude-plugins"], }); expect(result.all.map(server => server.name)).toEqual([]); expect(result.warnings).toEqual([ `[Claude Code Marketplace] [claude-plugins] Missing mcpServers file declared by broken-pointer@market: ${path.join(pluginPath, "mcp-omp.json")}`, ]); }); test("deduplicates a plugin alias of a directly configured MCP connection", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "context7"); const directConfigPath = path.join(tempDir, ".omp", "mcp.json"); const connection = { type: "http", url: "https://mcp.context7.example/mcp", headers: { CONTEXT7_API_KEY: "ctx7sk-test-key" }, }; await fs.mkdir(pluginsDir, { recursive: true }); await fs.mkdir(pluginPath, { recursive: true }); await fs.mkdir(path.dirname(directConfigPath), { recursive: true }); await fs.writeFile( path.join(pluginsDir, "installed_plugins.json"), JSON.stringify({ version: 2, plugins: { "context7@claude-plugins-official": [ { scope: "user", installPath: pluginPath, version: "1.0.0", installedAt: "2026-06-01T00:00:00Z", lastUpdated: "2026-06-01T00:00:00Z", }, ], }, }), ); await fs.writeFile(path.join(pluginPath, ".mcp.json"), JSON.stringify({ context7: connection })); await fs.writeFile( directConfigPath, JSON.stringify({ mcpServers: { context7: connection }, }), ); const result = await loadCapability(mcpCapability.id, { cwd: tempDir, providers: ["native", "claude-plugins"], }); expect(result.items.map(server => server.name)).toEqual(["context7"]); expect(result.items[0]?._source.provider).toBe("native"); expect(result.all.find(server => server.name === "context7:context7")?._shadowed).toBe(true); }); test("resolves relative path-like command and cwd against the plugin config directory", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "computer-use"); await fs.mkdir(pluginsDir, { recursive: true }); await fs.mkdir(pluginPath, { recursive: true }); await fs.writeFile( path.join(pluginsDir, "installed_plugins.json"), JSON.stringify({ version: 2, plugins: { "computer-use@openai-bundled": [ { scope: "user", installPath: pluginPath, version: "1.0.0", installedAt: "2026-06-01T00:00:00Z", lastUpdated: "2026-06-01T00:00:00Z", }, ], }, }), ); await fs.writeFile( path.join(pluginPath, ".mcp.json"), JSON.stringify({ mcpServers: { "computer-use": { command: "./bin/SkyComputerUseClient", args: ["mcp"], cwd: "." }, bare: { command: "npx", args: ["-y", "@some/mcp"] }, invalidCwd: { command: "npx", cwd: 1 }, }, }), ); // Session cwd is deliberately outside the plugin directory. const result = await loadCapability(mcpCapability.id, { cwd: path.join(tempDir, "elsewhere"), providers: ["claude-plugins"], }); const local = result.all.find(item => item.name === "computer-use:computer-use"); const bare = result.all.find(item => item.name === "computer-use:bare"); const invalidCwd = result.all.find(item => item.name === "computer-use:invalidCwd"); expect(local?.command).toBe(path.join(pluginPath, "bin", "SkyComputerUseClient")); expect(local?.cwd).toBe(pluginPath); // Bare executables must keep resolving through PATH, not the plugin dir. expect(bare?.command).toBe("npx"); expect(bare?.cwd).toBeUndefined(); expect(invalidCwd?.command).toBe("npx"); expect(invalidCwd?.cwd).toBeUndefined(); }); 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 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")); }); test("reads slash commands directory from plugin manifest commands field (standard Claude plugin format)", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "manifest-commands-key"); 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-key@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: "./.claude/commands" }), ); await fs.writeFile(path.join(pluginPath, ".claude", "commands", "plan.md"), "Plan it\n"); const result = await loadCapability("slash-commands", { cwd: tempDir }); expect(result.warnings).toEqual([]); const found = result.all.find(command => command.name === "manifest-commands-key:plan"); expect(found).toBeDefined(); expect(found?.path).toContain(path.join(".claude", "commands", "plan.md")); }); test("commands field takes precedence over slash-commands field when both are present", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "manifest-commands-precedence"); await fs.mkdir(path.join(pluginsDir), { recursive: true }); await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true }); // commands points to .claude/commands, slash-commands points to a different dir await fs.mkdir(path.join(pluginPath, ".claude", "commands"), { recursive: true }); await fs.mkdir(path.join(pluginPath, "legacy-commands"), { recursive: true }); const registry = { version: 2, plugins: { "manifest-commands-precedence@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: "./.claude/commands", "slash-commands": "./legacy-commands" }), ); await fs.writeFile(path.join(pluginPath, ".claude", "commands", "ship.md"), "Ship it\n"); // This file exists only under the legacy dir — should NOT be found await fs.writeFile(path.join(pluginPath, "legacy-commands", "old.md"), "Old\n"); const result = await loadCapability("slash-commands", { cwd: tempDir }); expect(result.warnings).toEqual([]); const found = result.all.find(command => command.name === "manifest-commands-precedence:ship"); const notFound = result.all.find(command => command.name === "manifest-commands-precedence:old"); expect(found).toBeDefined(); expect(notFound).toBeUndefined(); }); test("ignores manifest skills directory that resolves outside plugin root", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "manifest-skills-outside"); const outsideDir = path.join(tempDir, "outside-skills", "outside-skill"); await fs.mkdir(path.join(pluginsDir), { recursive: true }); await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true }); await fs.mkdir(outsideDir, { recursive: true }); const registry = { version: 2, plugins: { "manifest-skills-outside@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: "../../outside-skills" }), ); await fs.writeFile( path.join(outsideDir, "SKILL.md"), "---\nname: outside-skill\ndescription: Outside skill\n---\nBody\n", ); const result = await loadCapability("skills", { cwd: tempDir }); expect(result.warnings[0]).toContain("Ignoring skills path outside plugin root"); const found = result.all.find(skill => skill.name === "outside-skill"); expect(found).toBeUndefined(); }); test("ignores manifest slash commands directory that resolves outside plugin root", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "manifest-commands-outside"); const outsideDir = path.join(tempDir, "outside-commands"); await fs.mkdir(path.join(pluginsDir), { recursive: true }); await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true }); await fs.mkdir(outsideDir, { recursive: true }); const registry = { version: 2, plugins: { "manifest-commands-outside@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": "../../outside-commands" }), ); await fs.writeFile(path.join(outsideDir, "ship.md"), "Ship it\n"); const result = await loadCapability("slash-commands", { cwd: tempDir }); expect(result.warnings[0]).toContain("Ignoring slash-commands path outside plugin root"); const found = result.all.find(command => command.name === "manifest-commands-outside:ship"); expect(found).toBeUndefined(); }); test("reads slash commands from array-form commands manifest field (Claude plugin path-behavior rules)", async () => { // Mirrors real-world plugins such as addyosmani/agent-skills whose plugin.json // declares `"commands": ["./.claude/commands", "./commands"]`. Both directories // contribute; each command lands under the plugin's namespace. const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "manifest-commands-array"); await fs.mkdir(pluginsDir, { recursive: true }); await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true }); await fs.mkdir(path.join(pluginPath, ".claude", "commands"), { recursive: true }); await fs.mkdir(path.join(pluginPath, "commands"), { recursive: true }); const registry = { version: 2, plugins: { "manifest-commands-array@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: ["./.claude/commands", "./commands"] }), ); await fs.writeFile(path.join(pluginPath, ".claude", "commands", "spec.md"), "Spec\n"); await fs.writeFile(path.join(pluginPath, ".claude", "commands", "plan.md"), "Plan\n"); await fs.writeFile(path.join(pluginPath, "commands", "review.md"), "Review\n"); const result = await loadCapability("slash-commands", { cwd: tempDir }); expect(result.warnings).toEqual([]); const names = result.all .filter(command => command.name.startsWith("manifest-commands-array:")) .map(command => command.name) .sort(); expect(names).toEqual([ "manifest-commands-array:plan", "manifest-commands-array:review", "manifest-commands-array:spec", ]); }); 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("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"); const outsideDir = path.join(tempDir, "outside-commands"); await fs.mkdir(pluginsDir, { recursive: true }); await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true }); await fs.mkdir(path.join(pluginPath, ".claude", "commands"), { recursive: true }); await fs.mkdir(outsideDir, { recursive: true }); const registry = { version: 2, plugins: { "manifest-commands-mixed@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: ["./.claude/commands", "../../outside-commands"] }), ); await fs.writeFile(path.join(pluginPath, ".claude", "commands", "spec.md"), "Spec\n"); await fs.writeFile(path.join(outsideDir, "escape.md"), "Escape\n"); const result = await loadCapability("slash-commands", { cwd: tempDir }); expect(result.warnings.some(w => w.includes("Ignoring commands path outside plugin root"))).toBe(true); expect(result.all.find(c => c.name === "manifest-commands-mixed:spec")).toBeDefined(); expect(result.all.find(c => c.name === "manifest-commands-mixed:escape")).toBeUndefined(); }); test("reads skills from array-form skills manifest field", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "manifest-skills-array"); await fs.mkdir(pluginsDir, { recursive: true }); await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true }); await fs.mkdir(path.join(pluginPath, "extra-skills", "alpha"), { recursive: true }); await fs.mkdir(path.join(pluginPath, "more-skills", "beta"), { recursive: true }); const registry = { version: 2, plugins: { "manifest-skills-array@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: ["./extra-skills", "./more-skills"] }), ); await fs.writeFile( path.join(pluginPath, "extra-skills", "alpha", "SKILL.md"), "---\nname: alpha\ndescription: Alpha skill\n---\nBody\n", ); await fs.writeFile( path.join(pluginPath, "more-skills", "beta", "SKILL.md"), "---\nname: beta\ndescription: Beta skill\n---\nBody\n", ); const result = await loadCapability("skills", { cwd: tempDir }); expect(result.warnings).toEqual([]); expect(result.all.find(s => s.name === "alpha")).toBeDefined(); expect(result.all.find(s => s.name === "beta")).toBeDefined(); }); test("manifest skills field merges with default skills/ directory (adds, not replaces)", async () => { // Per Claude plugins reference "Path behavior rules": // `skills` adds to the default `skills/` scan; the default is always loaded // alongside any manifest-declared directories. const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "manifest-skills-merge"); await fs.mkdir(pluginsDir, { recursive: true }); await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true }); await fs.mkdir(path.join(pluginPath, "skills", "default-skill"), { recursive: true }); await fs.mkdir(path.join(pluginPath, "extra-skills", "extra-skill"), { recursive: true }); const registry = { version: 2, plugins: { "manifest-skills-merge@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: ["./extra-skills"] }), ); await fs.writeFile( path.join(pluginPath, "skills", "default-skill", "SKILL.md"), "---\nname: default-skill\ndescription: Default skill\n---\nBody\n", ); await fs.writeFile( path.join(pluginPath, "extra-skills", "extra-skill", "SKILL.md"), "---\nname: extra-skill\ndescription: Extra skill\n---\nBody\n", ); const result = await loadCapability("skills", { cwd: tempDir }); expect(result.warnings).toEqual([]); expect(result.all.find(s => s.name === "default-skill")).toBeDefined(); expect(result.all.find(s => s.name === "extra-skill")).toBeDefined(); }); test("marketplace-root skills manifest field replaces default skills directory", async () => { // Claude path-behavior rules carve out marketplace entries whose source is the // marketplace root: their manifest `skills` field selects the published // subdirectories instead of also loading the root `skills/` directory. const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "manifest-skills-marketplace-root"); await fs.mkdir(pluginsDir, { recursive: true }); await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true }); await fs.mkdir(path.join(pluginPath, "skills", "unpublished-root-skill"), { recursive: true }); await fs.mkdir(path.join(pluginPath, "plugins", "published", "skills", "published-skill"), { recursive: true, }); const registry = { version: 2, plugins: { "manifest-skills-marketplace-root@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, "marketplace.json"), JSON.stringify({ name: "market", owner: { name: "Market" }, plugins: [{ name: "manifest-skills-marketplace-root", source: "./" }], }), ); await fs.writeFile( path.join(pluginPath, ".claude-plugin", "plugin.json"), JSON.stringify({ skills: ["./plugins/published/skills"] }), ); await fs.writeFile( path.join(pluginPath, "skills", "unpublished-root-skill", "SKILL.md"), "---\nname: unpublished-root-skill\ndescription: Unpublished root skill\n---\nBody\n", ); await fs.writeFile( path.join(pluginPath, "plugins", "published", "skills", "published-skill", "SKILL.md"), "---\nname: published-skill\ndescription: Published skill\n---\nBody\n", ); const result = await loadCapability("skills", { cwd: tempDir }); expect(result.warnings).toEqual([]); expect(result.all.find(s => s.name === "published-skill")).toBeDefined(); expect(result.all.find(s => s.name === "unpublished-root-skill")).toBeUndefined(); }); test("array-form skills entry pointing at a directory containing SKILL.md loads the single skill", async () => { // Per Claude plugins reference: a skills path may point directly at a directory whose // SKILL.md is the skill (frontmatter name → invocation, directory basename → fallback). // Real plugins use `"skills": ["./"]` — that entry must not silently drop the skill. const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "manifest-skills-self"); await fs.mkdir(pluginsDir, { recursive: true }); await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true }); await fs.mkdir(path.join(pluginPath, "single"), { recursive: true }); const registry = { version: 2, plugins: { "manifest-skills-self@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: ["./single"] }), ); await fs.writeFile( path.join(pluginPath, "single", "SKILL.md"), "---\nname: solo-skill\ndescription: Solo skill\n---\nBody\n", ); const result = await loadCapability("skills", { cwd: tempDir }); expect(result.warnings).toEqual([]); expect(result.all.find(s => s.name === "solo-skill")).toBeDefined(); }); test("manifest commands field replaces default commands/ directory (Claude replace semantics)", async () => { // Per Claude plugins reference "Path behavior rules": // `commands` REPLACES the default `commands/` scan when the manifest key is set. // A plugin that wants both must list `./commands` explicitly. const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "manifest-commands-replace"); await fs.mkdir(pluginsDir, { recursive: true }); await fs.mkdir(path.join(pluginPath, ".claude-plugin"), { recursive: true }); await fs.mkdir(path.join(pluginPath, "commands"), { recursive: true }); await fs.mkdir(path.join(pluginPath, "admin-commands"), { recursive: true }); const registry = { version: 2, plugins: { "manifest-commands-replace@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: ["./admin-commands"] }), ); // This file lives under the default commands/ dir and MUST NOT load once the // manifest declares `commands` (Claude's documented "replaces default" semantic). await fs.writeFile(path.join(pluginPath, "commands", "default.md"), "Default\n"); await fs.writeFile(path.join(pluginPath, "admin-commands", "admin.md"), "Admin\n"); const result = await loadCapability("slash-commands", { cwd: tempDir }); expect(result.warnings).toEqual([]); expect(result.all.find(c => c.name === "manifest-commands-replace:admin")).toBeDefined(); expect(result.all.find(c => c.name === "manifest-commands-replace:default")).toBeUndefined(); }); }); describe("discoverAgents plugin precedence", () => { let tempDir: string; beforeEach(async () => { clearClaudePluginRootsCache(); clearFsCache(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "claude-plugins-precedence-test-")); }); afterEach(async () => { clearClaudePluginRootsCache(); await removeWithRetries(tempDir); }); test("prefers project-scoped plugin agent over user-scoped plugin agent", async () => { const pluginRegistryDir = path.join(tempDir, ".claude", "plugins"); const projectPluginPath = path.join(tempDir, "plugins", "project"); const userPluginPath = path.join(tempDir, "plugins", "user"); const agentName = "plugin-precedence-test-agent"; await fs.mkdir(pluginRegistryDir, { recursive: true }); await fs.mkdir(path.join(projectPluginPath, "agents"), { recursive: true }); await fs.mkdir(path.join(userPluginPath, "agents"), { recursive: true }); const projectAgent = `---\nname: ${agentName}\ndescription: Project plugin version\n---\nProject scope agent`; const userAgent = `---\nname: ${agentName}\ndescription: User plugin version\n---\nUser scope agent`; await fs.writeFile(path.join(projectPluginPath, "agents", "shared.md"), projectAgent); await fs.writeFile(path.join(userPluginPath, "agents", "shared.md"), userAgent); const registry = { version: 2, plugins: { "shared-plugin@market": [ { scope: "user", installPath: userPluginPath, version: "1.0.0", installedAt: "2025-01-01T00:00:00Z", lastUpdated: "2025-01-01T00:00:00Z", }, { scope: "project", installPath: projectPluginPath, version: "1.0.1", installedAt: "2025-01-02T00:00:00Z", lastUpdated: "2025-01-02T00:00:00Z", }, ], }, }; await fs.writeFile(path.join(pluginRegistryDir, "installed_plugins.json"), JSON.stringify(registry)); const result = await discoverAgents(tempDir, tempDir); const found = result.agents.find(agent => agent.name === agentName); expect(found).toBeDefined(); expect(found?.source).toBe("project"); expect(found?.filePath).toContain(projectPluginPath); }); });