Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions src/__tests__/unit/plugin-marketplace-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

import {
buildMarketplacePluginRef,
createMarketplacePluginSpawnSpec,
} from '../../lib/plugin-marketplace-cli';

describe('buildMarketplacePluginRef', () => {
it('should build a plugin reference from safe name and marketplace', () => {
assert.equal(
buildMarketplacePluginRef({ name: 'safe-plugin', marketplace: 'official.market' }),
'safe-plugin@official.market'
);
});

it('should allow plugin names without marketplace', () => {
assert.equal(
buildMarketplacePluginRef({ name: 'safe_plugin-1.0' }),
'safe_plugin-1.0'
);
});

it('should reject unsafe plugin names', () => {
assert.throws(
() => buildMarketplacePluginRef({ name: 'safe-plugin; rm -rf /' }),
/Invalid plugin name/
);
});

it('should reject unsafe marketplace names', () => {
assert.throws(
() => buildMarketplacePluginRef({ name: 'safe-plugin', marketplace: 'official && evil' }),
/Invalid marketplace name/
);
});
});

describe('createMarketplacePluginSpawnSpec', () => {
it('should create install args and shell=true only for trusted cmd wrappers', () => {
const spec = createMarketplacePluginSpawnSpec(
{
action: 'install',
name: 'safe-plugin',
marketplace: 'official.market',
scope: 'project',
},
{
findClaudeBinary: () => 'C:\\Users\\Admin\\AppData\\Roaming\\npm\\claude.cmd',
getExpandedPath: () => 'TEST_PATH',
needsShell: (binPath) => /\.cmd$/i.test(binPath),
env: { HOME: 'test-home' },
}
);

assert.equal(spec.command, 'C:\\Users\\Admin\\AppData\\Roaming\\npm\\claude.cmd');
assert.deepEqual(spec.args, ['plugin', 'install', 'safe-plugin@official.market', '--scope', 'project']);
assert.equal(spec.options.shell, true);
assert.ok(spec.options.env);
assert.equal(spec.options.env.PATH, 'TEST_PATH');
assert.equal(spec.options.env.HOME, 'test-home');
});

it('should create uninstall args without shell when binary is not a wrapper', () => {
const spec = createMarketplacePluginSpawnSpec(
{
action: 'uninstall',
name: 'safe-plugin',
marketplace: 'official.market',
scope: 'user',
},
{
findClaudeBinary: () => '/usr/local/bin/claude',
getExpandedPath: () => '/usr/local/bin',
needsShell: () => false,
env: {},
}
);

assert.equal(spec.command, '/usr/local/bin/claude');
assert.deepEqual(spec.args, ['plugin', 'uninstall', 'safe-plugin@official.market', '--scope', 'user']);
assert.equal(spec.options.shell, false);
});

it('should reject unsupported install scopes', () => {
assert.throws(
() => createMarketplacePluginSpawnSpec(
{
action: 'install',
name: 'safe-plugin',
scope: 'admin' as 'user',
},
{
findClaudeBinary: () => '/usr/local/bin/claude',
getExpandedPath: () => '/usr/local/bin',
needsShell: () => false,
env: {},
}
),
/Invalid plugin scope/
);
});

it('should fail fast when Claude CLI is unavailable', () => {
assert.throws(
() => createMarketplacePluginSpawnSpec(
{
action: 'install',
name: 'safe-plugin',
},
{
findClaudeBinary: () => undefined,
getExpandedPath: () => '',
needsShell: () => false,
env: {},
}
),
/Claude CLI not found/
);
});
});
216 changes: 216 additions & 0 deletions src/app/api/plugins/marketplace/browse/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import os from "os";
import type { MarketplacePlugin, PluginComponents } from "@/types";

function getMarketplacesDir(): string {
return path.join(os.homedir(), ".claude", "plugins", "marketplaces");
}

function getInstalledPlugins(): Map<string, { scope: string }> {
const installed = new Map<string, { scope: string }>();

// Primary source: ~/.claude/plugins/installed_plugins.json
const installedPluginsPath = path.join(
os.homedir(), ".claude", "plugins", "installed_plugins.json"
);
try {
if (fs.existsSync(installedPluginsPath)) {
const raw = JSON.parse(fs.readFileSync(installedPluginsPath, "utf-8"));
const plugins = raw.plugins || {};
for (const [key, entries] of Object.entries(plugins)) {
// key format: "name@marketplace"
const pluginName = key.split("@")[0];
const arr = entries as Array<{ scope?: string }>;
const scope = arr[0]?.scope || "user";
installed.set(pluginName, { scope });
installed.set(key, { scope });
}
}
} catch {
// ignore
}

// Fallback: ~/.claude/settings.json enabledPlugins
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
try {
if (fs.existsSync(settingsPath)) {
const raw = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
const plugins = raw.enabledPlugins || {};
if (typeof plugins === "object" && !Array.isArray(plugins)) {
for (const key of Object.keys(plugins)) {
const pluginName = key.split("@")[0];
if (!installed.has(pluginName)) {
installed.set(pluginName, { scope: "user" });
}
if (!installed.has(key)) {
installed.set(key, { scope: "user" });
}
}
}
}
} catch {
// ignore
}

return installed;
}

function detectComponents(pluginDir: string): PluginComponents {
return {
hasSkills: fs.existsSync(path.join(pluginDir, "skills")),
hasAgents: fs.existsSync(path.join(pluginDir, "agents")),
hasHooks:
fs.existsSync(path.join(pluginDir, "hooks")) ||
fs.existsSync(path.join(pluginDir, "hooks.json")),
hasMcp: fs.existsSync(path.join(pluginDir, ".mcp.json")),
hasLsp: fs.existsSync(path.join(pluginDir, ".lsp.json")),
hasCommands: fs.existsSync(path.join(pluginDir, "commands")),
};
}

function readPluginManifest(
pluginDir: string
): Record<string, unknown> | null {
const manifestPath = path.join(pluginDir, ".claude-plugin", "plugin.json");
try {
if (!fs.existsSync(manifestPath)) return null;
return JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
} catch {
return null;
}
}

function readMarketplaceJson(
marketplaceDir: string
): Record<string, unknown> | null {
// Try .claude-plugin/marketplace.json first
const p1 = path.join(marketplaceDir, ".claude-plugin", "marketplace.json");
const p2 = path.join(marketplaceDir, "marketplace.json");
for (const p of [p1, p2]) {
try {
if (fs.existsSync(p)) {
return JSON.parse(fs.readFileSync(p, "utf-8"));
}
} catch {
// ignore
}
}
return null;
}

export async function GET(request: NextRequest) {
try {
const q = (request.nextUrl.searchParams.get("q") || "").toLowerCase();
const category = request.nextUrl.searchParams.get("category") || "";

const marketplacesDir = getMarketplacesDir();
const installedPlugins = getInstalledPlugins();
const plugins: MarketplacePlugin[] = [];

if (!fs.existsSync(marketplacesDir)) {
return NextResponse.json({ plugins: [] });
}

const marketplaces = fs.readdirSync(marketplacesDir, {
withFileTypes: true,
});

for (const mkt of marketplaces) {
if (!mkt.isDirectory()) continue;
const mktDir = path.join(marketplacesDir, mkt.name);
const mktJson = readMarketplaceJson(mktDir);
const marketplaceName = (mktJson?.name as string) || mkt.name;

// Scan plugins directory
const pluginsDir = path.join(mktDir, "plugins");
if (!fs.existsSync(pluginsDir)) continue;

let pluginEntries: fs.Dirent[];
try {
pluginEntries = fs.readdirSync(pluginsDir, { withFileTypes: true });
} catch {
continue;
}

// Also get plugin info from marketplace.json if available
const mktPluginList = Array.isArray(mktJson?.plugins)
? (mktJson.plugins as Array<Record<string, unknown>>)
: [];
const mktPluginMap = new Map<string, Record<string, unknown>>();
for (const mp of mktPluginList) {
if (mp.name) mktPluginMap.set(String(mp.name), mp);
}

for (const entry of pluginEntries) {
if (!entry.isDirectory()) continue;
const pluginDir = path.join(pluginsDir, entry.name);
const manifest = readPluginManifest(pluginDir);
const mktEntry = mktPluginMap.get(entry.name);

const name = (manifest?.name as string) || entry.name;
const description =
(manifest?.description as string) ||
(mktEntry?.description as string) ||
"";
const version =
(manifest?.version as string) ||
(mktEntry?.version as string) ||
undefined;
const authorObj = (manifest?.author || mktEntry?.author) as
| { name: string; email?: string; url?: string }
| string
| undefined;
const author =
typeof authorObj === "string"
? { name: authorObj }
: authorObj || undefined;
const cat =
(manifest?.category as string) ||
(mktEntry?.category as string) ||
undefined;

const components = detectComponents(pluginDir);
const isInstalled = installedPlugins.has(name);
const installedInfo = installedPlugins.get(name);

const plugin: MarketplacePlugin = {
name,
description,
version,
author,
marketplace: marketplaceName,
category: cat,
components,
isInstalled,
installedScope: installedInfo?.scope as
| "user"
| "project"
| "local"
| undefined,
};

// Apply filters
if (q) {
const searchable = `${name} ${description} ${cat || ""}`.toLowerCase();
if (!searchable.includes(q)) continue;
}
if (category && cat !== category) continue;

plugins.push(plugin);
}
}

return NextResponse.json({ plugins });
} catch (error) {
console.error("[plugins/marketplace/browse] Error:", error);
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Failed to browse plugins",
},
{ status: 500 }
);
}
}
Loading
Loading