536 lines
17 KiB
TypeScript
536 lines
17 KiB
TypeScript
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 { discoverAgents } from "@oh-my-pi/pi-coding-agent/task/discovery";
|
|
import "@oh-my-pi/pi-coding-agent/discovery/claude-plugins";
|
|
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 fs.rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
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("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<Skill>("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 result = await loadCapability<SlashCommand>("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("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<Skill>("skills", { cwd: tempDir });
|
|
expect(result.warnings[0]).toContain("Ignoring skills path outside plugin root");
|
|
const found = result.all.find(skill => skill.name === "manifest-skills-outside: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<SlashCommand>("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();
|
|
});
|
|
});
|
|
|
|
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 fs.rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|