/** * Claude Code Plugin Manager - Conduit-style Plugin System * * Features: * - GitHub-based plugin discovery * - Secure plugin installation with validation * - Version management and updates * - Dependency resolution * - Plugin lifecycle management */ import fs from 'fs/promises' import path from 'path' import { spawn } from 'child_process' import { createHash } from 'crypto' // ============================================================================ // TYPES AND INTERFACES // ============================================================================ export interface PluginMetadata { name: string version: string description: string author: string license?: string repository?: string homepage?: string keywords?: string[] claude: { minVersion?: string maxVersion?: string permissions: string[] hooks?: HookDefinition[] commands?: CommandDefinition[] skills?: SkillDefinition[] } dependencies?: Record } export interface HookDefinition { event: string handler: string priority?: number condition?: string } export interface CommandDefinition { name: string description: string handler: string permissions?: string[] } export interface SkillDefinition { name: string description: string file: string } export interface InstalledPlugin { metadata: PluginMetadata installPath: string version: string installedAt: string lastUpdated: string scope: 'global' | 'project' projectPath?: string enabled: boolean integrity: string } export interface MarketplaceSource { type: 'github' | 'directory' | 'npm' url?: string repo?: string path?: string lastUpdated?: string } export interface PluginDiscoveryResult { name: string description: string version: string author: string source: string downloads?: number stars?: number updated: string } // ============================================================================ // PLUGIN MANAGER CLASS // ============================================================================ export class PluginManager { private pluginsDir: string private cacheDir: string private marketplacesFile: string private installedFile: string private configDir: string constructor(claudeDir: string = path.join(process.env.HOME || '', '.claude')) { this.configDir = claudeDir this.pluginsDir = path.join(claudeDir, 'plugins') this.cacheDir = path.join(this.pluginsDir, 'cache') this.marketplacesFile = path.join(this.pluginsDir, 'known_marketplaces.json') this.installedFile = path.join(this.pluginsDir, 'installed_plugins.json') } // ============================================================================ // INITIALIZATION // ============================================================================ async initialize(): Promise { await this.ensureDirectories() await this.loadMarketplaces() await this.loadInstalledPlugins() } private async ensureDirectories(): Promise { const dirs = [ this.pluginsDir, this.cacheDir, path.join(this.pluginsDir, 'marketplaces'), path.join(this.pluginsDir, 'tmp'), ] for (const dir of dirs) { try { await fs.mkdir(dir, { recursive: true }) } catch (error) { // Directory might already exist } } } // ============================================================================ // MARKETPLACE MANAGEMENT // ============================================================================ async addMarketplace(name: string, source: MarketplaceSource): Promise { const marketplaces = await this.loadMarketplaces() marketplaces[name] = { source, installLocation: path.join(this.pluginsDir, 'marketplaces', name), lastUpdated: new Date().toISOString(), } await fs.writeFile( this.marketplacesFile, JSON.stringify(marketplaces, null, 2) ) // Clone/download marketplace if it's a GitHub repo if (source.type === 'github' && source.repo) { await this.cloneRepository( `https://github.com/${source.repo}.git`, path.join(this.pluginsDir, 'marketplaces', name) ) } } async loadMarketplaces(): Promise> { try { const content = await fs.readFile(this.marketplacesFile, 'utf-8') return JSON.parse(content) } catch { return {} } } // ============================================================================ // PLUGIN DISCOVERY // ============================================================================ async discoverPlugins(query?: string): Promise { const marketplaces = await this.loadMarketplaces() const results: PluginDiscoveryResult[] = [] for (const [name, marketplace]: Object.entries(marketplaces)) { const mp = marketplace as any const pluginsPath = path.join(mp.installLocation, 'plugins') try { const pluginDirs = await fs.readdir(pluginsPath) for (const pluginDir of pluginDirs) { const pluginJsonPath = path.join( pluginsPath, pluginDir, '.claude-plugin', 'plugin.json' ) try { const metadata = JSON.parse( await fs.readFile(pluginJsonPath, 'utf-8') ) as PluginMetadata // Filter by query if provided if ( query && !metadata.name.toLowerCase().includes(query.toLowerCase()) && !metadata.description?.toLowerCase().includes(query.toLowerCase()) && !metadata.keywords?.some((k) => k.toLowerCase().includes(query.toLowerCase()) ) ) { continue } results.push({ name: metadata.name, description: metadata.description, version: metadata.version, author: metadata.author, source: name, updated: new Date().toISOString(), }) } catch { // Skip invalid plugins } } } catch { // Marketplace might not have plugins directory } } return results } // ============================================================================ // PLUGIN INSTALLATION // ============================================================================ async installPlugin( marketplace: string, pluginName: string, scope: 'global' | 'project' = 'global' ): Promise { const marketplaces = await this.loadMarketplaces() const mp = marketplaces[marketplace] as any if (!mp) { throw new Error(`Marketplace "${marketplace}" not found`) } const sourcePath = path.join(mp.installLocation, 'plugins', pluginName) const pluginJsonPath = path.join(sourcePath, '.claude-plugin', 'plugin.json') // Read plugin metadata const metadata: PluginMetadata = JSON.parse( await fs.readFile(pluginJsonPath, 'utf-8') ) // Validate permissions await this.validatePermissions(metadata.claude.permissions) // Calculate integrity hash const integrity = await this.calculateIntegrity(sourcePath) // Install to cache const versionedPath = path.join( this.cacheDir, marketplace, `${pluginName}-${metadata.version}` ) await fs.mkdir(versionedPath, { recursive: true }) await this.copyDirectory(sourcePath, versionedPath) const installedPlugin: InstalledPlugin = { metadata, installPath: versionedPath, version: metadata.version, installedAt: new Date().toISOString(), lastUpdated: new Date().toISOString(), scope, enabled: true, integrity, } // Register plugin await this.registerPlugin(installedPlugin) // Run install script if present const installScript = path.join(sourcePath, 'install.sh') try { await this.runScript(installScript, versionedPath) } catch { // No install script or failed } return installedPlugin } async installFromGitHub( repo: string, scope: 'global' | 'project' = 'global' ): Promise { const [owner, name] = repo.split('/') const tempDir = path.join(this.pluginsDir, 'tmp', `${name}-${Date.now()}`) // Clone repository await this.cloneRepository(`https://github.com/${repo}.git`, tempDir) // Read plugin metadata const pluginJsonPath = path.join(tempDir, '.claude-plugin', 'plugin.json') const metadata: PluginMetadata = JSON.parse( await fs.readFile(pluginJsonPath, 'utf-8') ) // Validate and install const integrity = await this.calculateIntegrity(tempDir) const versionedPath = path.join( this.cacheDir, 'github', `${name}-${metadata.version}` ) await fs.mkdir(versionedPath, { recursive: true }) await this.copyDirectory(tempDir, versionedPath) const installedPlugin: InstalledPlugin = { metadata, installPath: versionedPath, version: metadata.version, installedAt: new Date().toISOString(), lastUpdated: new Date().toISOString(), scope, enabled: true, integrity, } await this.registerPlugin(installedPlugin) // Cleanup temp dir await fs.rm(tempDir, { recursive: true, force: true }) return installedPlugin } // ============================================================================ // PLUGIN MANAGEMENT // ============================================================================ async uninstallPlugin(name: string, marketplace?: string): Promise { const installed = await this.loadInstalledPlugins() const key = marketplace ? `${name}@${marketplace}` : name if (!installed[key]) { throw new Error(`Plugin "${name}" is not installed`) } const plugin = installed[key][0] as InstalledPlugin // Run uninstall script if present const uninstallScript = path.join(plugin.installPath, 'uninstall.sh') try { await this.runScript(uninstallScript, plugin.installPath) } catch { // No uninstall script or failed } // Remove from cache await fs.rm(plugin.installPath, { recursive: true, force: true }) // Unregister delete installed[key] await fs.writeFile( this.installedFile, JSON.stringify({ version: 2, plugins: installed }, null, 2) ) } async enablePlugin(name: string, marketplace?: string): Promise { const installed = await this.loadInstalledPlugins() const key = marketplace ? `${name}@${marketplace}` : name if (installed[key]) { installed[key][0].enabled = true await fs.writeFile( this.installedFile, JSON.stringify({ version: 2, plugins: installed }, null, 2) ) } } async disablePlugin(name: string, marketplace?: string): Promise { const installed = await this.loadInstalledPlugins() const key = marketplace ? `${name}@${marketplace}` : name if (installed[key]) { installed[key][0].enabled = false await fs.writeFile( this.installedFile, JSON.stringify({ version: 2, plugins: installed }, null, 2) ) } } async updatePlugin(name: string, marketplace?: string): Promise { const installed = await this.loadInstalledPlugins() const key = marketplace ? `${name}@${marketplace}` : name if (!installed[key]) { throw new Error(`Plugin "${name}" is not installed`) } const plugin = installed[key][0] as InstalledPlugin // Reinstall to update if (plugin.metadata.repository) { await this.installFromGitHub( plugin.metadata.repository.replace('https://github.com/', ''), plugin.scope ) } else { // Reinstall from marketplace // Implementation depends on marketplace type } } // ============================================================================ // PLUGIN LOADING // ============================================================================ async loadInstalledPlugins(): Promise> { try { const content = await fs.readFile(this.installedFile, 'utf-8') const data = JSON.parse(content) return data.plugins || {} } catch { return {} } } async getEnabledPlugins(): Promise { const installed = await this.loadInstalledPlugins() const enabled: InstalledPlugin[] = [] for (const plugins of Object.values(installed)) { for (const plugin of plugins) { if (plugin.enabled) { enabled.push(plugin as InstalledPlugin) } } } return enabled } // ============================================================================ // SECURITY // ============================================================================ private async validatePermissions(permissions: string[]): Promise { const allowedPermissions = [ 'read:files', 'write:files', 'execute:commands', 'network:request', 'read:config', 'write:config', 'hook:events', ] for (const perm of permissions) { if (!allowedPermissions.includes(perm)) { throw new Error(`Unknown permission: ${perm}`) } } } private async calculateIntegrity(dirPath: string): Promise { const hash = createHash('sha256') const files = await this.getAllFiles(dirPath) for (const file of files.sort()) { const content = await fs.readFile(file) hash.update(content) } return hash.digest('hex') } private async getAllFiles(dirPath: string): Promise { const files: string[] = [] const entries = await fs.readdir(dirPath, { withFileTypes: true }) for (const entry of entries) { const fullPath = path.join(dirPath, entry.name) if (entry.isDirectory()) { files.push(...(await this.getAllFiles(fullPath))) } else { files.push(fullPath) } } return files } // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ private async registerPlugin(plugin: InstalledPlugin): Promise { const installed = await this.loadInstalledPlugins() const key = `${plugin.metadata.name}@${plugin.installPath.split('/').slice(-2).join('/')}` if (!installed[key]) { installed[key] = [] } installed[key].push(plugin) await fs.writeFile( this.installedFile, JSON.stringify({ version: 2, plugins: installed }, null, 2) ) } private async cloneRepository(repoUrl: string, targetPath: string): Promise { return new Promise((resolve, reject) => { const git = spawn('git', ['clone', '--depth', '1', repoUrl, targetPath], { stdio: 'inherit', }) git.on('close', (code) => { if (code === 0) { resolve() } else { reject(new Error(`Git clone failed with code ${code}`)) } }) }) } private async copyDirectory(source: string, target: string): Promise { await fs.mkdir(target, { recursive: true }) const entries = await fs.readdir(source, { withFileTypes: true }) for (const entry of entries) { const srcPath = path.join(source, entry.name) const destPath = path.join(target, entry.name) if (entry.isDirectory()) { await this.copyDirectory(srcPath, destPath) } else { await fs.copyFile(srcPath, destPath) } } } private async runScript(scriptPath: string, cwd: string): Promise { return new Promise((resolve, reject) => { const shell = spawn('bash', [scriptPath], { cwd, stdio: 'inherit', }) shell.on('close', (code) => { if (code === 0) { resolve() } else { reject(new Error(`Script failed with code ${code}`)) } }) }) } } // ============================================================================ // EXPORTS // ============================================================================ export default PluginManager