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) {
+11 -1
View File
@@ -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);
+11 -3
View File
@@ -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) => {
+11
View File
@@ -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;
+22 -2
View File
@@ -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 };
+466
View File
@@ -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();
}
+8
View File
@@ -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;
}