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) {
|
||||
|
||||
@@ -27,7 +27,14 @@ export interface SSECallbacks {
|
||||
onRewindPoint: (sdkUserMessageId: string) => void;
|
||||
onKeepAlive: () => void;
|
||||
onError: (accumulated: string) => void;
|
||||
onInitMeta?: (meta: { tools?: unknown; slash_commands?: unknown; skills?: unknown }) => void;
|
||||
onInitMeta?: (meta: {
|
||||
tools?: unknown;
|
||||
slash_commands?: unknown;
|
||||
skills?: unknown;
|
||||
plugins?: Array<{ name: string; path: string }>;
|
||||
mcp_servers?: unknown;
|
||||
output_style?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,6 +103,9 @@ function handleSSEEvent(
|
||||
tools: statusData.tools,
|
||||
slash_commands: statusData.slash_commands,
|
||||
skills: statusData.skills,
|
||||
plugins: statusData.plugins,
|
||||
mcp_servers: statusData.mcp_servers,
|
||||
output_style: statusData.output_style,
|
||||
});
|
||||
} else if (statusData.notification) {
|
||||
callbacks.onStatus(statusData.message || statusData.title || undefined);
|
||||
|
||||
@@ -88,12 +88,20 @@ export function useSlashCommands(opts: {
|
||||
const fetchSkills = useCallback(async () => {
|
||||
let apiSkills: PopoverItem[] = [];
|
||||
try {
|
||||
const cwdParam = workingDirectory ? `?cwd=${encodeURIComponent(workingDirectory)}` : '';
|
||||
const res = await fetch(`/api/skills${cwdParam}`);
|
||||
const params = new URLSearchParams();
|
||||
if (workingDirectory) params.set('cwd', workingDirectory);
|
||||
if (sessionId) params.set('sessionId', sessionId);
|
||||
const qs = params.toString();
|
||||
const res = await fetch(`/api/skills${qs ? `?${qs}` : ''}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const skills = data.skills || [];
|
||||
apiSkills = skills
|
||||
.filter((s: { source?: string; loaded?: boolean }) => {
|
||||
// Exclude plugin-source skills that are not loaded in the current session
|
||||
if (s.source === 'plugin' && s.loaded === false) return false;
|
||||
return true;
|
||||
})
|
||||
.map((s: { name: string; description: string; source?: "global" | "project" | "plugin" | "installed" | "sdk"; kind?: SkillKind; installedSource?: "agents" | "claude" }) => ({
|
||||
label: s.name,
|
||||
value: `/${s.name}`,
|
||||
@@ -162,7 +170,7 @@ export function useSlashCommands(opts: {
|
||||
const uniqueSkills = apiSkills.filter(s => !builtInNames.has(s.label));
|
||||
|
||||
return [...enrichedBuiltIns, ...uniqueSkills];
|
||||
}, [workingDirectory, sdkInitMeta, enrichedBuiltIns]);
|
||||
}, [sessionId, workingDirectory, sdkInitMeta, enrichedBuiltIns]);
|
||||
|
||||
// Insert selected item
|
||||
const insertItem = useCallback((item: PopoverItem) => {
|
||||
|
||||
@@ -28,6 +28,7 @@ interface ProviderCapabilityCache {
|
||||
commands: SlashCommand[];
|
||||
account: AccountInfo | null;
|
||||
mcpStatus: McpServerStatus[];
|
||||
loadedPlugins: Array<{ name: string; path: string }>;
|
||||
capturedAt: number;
|
||||
sessionId: string;
|
||||
}
|
||||
@@ -51,6 +52,7 @@ function getOrCreateCache(providerId: string): ProviderCapabilityCache {
|
||||
commands: [],
|
||||
account: null,
|
||||
mcpStatus: [],
|
||||
loadedPlugins: [],
|
||||
capturedAt: 0,
|
||||
sessionId: '',
|
||||
};
|
||||
@@ -147,6 +149,15 @@ export function getCachedMcpStatus(providerId: string = 'env'): McpServerStatus[
|
||||
return getOrCreateCache(providerId).mcpStatus;
|
||||
}
|
||||
|
||||
export function getCachedPlugins(providerId: string = 'env'): Array<{ name: string; path: string }> {
|
||||
return getOrCreateCache(providerId).loadedPlugins;
|
||||
}
|
||||
|
||||
export function setCachedPlugins(providerId: string, plugins: Array<{ name: string; path: string }>): void {
|
||||
const cache = getOrCreateCache(providerId);
|
||||
cache.loadedPlugins = plugins;
|
||||
}
|
||||
|
||||
export function getCapabilityCacheAge(providerId: string = 'env'): number {
|
||||
const { capturedAt } = getOrCreateCache(providerId);
|
||||
return capturedAt === 0 ? Infinity : Date.now() - capturedAt;
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, Permis
|
||||
import { isImageFile } from '@/types';
|
||||
import { registerPendingPermission } from './permission-registry';
|
||||
import { registerConversation, unregisterConversation } from './conversation-registry';
|
||||
import { captureCapabilities } from './agent-sdk-capabilities';
|
||||
import { captureCapabilities, setCachedPlugins } from './agent-sdk-capabilities';
|
||||
import { getSetting, updateSdkSessionId, createPermissionRequest } from './db';
|
||||
import { resolveForClaudeCode, toClaudeCodeEnv } from './provider-resolver';
|
||||
import { findClaudeBinary, findGitBash, getExpandedPath, invalidateClaudePathCache } from './platform';
|
||||
@@ -533,6 +533,11 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
];
|
||||
}
|
||||
|
||||
// Plugins: loaded by the SDK itself via enabledPlugins in ~/.claude/settings.json.
|
||||
// CodePilot does NOT explicitly inject plugins — the SDK reads settingSources
|
||||
// ['user', 'project', 'local'] and resolves enabledPlugins on its own,
|
||||
// ensuring parity with Claude CLI.
|
||||
|
||||
// Resume session if we have an SDK session ID from a previous conversation turn.
|
||||
// Pre-check: verify working_directory exists before attempting resume.
|
||||
// Resume depends on session context (cwd/project scope), so if the
|
||||
@@ -905,7 +910,13 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
const sysMsg = message as SDKSystemMessage;
|
||||
if ('subtype' in sysMsg) {
|
||||
if (sysMsg.subtype === 'init') {
|
||||
const initMsg = sysMsg as SDKSystemMessage & { slash_commands?: unknown; skills?: unknown };
|
||||
const initMsg = sysMsg as SDKSystemMessage & {
|
||||
slash_commands?: unknown;
|
||||
skills?: unknown;
|
||||
plugins?: Array<{ name: string; path: string }>;
|
||||
mcp_servers?: unknown;
|
||||
output_style?: string;
|
||||
};
|
||||
controller.enqueue(formatSSE({
|
||||
type: 'status',
|
||||
data: JSON.stringify({
|
||||
@@ -915,8 +926,17 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
tools: sysMsg.tools,
|
||||
slash_commands: initMsg.slash_commands,
|
||||
skills: initMsg.skills,
|
||||
plugins: initMsg.plugins,
|
||||
mcp_servers: initMsg.mcp_servers,
|
||||
output_style: initMsg.output_style,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Cache loaded plugins from init meta for cross-reference in skills route.
|
||||
// Always set — including empty array — so stale data from a previous
|
||||
// session that had plugins doesn't leak into a session without plugins.
|
||||
// capProviderId is defined at line 786 in the same scope.
|
||||
setCachedPlugins(capProviderId, Array.isArray(initMsg.plugins) ? initMsg.plugins : []);
|
||||
} else if (sysMsg.subtype === 'status') {
|
||||
// SDK sends status messages when permission mode changes (e.g. ExitPlanMode)
|
||||
const statusMsg = sysMsg as SDKSystemMessage & { permissionMode?: string };
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Plugin Discovery Layer — scans marketplace and external plugin directories,
|
||||
* reads plugin manifests, and provides enable/disable state that reads and writes
|
||||
* the official Claude `enabledPlugins` config.
|
||||
*
|
||||
* Enable state resolution reads user + project + local settings layers to match
|
||||
* the SDK's own `settingSources: ['user', 'project', 'local']` resolution.
|
||||
*
|
||||
* Write strategy:
|
||||
* - Default target: user-level ~/.claude/settings.json (matches `claude plugin enable/disable`).
|
||||
* - When a higher-priority layer (project or local) would override the user-level write
|
||||
* and prevent the desired state from taking effect, the write is escalated to the
|
||||
* local layer (.claude/settings.local.json in cwd) which has highest priority and
|
||||
* is gitignored. This requires a cwd parameter.
|
||||
*
|
||||
* Plugin loading into SDK sessions is handled by the SDK itself —
|
||||
* CodePilot does NOT explicitly inject plugins via queryOptions.plugins.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import type { PluginInfo } from '@/types';
|
||||
|
||||
// ==========================================
|
||||
// Types
|
||||
// ==========================================
|
||||
|
||||
interface PluginManifest {
|
||||
name: string;
|
||||
description?: string;
|
||||
author?: { name: string; url?: string };
|
||||
commands?: unknown;
|
||||
skills?: unknown;
|
||||
agents?: unknown;
|
||||
}
|
||||
|
||||
interface BlocklistEntry {
|
||||
plugin: string; // "name@marketplace"
|
||||
}
|
||||
|
||||
interface Blocklist {
|
||||
plugins: BlocklistEntry[];
|
||||
}
|
||||
|
||||
interface DiscoveredPlugin {
|
||||
name: string;
|
||||
description: string;
|
||||
author?: { name: string; url?: string };
|
||||
path: string;
|
||||
marketplace: string;
|
||||
location: 'plugins' | 'external_plugins';
|
||||
hasCommands: boolean;
|
||||
hasSkills: boolean;
|
||||
hasAgents: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* enabledPlugins values can be:
|
||||
* - boolean: simple enable/disable
|
||||
* - string[]: version constraints (extended format)
|
||||
*
|
||||
* We treat any truthy non-false value as "enabled".
|
||||
*/
|
||||
type EnabledPluginsMap = Record<string, boolean | string[]>;
|
||||
|
||||
// ==========================================
|
||||
// Cache (60s TTL)
|
||||
// ==========================================
|
||||
|
||||
let cachedPlugins: DiscoveredPlugin[] | null = null;
|
||||
let cachedBlocklist: Set<string> | null = null;
|
||||
let cachedMergedEnabled: EnabledPluginsMap | null = null;
|
||||
let cachedMergedCwd: string | null = null;
|
||||
let cacheTimestamp = 0;
|
||||
const CACHE_TTL = 60_000;
|
||||
|
||||
function isCacheValid(): boolean {
|
||||
return cachedPlugins !== null && Date.now() - cacheTimestamp < CACHE_TTL;
|
||||
}
|
||||
|
||||
function invalidateCache(): void {
|
||||
cachedPlugins = null;
|
||||
cachedBlocklist = null;
|
||||
cachedMergedEnabled = null;
|
||||
cachedMergedCwd = null;
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Settings file I/O
|
||||
// ==========================================
|
||||
|
||||
function readJsonFile(filePath: string): Record<string, unknown> {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonFile(filePath: string, data: Record<string, unknown>): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// enabledPlugins — multi-layer resolution
|
||||
// ==========================================
|
||||
|
||||
function getUserSettingsPath(): string {
|
||||
return path.join(os.homedir(), '.claude', 'settings.json');
|
||||
}
|
||||
|
||||
function getProjectSettingsPath(cwd: string): string {
|
||||
return path.join(cwd, '.claude', 'settings.json');
|
||||
}
|
||||
|
||||
function getLocalSettingsPath(cwd: string): string {
|
||||
return path.join(cwd, '.claude', 'settings.local.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract enabledPlugins from a settings object, preserving original value types.
|
||||
*/
|
||||
function extractEnabledPlugins(settings: Record<string, unknown>): EnabledPluginsMap {
|
||||
const raw = settings.enabledPlugins;
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return raw as EnabledPluginsMap;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge enabledPlugins across all setting layers.
|
||||
* Resolution order (later wins): user → project → local.
|
||||
* This mirrors the SDK's own settingSources cascade.
|
||||
*/
|
||||
export function readMergedEnabledPlugins(cwd?: string): EnabledPluginsMap {
|
||||
const effectiveCwd = cwd || process.cwd();
|
||||
if (cachedMergedEnabled !== null && cachedMergedCwd === effectiveCwd && isCacheValid()) {
|
||||
return cachedMergedEnabled;
|
||||
}
|
||||
|
||||
const userEnabled = extractEnabledPlugins(readJsonFile(getUserSettingsPath()));
|
||||
const projectEnabled = extractEnabledPlugins(readJsonFile(getProjectSettingsPath(effectiveCwd)));
|
||||
const localEnabled = extractEnabledPlugins(readJsonFile(getLocalSettingsPath(effectiveCwd)));
|
||||
|
||||
// Later layers override earlier ones (same key)
|
||||
const merged: EnabledPluginsMap = { ...userEnabled, ...projectEnabled, ...localEnabled };
|
||||
|
||||
cachedMergedEnabled = merged;
|
||||
cachedMergedCwd = effectiveCwd;
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a single enabledPlugins entry to a specific settings file.
|
||||
* Only touches the target key — preserves all other settings and other plugin entries.
|
||||
*/
|
||||
function writeEnabledPluginEntry(
|
||||
settingsPath: string,
|
||||
pluginKey: string,
|
||||
value: boolean | string[],
|
||||
): void {
|
||||
const settings = readJsonFile(settingsPath);
|
||||
const current = extractEnabledPlugins(settings);
|
||||
current[pluginKey] = value;
|
||||
settings.enabledPlugins = current;
|
||||
writeJsonFile(settingsPath, settings);
|
||||
cachedMergedEnabled = null; // bust cache
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Blocklist
|
||||
// ==========================================
|
||||
|
||||
function getBlocklistPath(): string {
|
||||
return path.join(os.homedir(), '.claude', 'plugins', 'blocklist.json');
|
||||
}
|
||||
|
||||
export function readBlocklist(): Set<string> {
|
||||
if (cachedBlocklist !== null && isCacheValid()) return cachedBlocklist;
|
||||
|
||||
const blocklistPath = getBlocklistPath();
|
||||
const blocked = new Set<string>();
|
||||
|
||||
if (fs.existsSync(blocklistPath)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(blocklistPath, 'utf-8');
|
||||
const data = JSON.parse(raw) as Blocklist;
|
||||
if (Array.isArray(data.plugins)) {
|
||||
for (const entry of data.plugins) {
|
||||
if (entry.plugin) {
|
||||
blocked.add(entry.plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
cachedBlocklist = blocked;
|
||||
return blocked;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Enable/disable resolution
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Check if a value from enabledPlugins represents "enabled".
|
||||
* - `true` → enabled
|
||||
* - `string[]` (version constraints) → enabled (with constraints)
|
||||
* - `false` / absent → disabled
|
||||
*/
|
||||
function isEnabledValue(value: boolean | string[] | undefined): boolean {
|
||||
if (value === undefined || value === false) return false;
|
||||
if (value === true) return true;
|
||||
// string[] (version constraints) — treat as enabled
|
||||
if (Array.isArray(value)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether a plugin is enabled.
|
||||
* Priority: blocklist (hard block) > merged enabledPlugins > default (not enabled).
|
||||
*/
|
||||
function isPluginEnabled(
|
||||
pluginKey: string,
|
||||
blocked: Set<string>,
|
||||
mergedEnabled: EnabledPluginsMap,
|
||||
): boolean {
|
||||
if (blocked.has(pluginKey)) return false;
|
||||
return isEnabledValue(mergedEnabled[pluginKey]);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Plugin Discovery
|
||||
// ==========================================
|
||||
|
||||
function readManifest(pluginDir: string): PluginManifest | null {
|
||||
const manifestPath = path.join(pluginDir, '.claude-plugin', 'plugin.json');
|
||||
if (!fs.existsSync(manifestPath)) return null;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||
return JSON.parse(raw) as PluginManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan marketplace and external plugin directories for installed plugins.
|
||||
*/
|
||||
export function discoverMarketplacePlugins(): DiscoveredPlugin[] {
|
||||
if (isCacheValid()) return cachedPlugins!;
|
||||
|
||||
const plugins: DiscoveredPlugin[] = [];
|
||||
const claudeDir = path.join(os.homedir(), '.claude', 'plugins');
|
||||
|
||||
// Scan marketplaces: ~/.claude/plugins/marketplaces/{mkt}/plugins/*/
|
||||
const marketplacesDir = path.join(claudeDir, 'marketplaces');
|
||||
if (fs.existsSync(marketplacesDir)) {
|
||||
try {
|
||||
const marketplaces = fs.readdirSync(marketplacesDir);
|
||||
for (const mkt of marketplaces) {
|
||||
const pluginsDir = path.join(marketplacesDir, mkt, 'plugins');
|
||||
if (!fs.existsSync(pluginsDir)) continue;
|
||||
|
||||
try {
|
||||
const pluginNames = fs.readdirSync(pluginsDir);
|
||||
for (const pluginName of pluginNames) {
|
||||
const pluginDir = path.join(pluginsDir, pluginName);
|
||||
if (!fs.statSync(pluginDir).isDirectory()) continue;
|
||||
|
||||
const manifest = readManifest(pluginDir);
|
||||
const name = manifest?.name || pluginName;
|
||||
const description = manifest?.description || `Plugin: ${name}`;
|
||||
|
||||
plugins.push({
|
||||
name,
|
||||
description,
|
||||
author: manifest?.author,
|
||||
path: path.resolve(pluginDir),
|
||||
marketplace: mkt,
|
||||
location: 'plugins',
|
||||
hasCommands: fs.existsSync(path.join(pluginDir, 'commands')),
|
||||
hasSkills: fs.existsSync(path.join(pluginDir, 'skills')),
|
||||
hasAgents: fs.existsSync(path.join(pluginDir, 'agents')),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore per-marketplace errors
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Scan external_plugins: ~/.claude/plugins/external_plugins/*/
|
||||
const externalDir = path.join(claudeDir, 'external_plugins');
|
||||
if (fs.existsSync(externalDir)) {
|
||||
try {
|
||||
const externalNames = fs.readdirSync(externalDir);
|
||||
for (const pluginName of externalNames) {
|
||||
const pluginDir = path.join(externalDir, pluginName);
|
||||
if (!fs.statSync(pluginDir).isDirectory()) continue;
|
||||
|
||||
const manifest = readManifest(pluginDir);
|
||||
const name = manifest?.name || pluginName;
|
||||
const description = manifest?.description || `Plugin: ${name}`;
|
||||
|
||||
plugins.push({
|
||||
name,
|
||||
description,
|
||||
author: manifest?.author,
|
||||
path: path.resolve(pluginDir),
|
||||
marketplace: 'external',
|
||||
location: 'external_plugins',
|
||||
hasCommands: fs.existsSync(path.join(pluginDir, 'commands')),
|
||||
hasSkills: fs.existsSync(path.join(pluginDir, 'skills')),
|
||||
hasAgents: fs.existsSync(path.join(pluginDir, 'agents')),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
cachedPlugins = plugins;
|
||||
cacheTimestamp = Date.now();
|
||||
console.log(`[plugin-discovery] Found ${plugins.length} plugins`);
|
||||
return plugins;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Public API
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Get full plugin info list for API responses.
|
||||
* Reads enabledPlugins from all settings layers to match SDK resolution.
|
||||
*
|
||||
* @param cwd - Working directory for project/local settings resolution.
|
||||
* Defaults to process.cwd().
|
||||
*/
|
||||
export function getPluginInfoList(cwd?: string): PluginInfo[] {
|
||||
const plugins = discoverMarketplacePlugins();
|
||||
const blocked = readBlocklist();
|
||||
const mergedEnabled = readMergedEnabledPlugins(cwd);
|
||||
|
||||
return plugins.map((plugin) => {
|
||||
const pluginKey = `${plugin.name}@${plugin.marketplace}`;
|
||||
const isBlocked = blocked.has(pluginKey);
|
||||
const enabled = isPluginEnabled(pluginKey, blocked, mergedEnabled);
|
||||
|
||||
return {
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
author: plugin.author,
|
||||
path: plugin.path,
|
||||
marketplace: plugin.marketplace,
|
||||
location: plugin.location,
|
||||
hasCommands: plugin.hasCommands,
|
||||
hasSkills: plugin.hasSkills,
|
||||
hasAgents: plugin.hasAgents,
|
||||
blocked: isBlocked,
|
||||
enabled,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the value to write for an enable/disable operation.
|
||||
* Preserves string[] (version constraints) when enabling — only writes `true`
|
||||
* if there's no existing constraint across any layer. Always writes `false` for disable.
|
||||
*
|
||||
* @param allLayers - all settings layers to search for existing constraints,
|
||||
* ordered from lowest to highest priority.
|
||||
*/
|
||||
function resolveWriteValue(
|
||||
pluginKey: string,
|
||||
enabled: boolean,
|
||||
allLayers: EnabledPluginsMap[],
|
||||
): boolean | string[] {
|
||||
if (!enabled) return false;
|
||||
// Search all layers (highest priority first) for an existing string[] constraint
|
||||
for (let i = allLayers.length - 1; i >= 0; i--) {
|
||||
const existing = allLayers[i][pluginKey];
|
||||
if (Array.isArray(existing)) return existing;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface SetPluginResult {
|
||||
success: boolean;
|
||||
/** Which settings layer was written to */
|
||||
layer: 'user' | 'local';
|
||||
/** If the write was escalated to local because a higher layer overrides user */
|
||||
escalated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set enabled state for a plugin.
|
||||
*
|
||||
* Default write target: user-level ~/.claude/settings.json.
|
||||
* If a higher-priority layer (project or local) would override the user-level
|
||||
* write, the write is escalated to the local layer (.claude/settings.local.json)
|
||||
* which has the highest priority and is gitignored.
|
||||
*
|
||||
* @param pluginKey - "name@marketplace"
|
||||
* @param enabled - desired state
|
||||
* @param cwd - working directory for project/local layer resolution.
|
||||
* Required for correct escalation detection.
|
||||
*/
|
||||
export function setPluginEnabled(
|
||||
pluginKey: string,
|
||||
enabled: boolean,
|
||||
cwd?: string,
|
||||
): SetPluginResult {
|
||||
const effectiveCwd = cwd || process.cwd();
|
||||
|
||||
// Read each layer independently to detect overrides
|
||||
const userEnabled = extractEnabledPlugins(readJsonFile(getUserSettingsPath()));
|
||||
const projectEnabled = extractEnabledPlugins(readJsonFile(getProjectSettingsPath(effectiveCwd)));
|
||||
const localEnabled = extractEnabledPlugins(readJsonFile(getLocalSettingsPath(effectiveCwd)));
|
||||
|
||||
const allLayers = [userEnabled, projectEnabled, localEnabled];
|
||||
|
||||
// Check if writing to user-level would achieve the desired effective state.
|
||||
// Simulate: set the value at user level, then re-merge.
|
||||
const simulatedUser = { ...userEnabled };
|
||||
simulatedUser[pluginKey] = resolveWriteValue(pluginKey, enabled, allLayers);
|
||||
const simulatedMerged = { ...simulatedUser, ...projectEnabled, ...localEnabled };
|
||||
const simulatedEffective = isEnabledValue(simulatedMerged[pluginKey]);
|
||||
|
||||
if (simulatedEffective === enabled) {
|
||||
// User-level write is sufficient — no higher layer overrides it
|
||||
writeEnabledPluginEntry(getUserSettingsPath(), pluginKey, simulatedUser[pluginKey]);
|
||||
invalidateCache();
|
||||
return { success: true, layer: 'user', escalated: false };
|
||||
}
|
||||
|
||||
// A higher-priority layer overrides the user-level write.
|
||||
// Escalate to local layer (highest priority, gitignored).
|
||||
// Pass allLayers so constraints from any layer (including project) are preserved.
|
||||
const writeValue = resolveWriteValue(pluginKey, enabled, allLayers);
|
||||
writeEnabledPluginEntry(getLocalSettingsPath(effectiveCwd), pluginKey, writeValue);
|
||||
invalidateCache();
|
||||
return { success: true, layer: 'local', escalated: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-refresh cache (e.g. after install/uninstall).
|
||||
*/
|
||||
export function invalidatePluginCache(): void {
|
||||
invalidateCache();
|
||||
}
|
||||
@@ -518,6 +518,14 @@ export interface PermissionResponseRequest {
|
||||
export interface PluginInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
author?: { name: string; url?: string };
|
||||
path: string;
|
||||
marketplace: string;
|
||||
location: 'plugins' | 'external_plugins';
|
||||
hasCommands: boolean;
|
||||
hasSkills: boolean;
|
||||
hasAgents: boolean;
|
||||
blocked: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user