Files
CodePilot/src/lib/plugin-discovery.ts
T
7418andClaude Opus 4.6 7b48ee1d2a 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>
2026-03-12 21:28:25 +08:00

467 lines
15 KiB
TypeScript

/**
* 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();
}