From 7b48ee1d2a13bd91841ae3d92ce463dc1df49fb9 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Thu, 12 Mar 2026 21:28:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20plugin=20system=20=E2=80=94=20real=20ma?= =?UTF-8?q?nifest=20discovery=20and=20SDK=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/api/plugins/[id]/route.ts | 142 +++++---- src/app/api/plugins/route.ts | 96 +----- src/app/api/skills/route.ts | 93 +++++- src/hooks/useSSEStream.ts | 12 +- src/hooks/useSlashCommands.ts | 14 +- src/lib/agent-sdk-capabilities.ts | 11 + src/lib/claude-client.ts | 24 +- src/lib/plugin-discovery.ts | 466 ++++++++++++++++++++++++++++++ src/types/index.ts | 8 + 9 files changed, 679 insertions(+), 187 deletions(-) create mode 100644 src/lib/plugin-discovery.ts diff --git a/src/app/api/plugins/[id]/route.ts b/src/app/api/plugins/[id]/route.ts index e0e8f33..b13f13c 100644 --- a/src/app/api/plugins/[id]/route.ts +++ b/src/app/api/plugins/[id]/route.ts @@ -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 { - const settingsPath = getSettingsPath(); - if (!fs.existsSync(settingsPath)) return {}; - try { - return JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); - } catch { - return {}; - } -} - -function writeSettings(settings: Record): 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> { 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> { +): Promise> { 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 }, ); } } diff --git a/src/app/api/plugins/route.ts b/src/app/api/plugins/route.ts index c4e910e..8068e04 100644 --- a/src/app/api/plugins/route.ts +++ b/src/app/api/plugins/route.ts @@ -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> { +export async function GET(request: NextRequest): Promise> { 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( diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index 7890f05..14c59d6 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -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 | 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 = [ + ...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) { diff --git a/src/hooks/useSSEStream.ts b/src/hooks/useSSEStream.ts index 13c7097..14c39fc 100644 --- a/src/hooks/useSSEStream.ts +++ b/src/hooks/useSSEStream.ts @@ -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); diff --git a/src/hooks/useSlashCommands.ts b/src/hooks/useSlashCommands.ts index e728e98..45394aa 100644 --- a/src/hooks/useSlashCommands.ts +++ b/src/hooks/useSlashCommands.ts @@ -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) => { diff --git a/src/lib/agent-sdk-capabilities.ts b/src/lib/agent-sdk-capabilities.ts index e05beb1..81f9e50 100644 --- a/src/lib/agent-sdk-capabilities.ts +++ b/src/lib/agent-sdk-capabilities.ts @@ -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; diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 25bc41e..57345f2 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -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; + mcp_servers?: unknown; + output_style?: string; + }; controller.enqueue(formatSSE({ type: 'status', data: JSON.stringify({ @@ -915,8 +926,17 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream; + +// ========================================== +// Cache (60s TTL) +// ========================================== + +let cachedPlugins: DiscoveredPlugin[] | null = null; +let cachedBlocklist: Set | 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 { + if (!fs.existsSync(filePath)) return {}; + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return {}; + } +} + +function writeJsonFile(filePath: string, data: Record): 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): 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 { + if (cachedBlocklist !== null && isCacheValid()) return cachedBlocklist; + + const blocklistPath = getBlocklistPath(); + const blocked = new Set(); + + 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, + 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(); +} diff --git a/src/types/index.ts b/src/types/index.ts index 3a621ef..a34c17b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; }