Files
oh-my-pi/packages/coding-agent/test/discovery/claude-plugins.test.ts
T

367 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
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";
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;
beforeEach(async () => {
clearClaudePluginRootsCache();
clearFsCache();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "claude-plugins-test-"));
});
afterEach(async () => {
clearClaudePluginRootsCache();
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");
});
});
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);
});
});