import fs from 'fs'; import path from 'path'; import os from 'os'; import { spawn } from 'child_process'; const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins'); const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json'); const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry']; const ALLOWED_TYPES = ['iframe', 'react', 'module']; const ALLOWED_SLOTS = ['tab']; export function getPluginsDir() { if (!fs.existsSync(PLUGINS_DIR)) { fs.mkdirSync(PLUGINS_DIR, { recursive: true }); } return PLUGINS_DIR; } export function getPluginsConfig() { try { if (fs.existsSync(PLUGINS_CONFIG_PATH)) { return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8')); } } catch { // Corrupted config, start fresh } return {}; } export function savePluginsConfig(config) { const dir = path.dirname(PLUGINS_CONFIG_PATH); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2)); } export function validateManifest(manifest) { if (!manifest || typeof manifest !== 'object') { return { valid: false, error: 'Manifest must be a JSON object' }; } for (const field of REQUIRED_MANIFEST_FIELDS) { if (!manifest[field] || typeof manifest[field] !== 'string') { return { valid: false, error: `Missing or invalid required field: ${field}` }; } } // Sanitize name — only allow alphanumeric, hyphens, underscores if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) { return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' }; } if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) { return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` }; } if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) { return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` }; } return { valid: true }; } export function scanPlugins() { const pluginsDir = getPluginsDir(); const config = getPluginsConfig(); const plugins = []; let entries; try { entries = fs.readdirSync(pluginsDir, { withFileTypes: true }); } catch { return plugins; } for (const entry of entries) { if (!entry.isDirectory()) continue; const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json'); if (!fs.existsSync(manifestPath)) continue; try { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); const validation = validateManifest(manifest); if (!validation.valid) { console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`); continue; } plugins.push({ name: manifest.name, displayName: manifest.displayName, version: manifest.version || '0.0.0', description: manifest.description || '', author: manifest.author || '', icon: manifest.icon || 'Puzzle', type: manifest.type || 'iframe', slot: manifest.slot || 'tab', entry: manifest.entry, server: manifest.server || null, permissions: manifest.permissions || [], enabled: config[manifest.name]?.enabled !== false, // enabled by default dirName: entry.name, }); } catch (err) { console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message); } } return plugins; } export function getPluginDir(name) { const plugins = scanPlugins(); const plugin = plugins.find(p => p.name === name); if (!plugin) return null; return path.join(getPluginsDir(), plugin.dirName); } export function resolvePluginAssetPath(name, assetPath) { const pluginDir = getPluginDir(name); if (!pluginDir) return null; const resolved = path.resolve(pluginDir, assetPath); // Prevent path traversal — resolved path must be within plugin directory if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) { return null; } if (!fs.existsSync(resolved)) return null; return resolved; } export function installPluginFromGit(url) { return new Promise((resolve, reject) => { // Extract repo name from URL for directory name const urlClean = url.replace(/\.git$/, '').replace(/\/$/, ''); const repoName = urlClean.split('/').pop(); if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) { return reject(new Error('Could not determine a valid directory name from the URL')); } const pluginsDir = getPluginsDir(); const targetDir = path.resolve(pluginsDir, repoName); // Ensure the resolved target directory stays within the plugins directory if (!targetDir.startsWith(pluginsDir + path.sep)) { return reject(new Error('Invalid plugin directory path')); } if (fs.existsSync(targetDir)) { return reject(new Error(`Plugin directory "${repoName}" already exists`)); } const gitProcess = spawn('git', ['clone', '--depth', '1', url, targetDir], { stdio: ['ignore', 'pipe', 'pipe'], }); let stderr = ''; gitProcess.stderr.on('data', (data) => { stderr += data.toString(); }); gitProcess.on('close', (code) => { if (code !== 0) { // Clean up failed clone try { fs.rmSync(targetDir, { recursive: true, force: true }); } catch {} return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`)); } // Validate manifest exists const manifestPath = path.join(targetDir, 'manifest.json'); if (!fs.existsSync(manifestPath)) { fs.rmSync(targetDir, { recursive: true, force: true }); return reject(new Error('Cloned repository does not contain a manifest.json')); } let manifest; try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); } catch { fs.rmSync(targetDir, { recursive: true, force: true }); return reject(new Error('manifest.json is not valid JSON')); } const validation = validateManifest(manifest); if (!validation.valid) { fs.rmSync(targetDir, { recursive: true, force: true }); return reject(new Error(`Invalid manifest: ${validation.error}`)); } // Run npm install if package.json exists. // --ignore-scripts prevents postinstall hooks from executing arbitrary code. const packageJsonPath = path.join(targetDir, 'package.json'); if (fs.existsSync(packageJsonPath)) { const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], { cwd: targetDir, stdio: ['ignore', 'pipe', 'pipe'], }); npmProcess.on('close', (npmCode) => { if (npmCode !== 0) { console.warn(`[Plugins] npm install for ${repoName} exited with code ${npmCode}`); } resolve(manifest); }); npmProcess.on('error', () => { // npm not available, continue anyway resolve(manifest); }); } else { resolve(manifest); } }); gitProcess.on('error', (err) => { reject(new Error(`Failed to spawn git: ${err.message}`)); }); }); } export function updatePluginFromGit(name) { return new Promise((resolve, reject) => { const pluginDir = getPluginDir(name); if (!pluginDir) { return reject(new Error(`Plugin "${name}" not found`)); } // Only fast-forward to avoid silent divergence const gitProcess = spawn('git', ['pull', '--ff-only'], { cwd: pluginDir, stdio: ['ignore', 'pipe', 'pipe'], }); let stderr = ''; gitProcess.stderr.on('data', (data) => { stderr += data.toString(); }); gitProcess.on('close', (code) => { if (code !== 0) { return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`)); } // Re-validate manifest after update const manifestPath = path.join(pluginDir, 'manifest.json'); let manifest; try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); } catch { return reject(new Error('manifest.json is not valid JSON after update')); } const validation = validateManifest(manifest); if (!validation.valid) { return reject(new Error(`Invalid manifest after update: ${validation.error}`)); } // Re-run npm install if package.json exists const packageJsonPath = path.join(pluginDir, 'package.json'); if (fs.existsSync(packageJsonPath)) { const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], { cwd: pluginDir, stdio: ['ignore', 'pipe', 'pipe'], }); npmProcess.on('close', () => resolve(manifest)); npmProcess.on('error', () => resolve(manifest)); } else { resolve(manifest); } }); gitProcess.on('error', (err) => { reject(new Error(`Failed to spawn git: ${err.message}`)); }); }); } export function uninstallPlugin(name) { const pluginDir = getPluginDir(name); if (!pluginDir) { throw new Error(`Plugin "${name}" not found`); } fs.rmSync(pluginDir, { recursive: true, force: true }); // Remove from config const config = getPluginsConfig(); delete config[name]; savePluginsConfig(config); }