feat: plugin system — real manifest discovery and SDK integration
- New plugin-discovery.ts: scans ~/.claude/plugins/marketplaces/ and external_plugins/, reads .claude-plugin/plugin.json manifests, merges enabledPlugins across user/project/local settings layers with write escalation to local when higher layers override - Rewrite /api/plugins and /api/plugins/[id] to use real plugin manifests with name@marketplace composite keys, blocklist, and multi-layer enable/disable with CAS-style write escalation - SDK plugin loading delegated to SDK itself (no explicit queryOptions.plugins) — ensures parity with Claude CLI's settingSources resolution - Extended init meta forwarding (plugins, mcp_servers, output_style) in claude-client.ts and useSSEStream.ts - Per-provider plugin cache in agent-sdk-capabilities.ts with getCachedPlugins/setCachedPlugins - Skills route: provider-scoped plugin cache lookup via sessionId param, external_plugins commands scan, loaded status annotation - Updated PluginInfo type with marketplace, location, author, capability flags Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
857e9fc413
commit
7b48ee1d2a
9 files changed
+679
-187
No files matched your search
@@ -1,113 +1,101 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import type { PluginInfo, ErrorResponse, SuccessResponse } from '@/types';
|
||||
import { getPluginInfoList, setPluginEnabled } from '@/lib/plugin-discovery';
|
||||
|
||||
function getClaudeDir(): string {
|
||||
return path.join(os.homedir(), '.claude');
|
||||
/**
|
||||
* Plugin ID format: "name@marketplace" (URL-encoded in the path segment).
|
||||
* This matches the official Claude enabledPlugins key format.
|
||||
*/
|
||||
function parsePluginId(rawId: string): { name: string; marketplace: string } | null {
|
||||
const decoded = decodeURIComponent(rawId);
|
||||
const atIdx = decoded.lastIndexOf('@');
|
||||
if (atIdx <= 0) return null; // no @ or @ at start
|
||||
return {
|
||||
name: decoded.slice(0, atIdx),
|
||||
marketplace: decoded.slice(atIdx + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function getSettingsPath(): string {
|
||||
return path.join(getClaudeDir(), 'settings.json');
|
||||
}
|
||||
|
||||
function readSettings(): Record<string, unknown> {
|
||||
const settingsPath = getSettingsPath();
|
||||
if (!fs.existsSync(settingsPath)) return {};
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeSettings(settings: Record<string, unknown>): void {
|
||||
const settingsPath = getSettingsPath();
|
||||
const dir = path.dirname(settingsPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
function findPlugin(plugins: PluginInfo[], name: string, marketplace: string): PluginInfo | undefined {
|
||||
return plugins.find((p) => p.name === name && p.marketplace === marketplace);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
): Promise<NextResponse<{ plugin: PluginInfo } | ErrorResponse>> {
|
||||
const { id } = await params;
|
||||
const pluginName = decodeURIComponent(id);
|
||||
const parsed = parsePluginId(id);
|
||||
|
||||
// Check in commands directory
|
||||
const commandsDir = path.join(getClaudeDir(), 'commands');
|
||||
const filePath = path.join(commandsDir, `${pluginName}.md`);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const firstLine = content.split('\n')[0]?.trim() || '';
|
||||
return NextResponse.json({
|
||||
plugin: {
|
||||
name: pluginName,
|
||||
description: firstLine.startsWith('#')
|
||||
? firstLine.replace(/^#+\s*/, '')
|
||||
: `Skill: /${pluginName}`,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
if (!parsed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid plugin ID format. Expected: name@marketplace' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check in settings customCommands
|
||||
const settings = readSettings();
|
||||
const customCommands = (settings.customCommands || {}) as Record<
|
||||
string,
|
||||
{ description?: string; enabled?: boolean }
|
||||
>;
|
||||
if (customCommands[pluginName]) {
|
||||
const cmd = customCommands[pluginName];
|
||||
return NextResponse.json({
|
||||
plugin: {
|
||||
name: pluginName,
|
||||
description: cmd.description || `Custom command: /${pluginName}`,
|
||||
enabled: cmd.enabled !== false,
|
||||
},
|
||||
});
|
||||
// Accept optional cwd for project/local settings layer resolution
|
||||
const cwd = request.nextUrl.searchParams.get('cwd') || undefined;
|
||||
const plugins = getPluginInfoList(cwd);
|
||||
const plugin = findPlugin(plugins, parsed.name, parsed.marketplace);
|
||||
|
||||
if (!plugin) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
return NextResponse.json({ plugin });
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
): Promise<NextResponse<SuccessResponse | ErrorResponse>> {
|
||||
): Promise<NextResponse<(SuccessResponse & { layer?: string; escalated?: boolean }) | ErrorResponse>> {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const pluginName = decodeURIComponent(id);
|
||||
const parsed = parsePluginId(id);
|
||||
|
||||
if (!parsed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid plugin ID format. Expected: name@marketplace' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { enabled } = body as { enabled: boolean };
|
||||
const { enabled, cwd } = body as { enabled: boolean; cwd?: string };
|
||||
|
||||
const settings = readSettings();
|
||||
if (!settings.customCommands) {
|
||||
settings.customCommands = {};
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'enabled must be a boolean' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const customCommands = settings.customCommands as Record<
|
||||
string,
|
||||
{ description?: string; enabled?: boolean }
|
||||
>;
|
||||
const plugins = getPluginInfoList(cwd);
|
||||
const plugin = findPlugin(plugins, parsed.name, parsed.marketplace);
|
||||
|
||||
if (customCommands[pluginName]) {
|
||||
customCommands[pluginName].enabled = enabled;
|
||||
} else {
|
||||
customCommands[pluginName] = { enabled };
|
||||
if (!plugin) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
writeSettings(settings);
|
||||
return NextResponse.json({ success: true });
|
||||
if (plugin.blocked) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Plugin is blocked and cannot be enabled' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const pluginKey = `${parsed.name}@${parsed.marketplace}`;
|
||||
const result = setPluginEnabled(pluginKey, enabled, cwd);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
layer: result.layer,
|
||||
escalated: result.escalated,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to update plugin' },
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import type { ErrorResponse } from '@/types';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { PluginsResponse, ErrorResponse } from '@/types';
|
||||
import { getPluginInfoList } from '@/lib/plugin-discovery';
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
source: 'global' | 'project';
|
||||
content: string;
|
||||
filePath: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface SkillsResponse {
|
||||
plugins: SkillInfo[];
|
||||
}
|
||||
|
||||
function getClaudeDir(): string {
|
||||
return path.join(os.homedir(), '.claude');
|
||||
}
|
||||
|
||||
function discoverSkills(): SkillInfo[] {
|
||||
const claudeDir = getClaudeDir();
|
||||
const skills: SkillInfo[] = [];
|
||||
|
||||
// Scan for .md skill files in global commands directory
|
||||
const globalCommandsDir = path.join(claudeDir, 'commands');
|
||||
if (fs.existsSync(globalCommandsDir)) {
|
||||
try {
|
||||
const files = fs.readdirSync(globalCommandsDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
const name = file.replace(/\.md$/, '');
|
||||
const filePath = path.join(globalCommandsDir, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const firstLine = content.split('\n')[0]?.trim() || '';
|
||||
const description = firstLine.startsWith('#')
|
||||
? firstLine.replace(/^#+\s*/, '')
|
||||
: `Skill: /${name}`;
|
||||
skills.push({
|
||||
name,
|
||||
description,
|
||||
source: 'global',
|
||||
content,
|
||||
filePath,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
// Scan project-level .claude/commands
|
||||
const projectCommandsDir = path.join(process.cwd(), '.claude', 'commands');
|
||||
if (fs.existsSync(projectCommandsDir)) {
|
||||
try {
|
||||
const files = fs.readdirSync(projectCommandsDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
const name = file.replace(/\.md$/, '');
|
||||
const filePath = path.join(projectCommandsDir, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const firstLine = content.split('\n')[0]?.trim() || '';
|
||||
const description = firstLine.startsWith('#')
|
||||
? firstLine.replace(/^#+\s*/, '')
|
||||
: `Project skill: /${name}`;
|
||||
skills.push({
|
||||
name: `project:${name}`,
|
||||
description,
|
||||
source: 'project',
|
||||
content,
|
||||
filePath,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
export async function GET(): Promise<NextResponse<SkillsResponse | ErrorResponse>> {
|
||||
export async function GET(request: NextRequest): Promise<NextResponse<PluginsResponse | ErrorResponse>> {
|
||||
try {
|
||||
const plugins = discoverSkills();
|
||||
// Accept optional cwd for project/local settings layer resolution
|
||||
const cwd = request.nextUrl.searchParams.get('cwd') || undefined;
|
||||
const plugins = getPluginInfoList(cwd);
|
||||
return NextResponse.json({ plugins });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
|
||||
+78
-15
@@ -32,26 +32,45 @@ function getProjectSkillsDir(cwd?: string): string {
|
||||
|
||||
function getPluginCommandsDirs(): string[] {
|
||||
const dirs: string[] = [];
|
||||
const marketplacesDir = path.join(os.homedir(), ".claude", "plugins", "marketplaces");
|
||||
if (!fs.existsSync(marketplacesDir)) return dirs;
|
||||
const pluginsRoot = path.join(os.homedir(), ".claude", "plugins");
|
||||
|
||||
try {
|
||||
// Scan marketplaces -> each marketplace -> plugins -> each plugin -> commands
|
||||
const marketplaces = fs.readdirSync(marketplacesDir);
|
||||
for (const marketplace of marketplaces) {
|
||||
const pluginsDir = path.join(marketplacesDir, marketplace, "plugins");
|
||||
if (!fs.existsSync(pluginsDir)) continue;
|
||||
const plugins = fs.readdirSync(pluginsDir);
|
||||
for (const plugin of plugins) {
|
||||
const commandsDir = path.join(pluginsDir, plugin, "commands");
|
||||
// Scan marketplaces: ~/.claude/plugins/marketplaces/{mkt}/plugins/*/commands
|
||||
const marketplacesDir = path.join(pluginsRoot, "marketplaces");
|
||||
if (fs.existsSync(marketplacesDir)) {
|
||||
try {
|
||||
const marketplaces = fs.readdirSync(marketplacesDir);
|
||||
for (const marketplace of marketplaces) {
|
||||
const pluginsDir = path.join(marketplacesDir, marketplace, "plugins");
|
||||
if (!fs.existsSync(pluginsDir)) continue;
|
||||
const plugins = fs.readdirSync(pluginsDir);
|
||||
for (const plugin of plugins) {
|
||||
const commandsDir = path.join(pluginsDir, plugin, "commands");
|
||||
if (fs.existsSync(commandsDir)) {
|
||||
dirs.push(commandsDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Scan external plugins: ~/.claude/plugins/external_plugins/*/commands
|
||||
const externalDir = path.join(pluginsRoot, "external_plugins");
|
||||
if (fs.existsSync(externalDir)) {
|
||||
try {
|
||||
const externals = fs.readdirSync(externalDir);
|
||||
for (const plugin of externals) {
|
||||
const commandsDir = path.join(externalDir, plugin, "commands");
|
||||
if (fs.existsSync(commandsDir)) {
|
||||
dirs.push(commandsDir);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
@@ -272,6 +291,20 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Accept optional cwd query param for project-level skills
|
||||
const cwd = request.nextUrl.searchParams.get("cwd") || undefined;
|
||||
|
||||
// Resolve provider ID from session for correct capability cache lookup.
|
||||
// Falls back to 'env' when no session is specified.
|
||||
const sessionId = request.nextUrl.searchParams.get("sessionId");
|
||||
let providerId = 'env';
|
||||
if (sessionId) {
|
||||
try {
|
||||
const { getSession } = await import('@/lib/db');
|
||||
const session = getSession(sessionId);
|
||||
providerId = session?.provider_id || 'env';
|
||||
} catch {
|
||||
// DB not available, fall back to 'env'
|
||||
}
|
||||
}
|
||||
const globalDir = getGlobalCommandsDir();
|
||||
const projectDir = getProjectCommandsDir(cwd);
|
||||
|
||||
@@ -321,13 +354,43 @@ export async function GET(request: NextRequest) {
|
||||
pluginSkills.push(...scanDirectory(dir, "plugin"));
|
||||
}
|
||||
|
||||
const all = [...globalSkills, ...projectSkills, ...dedupedProjectSkills, ...installedSkills, ...pluginSkills];
|
||||
// Cross-reference plugin skills with loaded plugins from SDK init meta.
|
||||
// Uses provider-scoped cache so custom providers see their own plugin set.
|
||||
let loadedPluginPaths: Set<string> | null = null;
|
||||
try {
|
||||
const { getCachedPlugins } = await import('@/lib/agent-sdk-capabilities');
|
||||
const loaded = getCachedPlugins(providerId);
|
||||
loadedPluginPaths = new Set(loaded.map(p => p.path));
|
||||
} catch {
|
||||
// SDK capabilities not available
|
||||
}
|
||||
|
||||
// Annotate plugin skills with loaded status
|
||||
const annotatedPluginSkills = pluginSkills.map(skill => ({
|
||||
...skill,
|
||||
loaded: loadedPluginPaths ? loadedPluginPaths.has(
|
||||
// The skill filePath is inside commands/ — check if any loaded plugin path is a parent
|
||||
(() => {
|
||||
// Walk up from skill filePath to find plugin root
|
||||
let dir = path.dirname(skill.filePath);
|
||||
while (dir && dir !== path.dirname(dir)) {
|
||||
if (loadedPluginPaths!.has(dir)) return dir;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return '';
|
||||
})()
|
||||
) : false,
|
||||
}));
|
||||
|
||||
const all: Array<SkillFile & { loaded?: boolean }> = [
|
||||
...globalSkills, ...projectSkills, ...dedupedProjectSkills, ...installedSkills, ...annotatedPluginSkills,
|
||||
];
|
||||
console.log(`[skills] Found: global=${globalSkills.length}, project=${projectSkills.length}, projectSkills=${dedupedProjectSkills.length}, installed=${installedSkills.length}, plugin=${pluginSkills.length}`);
|
||||
|
||||
// Merge SDK slash commands if available
|
||||
try {
|
||||
const { getCachedCommands } = await import('@/lib/agent-sdk-capabilities');
|
||||
const sdkCommands = getCachedCommands('env');
|
||||
const sdkCommands = getCachedCommands(providerId);
|
||||
if (sdkCommands.length > 0) {
|
||||
const existingNames = new Set(all.map(s => s.name));
|
||||
for (const cmd of sdkCommands) {
|
||||
|
||||
Reference in new issue
Block a user