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:
7418andClaude Opus 4.6 committed 2026-03-12 21:28:25 +08:00
1 parent 857e9fc413
commit 7b48ee1d2a
9 files changed
+679 -187

No files matched your search

+65 -77
View File
@@ -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 },
);
}
}
+7 -89
View File
@@ -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
View File
@@ -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) {